takt: slackweb (#234)
This commit is contained in:
parent
a3555ebeb4
commit
4fb058aa6a
@ -28,6 +28,8 @@ const {
|
|||||||
mockRecoverInterruptedRunningTasks,
|
mockRecoverInterruptedRunningTasks,
|
||||||
mockNotifySuccess,
|
mockNotifySuccess,
|
||||||
mockNotifyError,
|
mockNotifyError,
|
||||||
|
mockSendSlackNotification,
|
||||||
|
mockGetSlackWebhookUrl,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockClaimNextTasks: vi.fn(),
|
mockClaimNextTasks: vi.fn(),
|
||||||
mockCompleteTask: vi.fn(),
|
mockCompleteTask: vi.fn(),
|
||||||
@ -35,6 +37,8 @@ const {
|
|||||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
mockNotifySuccess: vi.fn(),
|
mockNotifySuccess: vi.fn(),
|
||||||
mockNotifyError: vi.fn(),
|
mockNotifyError: vi.fn(),
|
||||||
|
mockSendSlackNotification: vi.fn(),
|
||||||
|
mockGetSlackWebhookUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
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),
|
getErrorMessage: vi.fn((e) => e.message),
|
||||||
notifySuccess: mockNotifySuccess,
|
notifySuccess: mockNotifySuccess,
|
||||||
notifyError: mockNotifyError,
|
notifyError: mockNotifyError,
|
||||||
|
sendSlackNotification: mockSendSlackNotification,
|
||||||
|
getSlackWebhookUrl: mockGetSlackWebhookUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
||||||
@ -655,4 +661,99 @@ describe('runAllTasks concurrency', () => {
|
|||||||
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
135
src/__tests__/slackWebhook.test.ts
Normal file
135
src/__tests__/slackWebhook.test.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
status,
|
status,
|
||||||
blankLine,
|
blankLine,
|
||||||
} from '../../../shared/ui/index.js';
|
} 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 { getLabel } from '../../../shared/i18n/index.js';
|
||||||
import { executePiece } from './pieceExecution.js';
|
import { executePiece } from './pieceExecution.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||||
@ -259,6 +259,7 @@ export async function runAllTasks(
|
|||||||
const shouldNotifyRunAbort = globalConfig.notificationSound !== false
|
const shouldNotifyRunAbort = globalConfig.notificationSound !== false
|
||||||
&& globalConfig.notificationSoundEvents?.runAbort !== false;
|
&& globalConfig.notificationSoundEvents?.runAbort !== false;
|
||||||
const concurrency = globalConfig.concurrency;
|
const concurrency = globalConfig.concurrency;
|
||||||
|
const slackWebhookUrl = getSlackWebhookUrl();
|
||||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||||
if (recovered > 0) {
|
if (recovered > 0) {
|
||||||
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
||||||
@ -290,16 +291,25 @@ export async function runAllTasks(
|
|||||||
if (shouldNotifyRunAbort) {
|
if (shouldNotifyRunAbort) {
|
||||||
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldNotifyRunComplete) {
|
if (shouldNotifyRunComplete) {
|
||||||
notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) }));
|
notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) }));
|
||||||
}
|
}
|
||||||
|
if (slackWebhookUrl) {
|
||||||
|
await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (shouldNotifyRunAbort) {
|
if (shouldNotifyRunAbort) {
|
||||||
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) }));
|
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) }));
|
||||||
}
|
}
|
||||||
|
if (slackWebhookUrl) {
|
||||||
|
await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export * from './error.js';
|
|||||||
export * from './notification.js';
|
export * from './notification.js';
|
||||||
export * from './providerEventLogger.js';
|
export * from './providerEventLogger.js';
|
||||||
export * from './reportDir.js';
|
export * from './reportDir.js';
|
||||||
|
export * from './slackWebhook.js';
|
||||||
export * from './sleep.js';
|
export * from './sleep.js';
|
||||||
export * from './slug.js';
|
export * from './slug.js';
|
||||||
export * from './taskPaths.js';
|
export * from './taskPaths.js';
|
||||||
|
|||||||
43
src/shared/utils/slackWebhook.ts
Normal file
43
src/shared/utils/slackWebhook.ts
Normal file
@ -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<void> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user