diff --git a/Dockerfile b/Dockerfile index cda1b2c..a121cd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,8 +35,8 @@ RUN ARCH=$(dpkg --print-architecture) && \ ENV PATH="/usr/local/go/bin:/home/bot/go/bin:${PATH}" ENV GOPATH="/home/bot/go" -# Install Claude Code CLI -RUN npm install -g @anthropic-ai/claude-code +# Install Claude Code CLI and TypeScript +RUN npm install -g @anthropic-ai/claude-code typescript # Create non-root user RUN useradd -m -s /bin/bash bot \ diff --git a/src/bot/commands/deploy.ts b/src/bot/commands/deploy.ts index 02ba444..509b1db 100644 --- a/src/bot/commands/deploy.ts +++ b/src/bot/commands/deploy.ts @@ -1,6 +1,5 @@ import { WebClient } from '@slack/web-api'; import { ThreadPRManager } from '../../manager/thread-pr'; -import { ThreadCompletionManager } from '../../manager/thread-completion'; import { SessionManager } from '../../orchestrator/session'; import { WorkspaceManager } from '../../workspace/manager'; import { BranchManager } from '../../git/branch'; @@ -16,7 +15,6 @@ export function createDeployHandler( threadPRManager: ThreadPRManager, sessionManager: SessionManager, workspaceManager: WorkspaceManager, - threadCompletionManager: ThreadCompletionManager, botName: string = 'silverback' ) { return async (command: any, client: WebClient): Promise => { @@ -148,8 +146,6 @@ export function createDeployHandler( text: `PR created: ${pr.url}\n\nBranch \`${branch}\` pushed to \`${session.repository}\`.`, }); - // Mark thread as complete: add ✅ reaction and clean up resources - await threadCompletionManager.markComplete(client, channelId, threadTs); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; logger.error('Deploy failed', { threadTs, error }); diff --git a/src/bot/events/__tests__/reaction.test.ts b/src/bot/events/__tests__/reaction.test.ts new file mode 100644 index 0000000..dfb98b9 --- /dev/null +++ b/src/bot/events/__tests__/reaction.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { registerReactionHandler } from '../reaction'; + +vi.mock('../../../git/pr', () => ({ + PRManager: { + getStatus: vi.fn(), + merge: vi.fn(), + }, +})); + +vi.mock('../../../logging/logger', () => ({ + Logger: class { + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, +})); + +import { PRManager } from '../../../git/pr'; + +function createMockClient() { + return { + chat: { + postMessage: vi.fn().mockResolvedValue({}), + }, + reactions: { + add: vi.fn().mockResolvedValue({}), + }, + }; +} + +function createMockEvent(overrides: Record = {}) { + return { + reaction: 'approved', + item: { + type: 'message', + channel: 'C123', + ts: '1234567890.000001', + }, + ...overrides, + }; +} + +describe('registerReactionHandler', () => { + let reactionHandler: Function; + let mockApp: { event: ReturnType }; + let mockSessionManager: { getSession: ReturnType }; + let mockThreadPRManager: { getByThread: ReturnType }; + let mockThreadCompletionManager: { cleanup: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockApp = { + event: vi.fn((eventName: string, handler: Function) => { + if (eventName === 'reaction_added') { + reactionHandler = handler; + } + }), + }; + + mockSessionManager = { + getSession: vi.fn(), + }; + + mockThreadPRManager = { + getByThread: vi.fn(), + }; + + mockThreadCompletionManager = { + cleanup: vi.fn().mockResolvedValue(undefined), + }; + + registerReactionHandler( + mockApp as any, + mockSessionManager as any, + mockThreadPRManager as any, + mockThreadCompletionManager as any, + ); + }); + + it('ignores non-approved reactions', async () => { + const mockClient = createMockClient(); + const event = createMockEvent({ reaction: 'thumbsup' }); + + await reactionHandler({ event, client: mockClient }); + + expect(mockSessionManager.getSession).not.toHaveBeenCalled(); + expect(mockClient.chat.postMessage).not.toHaveBeenCalled(); + }); + + it('ignores non-message items', async () => { + const mockClient = createMockClient(); + const event = createMockEvent({ item: { type: 'file', channel: 'C123', ts: '1234567890.000001' } }); + + await reactionHandler({ event, client: mockClient }); + + expect(mockSessionManager.getSession).not.toHaveBeenCalled(); + expect(mockClient.chat.postMessage).not.toHaveBeenCalled(); + }); + + it('returns silently when no session exists', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue(null); + + await reactionHandler({ event, client: mockClient }); + + expect(mockSessionManager.getSession).toHaveBeenCalledWith('1234567890.000001'); + expect(mockClient.chat.postMessage).not.toHaveBeenCalled(); + expect(mockThreadPRManager.getByThread).not.toHaveBeenCalled(); + }); + + it('posts message when no PR mapping found', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue(null); + + await reactionHandler({ event, client: mockClient }); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'No PR found for this thread. Use `/sb-deploy` to create a PR first.', + }); + expect(vi.mocked(PRManager.getStatus)).not.toHaveBeenCalled(); + }); + + it('posts message when PR mapping has no prNumber', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/42' }); + + await reactionHandler({ event, client: mockClient }); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'No PR found for this thread. Use `/sb-deploy` to create a PR first.', + }); + }); + + it('posts message when no workspace path on session', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: null }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + + await reactionHandler({ event, client: mockClient }); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'Cannot merge: workspace not found for this thread.', + }); + expect(vi.mocked(PRManager.getStatus)).not.toHaveBeenCalled(); + }); + + it('adds :merged: reaction and returns when PR is already merged', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + vi.mocked(PRManager.getStatus).mockResolvedValue('MERGED'); + + await reactionHandler({ event, client: mockClient }); + + expect(vi.mocked(PRManager.getStatus)).toHaveBeenCalledWith('/workspace', 42); + expect(mockClient.reactions.add).toHaveBeenCalledWith({ + channel: 'C123', + timestamp: '1234567890.000001', + name: 'merged', + }); + expect(vi.mocked(PRManager.merge)).not.toHaveBeenCalled(); + expect(mockClient.chat.postMessage).not.toHaveBeenCalled(); + expect(mockThreadCompletionManager.cleanup).not.toHaveBeenCalled(); + }); + + it('does not throw when already_reacted on merged PR', async () => { + const mockClient = createMockClient(); + mockClient.reactions.add.mockRejectedValue(new Error('already_reacted')); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + vi.mocked(PRManager.getStatus).mockResolvedValue('MERGED'); + + await expect(reactionHandler({ event, client: mockClient })).resolves.not.toThrow(); + }); + + it('posts error message when PR is closed', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + vi.mocked(PRManager.getStatus).mockResolvedValue('CLOSED'); + + await reactionHandler({ event, client: mockClient }); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'Cannot merge: PR #42 is closed.', + }); + expect(vi.mocked(PRManager.merge)).not.toHaveBeenCalled(); + expect(mockThreadCompletionManager.cleanup).not.toHaveBeenCalled(); + }); + + it('merges PR, adds reaction, posts confirmation, and calls cleanup on happy path', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + vi.mocked(PRManager.getStatus).mockResolvedValue('OPEN'); + vi.mocked(PRManager.merge).mockResolvedValue(undefined); + + await reactionHandler({ event, client: mockClient }); + + expect(vi.mocked(PRManager.merge)).toHaveBeenCalledWith('/workspace', 42); + expect(mockClient.reactions.add).toHaveBeenCalledWith({ + channel: 'C123', + timestamp: '1234567890.000001', + name: 'merged', + }); + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'PR #42 merged. :merged:', + }); + expect(mockThreadCompletionManager.cleanup).toHaveBeenCalledWith('1234567890.000001'); + }); + + it('posts error message when merge throws', async () => { + const mockClient = createMockClient(); + const event = createMockEvent(); + mockSessionManager.getSession.mockResolvedValue({ workspacePath: '/workspace' }); + mockThreadPRManager.getByThread.mockResolvedValue({ prNumber: 42, prUrl: 'https://github.com/org/repo/pull/42' }); + vi.mocked(PRManager.getStatus).mockResolvedValue('OPEN'); + vi.mocked(PRManager.merge).mockRejectedValue(new Error('merge conflict')); + + await reactionHandler({ event, client: mockClient }); + + expect(mockClient.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '1234567890.000001', + text: 'Failed to merge PR #42: merge conflict', + }); + expect(mockThreadCompletionManager.cleanup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/events/reaction.ts b/src/bot/events/reaction.ts new file mode 100644 index 0000000..f16fa68 --- /dev/null +++ b/src/bot/events/reaction.ts @@ -0,0 +1,107 @@ +import { App } from '@slack/bolt'; +import { ThreadPRManager } from '../../manager/thread-pr'; +import { ThreadCompletionManager } from '../../manager/thread-completion'; +import { SessionManager } from '../../orchestrator/session'; +import { PRManager } from '../../git/pr'; +import { Logger } from '../../logging/logger'; + +const logger = new Logger('reaction-handler'); + +export function registerReactionHandler( + app: App, + sessionManager: SessionManager, + threadPRManager: ThreadPRManager, + threadCompletionManager: ThreadCompletionManager, +): void { + app.event('reaction_added', async ({ event, client }) => { + // Only handle :approved: reaction + if (event.reaction !== 'approved') return; + + // The item must be a message + if (event.item.type !== 'message') return; + + const channelId = event.item.channel; + const threadTs = event.item.ts; + + // Look up the session for this thread + const session = await sessionManager.getSession(threadTs); + if (!session) { + logger.debug('No session found for reacted message', { threadTs }); + return; + } + + // Look up PR mapping + const mapping = await threadPRManager.getByThread(threadTs); + if (!mapping?.prNumber || !mapping?.prUrl) { + logger.info('No PR found for thread, ignoring approved reaction', { threadTs }); + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: 'No PR found for this thread. Use `/sb-deploy` to create a PR first.', + }); + return; + } + + // Need workspace to run gh commands + if (!session.workspacePath) { + logger.warn('No workspace path for session', { threadTs }); + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: 'Cannot merge: workspace not found for this thread.', + }); + return; + } + + try { + // Check PR status first + const status = await PRManager.getStatus(session.workspacePath, mapping.prNumber); + if (status === 'MERGED') { + logger.info('PR already merged', { prNumber: mapping.prNumber, threadTs }); + // Add merged reaction anyway in case it's missing + try { + await client.reactions.add({ channel: channelId, timestamp: threadTs, name: 'merged' }); + } catch { /* already_reacted */ } + return; + } + if (status === 'CLOSED') { + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: `Cannot merge: PR #${mapping.prNumber} is closed.`, + }); + return; + } + + // Merge the PR + await PRManager.merge(session.workspacePath, mapping.prNumber); + logger.info('PR merged via approved reaction', { prNumber: mapping.prNumber, threadTs }); + + // Add :merged: reaction + await client.reactions.add({ + channel: channelId, + timestamp: threadTs, + name: 'merged', + }); + + // Post confirmation + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: `PR #${mapping.prNumber} merged. :merged:`, + }); + + // Clean up thread resources + await threadCompletionManager.cleanup(threadTs); + logger.info('Thread completed via approved reaction', { threadTs }); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to merge PR via reaction', { threadTs, prNumber: mapping.prNumber, error: errMsg }); + await client.chat.postMessage({ + channel: channelId, + thread_ts: threadTs, + text: `Failed to merge PR #${mapping.prNumber}: ${errMsg}`, + }); + } + }); +} diff --git a/src/executor/__tests__/process-executor.test.ts b/src/executor/__tests__/process-executor.test.ts index 78688af..97e966b 100644 --- a/src/executor/__tests__/process-executor.test.ts +++ b/src/executor/__tests__/process-executor.test.ts @@ -61,36 +61,42 @@ describe('ProcessExecutor', () => { }); it('spawns claude with correct default args', async () => { - const mockProc = createMockProcess(); - mockSpawn.mockReturnValue(mockProc); - - const executePromise = executor.execute( - { prompt: 'test prompt' }, - () => {} - ); - - // Simulate successful completion - setImmediate(() => { - mockProc.emit('close', 0); - }); - - await executePromise; - - expect(mockSpawn).toHaveBeenCalledWith( - 'claude', - [ - '--print', - '--verbose', - '--output-format', 'stream-json', - '--dangerously-skip-permissions', - '--allowedTools', '*', - '--', 'test prompt' - ], - expect.objectContaining({ - cwd: '/tmp/claude-work-test', - stdio: ['ignore', 'pipe', 'pipe'] - }) - ); + const savedClaudeModel = process.env.CLAUDE_MODEL; + delete process.env.CLAUDE_MODEL; + try { + const mockProc = createMockProcess(); + mockSpawn.mockReturnValue(mockProc); + + const executePromise = executor.execute( + { prompt: 'test prompt' }, + () => {} + ); + + // Simulate successful completion + setImmediate(() => { + mockProc.emit('close', 0); + }); + + await executePromise; + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + [ + '--print', + '--verbose', + '--output-format', 'stream-json', + '--dangerously-skip-permissions', + '--model', 'sonnet', + '--', 'test prompt' + ], + expect.objectContaining({ + cwd: '/tmp/claude-work-test', + stdio: ['ignore', 'pipe', 'pipe'] + }) + ); + } finally { + process.env.CLAUDE_MODEL = savedClaudeModel; + } }); it('includes --resume when resumeSessionId provided', async () => { @@ -240,12 +246,19 @@ describe('ProcessExecutor', () => { }); it('healthCheck() returns false when credentials file missing', async () => { - mockExistsSync.mockReturnValue(false); - - const result = await executor.healthCheck(); - - expect(result).toBe(false); - expect(mockExistsSync).toHaveBeenCalled(); + const savedOauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + const savedApiKey = process.env.ANTHROPIC_API_KEY; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + try { + mockExistsSync.mockReturnValue(false); + const result = await executor.healthCheck(); + expect(result).toBe(false); + expect(mockExistsSync).toHaveBeenCalled(); + } finally { + process.env.CLAUDE_CODE_OAUTH_TOKEN = savedOauthToken; + process.env.ANTHROPIC_API_KEY = savedApiKey; + } }); it('healthCheck() returns true when credentials exist and claude --version succeeds', async () => { @@ -293,6 +306,6 @@ describe('ProcessExecutor', () => { const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).not.toContain('--dangerously-skip-permissions'); expect(spawnArgs).toContain('--print'); - expect(spawnArgs).toContain('--allowedTools'); + expect(spawnArgs).toContain('--model'); }); }); diff --git a/src/index.ts b/src/index.ts index 2d5221b..64ee3bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { createApp } from './bot/app'; import { registerMentionHandler } from './bot/events/mention'; import { registerMessageHandler } from './bot/events/message'; import { registerChannelJoinHandler } from './bot/events/channel-join'; +import { registerReactionHandler } from './bot/events/reaction'; import { CommandRegistry } from './bot/commands/registry'; import { createClaudeHandler } from './bot/commands/claude'; import { createDeployHandler } from './bot/commands/deploy'; @@ -121,11 +122,12 @@ async function main(): Promise { registerMentionHandler(app, queue, botToken); registerMessageHandler(app, queue, sessionManager, botToken); registerChannelJoinHandler(app, workspaceManager, botUserId); + registerReactionHandler(app, sessionManager, threadPRManager, threadCompletionManager); // Register commands const registry = new CommandRegistry(app, workspaceManager); registry.registerHandler('sb-claude', createClaudeHandler(queue)); - registry.registerHandler('sb-deploy', createDeployHandler(threadPRManager, sessionManager, workspaceManager, threadCompletionManager, botName)); + registry.registerHandler('sb-deploy', createDeployHandler(threadPRManager, sessionManager, workspaceManager, botName)); registry.registerHandler('sb-status', createStatusHandler(queue, auth, executor, workspaceManager)); registry.registerHandler('sb-queue', createQueueHandler(queue)); registry.registerHandler('sb-connect', createConnectHandler(workspaceManager));