diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 57dcd55..9bab686 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -28,6 +28,8 @@ const { mockRecoverInterruptedRunningTasks, mockNotifySuccess, mockNotifyError, + mockSendSlackNotification, + mockGetSlackWebhookUrl, } = vi.hoisted(() => ({ mockClaimNextTasks: vi.fn(), mockCompleteTask: vi.fn(), @@ -35,6 +37,8 @@ const { mockRecoverInterruptedRunningTasks: vi.fn(), mockNotifySuccess: vi.fn(), mockNotifyError: vi.fn(), + mockSendSlackNotification: vi.fn(), + mockGetSlackWebhookUrl: vi.fn(), })); vi.mock('../infra/task/index.js', async (importOriginal) => ({ @@ -88,6 +92,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ getErrorMessage: vi.fn((e) => e.message), notifySuccess: mockNotifySuccess, notifyError: mockNotifyError, + sendSlackNotification: mockSendSlackNotification, + getSlackWebhookUrl: mockGetSlackWebhookUrl, })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ @@ -655,4 +661,99 @@ describe('runAllTasks concurrency', () => { expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); }); }); + + describe('Slack webhook notification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + const fakePieceConfig = { + name: 'default', + movements: [{ name: 'implement', personaDisplayName: 'coder' }], + initialMovement: 'implement', + maxMovements: 10, + }; + + beforeEach(() => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + concurrency: 1, + taskPollIntervalMs: 500, + }); + mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); + }); + + it('should send Slack notification on success when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run complete: 1 tasks succeeded', + ); + }); + + it('should send Slack notification on failure when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run finished with errors: 1 failed out of 1 tasks', + ); + }); + + it('should send Slack notification on exception when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + const poolError = new Error('worker pool crashed'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockImplementationOnce(() => { + throw poolError; + }); + + // When / Then + await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed'); + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run error: worker pool crashed', + ); + }); + + it('should not send Slack notification when webhook URL is not set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(undefined); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/slackWebhook.test.ts b/src/__tests__/slackWebhook.test.ts new file mode 100644 index 0000000..5946eb3 --- /dev/null +++ b/src/__tests__/slackWebhook.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for Slack Incoming Webhook notification + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sendSlackNotification, getSlackWebhookUrl } from '../shared/utils/slackWebhook.js'; + +describe('sendSlackNotification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should send POST request with correct payload', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'Hello from TAKT'); + + // Then + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello from TAKT' }), + }), + ); + }); + + it('should include AbortSignal for timeout', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + const callArgs = mockFetch.mock.calls[0]![1] as RequestInit; + expect(callArgs.signal).toBeInstanceOf(AbortSignal); + }); + + it('should write to stderr on non-ok response', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook failed: HTTP 403 Forbidden\n', + ); + }); + + it('should write to stderr on fetch error without throwing', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue(new Error('network timeout')); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: network timeout\n', + ); + }); + + it('should handle non-Error thrown values', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue('string error'); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: string error\n', + ); + }); +}); + +describe('getSlackWebhookUrl', () => { + const envKey = 'TAKT_NOTIFY_WEBHOOK'; + let originalValue: string | undefined; + + beforeEach(() => { + originalValue = process.env[envKey]; + }); + + afterEach(() => { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + }); + + it('should return the webhook URL when environment variable is set', () => { + // Given + process.env[envKey] = 'https://hooks.slack.com/services/T00/B00/xxx'; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBe('https://hooks.slack.com/services/T00/B00/xxx'); + }); + + it('should return undefined when environment variable is not set', () => { + // Given + delete process.env[envKey]; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBeUndefined(); + }); +}); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index bdc50cb..14c4a0d 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -12,7 +12,7 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage, notifyError, notifySuccess } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification } from '../../../shared/utils/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; @@ -259,6 +259,7 @@ export async function runAllTasks( const shouldNotifyRunAbort = globalConfig.notificationSound !== false && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; + const slackWebhookUrl = getSlackWebhookUrl(); const recovered = taskRunner.recoverInterruptedRunningTasks(); if (recovered > 0) { info(`Recovered ${recovered} interrupted running task(s) to pending.`); @@ -290,16 +291,25 @@ export async function runAllTasks( if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run finished with errors: ${String(result.fail)} failed out of ${String(totalCount)} tasks`); + } return; } if (shouldNotifyRunComplete) { notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`); + } } catch (e) { if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`); + } throw e; } } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index b23b774..340d55c 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -7,6 +7,7 @@ export * from './error.js'; export * from './notification.js'; export * from './providerEventLogger.js'; export * from './reportDir.js'; +export * from './slackWebhook.js'; export * from './sleep.js'; export * from './slug.js'; export * from './taskPaths.js'; diff --git a/src/shared/utils/slackWebhook.ts b/src/shared/utils/slackWebhook.ts new file mode 100644 index 0000000..6d208f9 --- /dev/null +++ b/src/shared/utils/slackWebhook.ts @@ -0,0 +1,43 @@ +/** + * Slack Incoming Webhook notification + * + * Sends a text message to a Slack channel via Incoming Webhook. + * Activated only when TAKT_NOTIFY_WEBHOOK environment variable is set. + */ + +const WEBHOOK_ENV_KEY = 'TAKT_NOTIFY_WEBHOOK'; +const TIMEOUT_MS = 10_000; + +/** + * Send a notification message to Slack via Incoming Webhook. + * + * Never throws: errors are written to stderr so the caller's flow is not disrupted. + */ +export async function sendSlackNotification(webhookUrl: string, message: string): Promise { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (!response.ok) { + process.stderr.write( + `Slack webhook failed: HTTP ${String(response.status)} ${response.statusText}\n`, + ); + } + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + process.stderr.write(`Slack webhook error: ${detail}\n`); + } +} + +/** + * Read the Slack webhook URL from the environment. + * + * @returns The webhook URL, or undefined if the environment variable is not set. + */ +export function getSlackWebhookUrl(): string | undefined { + return process.env[WEBHOOK_ENV_KEY]; +}