takt: extend-slack-task-notification (#316)
This commit is contained in:
parent
64d06f96c0
commit
e70bceb4a8
@ -49,6 +49,7 @@ const {
|
|||||||
mockCompleteTask,
|
mockCompleteTask,
|
||||||
mockFailTask,
|
mockFailTask,
|
||||||
mockRecoverInterruptedRunningTasks,
|
mockRecoverInterruptedRunningTasks,
|
||||||
|
mockListAllTaskItems,
|
||||||
mockNotifySuccess,
|
mockNotifySuccess,
|
||||||
mockNotifyError,
|
mockNotifyError,
|
||||||
mockSendSlackNotification,
|
mockSendSlackNotification,
|
||||||
@ -58,6 +59,7 @@ const {
|
|||||||
mockCompleteTask: vi.fn(),
|
mockCompleteTask: vi.fn(),
|
||||||
mockFailTask: vi.fn(),
|
mockFailTask: vi.fn(),
|
||||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
|
mockListAllTaskItems: vi.fn().mockReturnValue([]),
|
||||||
mockNotifySuccess: vi.fn(),
|
mockNotifySuccess: vi.fn(),
|
||||||
mockNotifyError: vi.fn(),
|
mockNotifyError: vi.fn(),
|
||||||
mockSendSlackNotification: vi.fn(),
|
mockSendSlackNotification: vi.fn(),
|
||||||
@ -71,6 +73,7 @@ vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
|||||||
completeTask: mockCompleteTask,
|
completeTask: mockCompleteTask,
|
||||||
failTask: mockFailTask,
|
failTask: mockFailTask,
|
||||||
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
||||||
|
listAllTaskItems: mockListAllTaskItems,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -711,16 +714,37 @@ describe('runAllTasks concurrency', () => {
|
|||||||
mockClaimNextTasks
|
mockClaimNextTasks
|
||||||
.mockReturnValueOnce([task1])
|
.mockReturnValueOnce([task1])
|
||||||
.mockReturnValueOnce([]);
|
.mockReturnValueOnce([]);
|
||||||
|
mockListAllTaskItems.mockReturnValue([
|
||||||
|
{
|
||||||
|
kind: 'completed',
|
||||||
|
name: 'task-1',
|
||||||
|
createdAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
filePath: '/tasks/task-1.yaml',
|
||||||
|
content: 'Task: task-1',
|
||||||
|
startedAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
completedAt: '2026-02-19T00:00:30.000Z',
|
||||||
|
branch: 'feat/task-1',
|
||||||
|
prUrl: 'https://github.com/org/repo/pull/10',
|
||||||
|
data: { task: 'task-1', piece: 'default', issue: 42 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runAllTasks('/project');
|
await runAllTasks('/project');
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||||
webhookUrl,
|
expect(url).toBe(webhookUrl);
|
||||||
'TAKT Run complete: 1 tasks succeeded',
|
expect(message).toContain('TAKT Run');
|
||||||
);
|
expect(message).toContain('total=1');
|
||||||
|
expect(message).toContain('success=1');
|
||||||
|
expect(message).toContain('failed=0');
|
||||||
|
expect(message).toContain('task-1');
|
||||||
|
expect(message).toContain('piece=default');
|
||||||
|
expect(message).toContain('issue=#42');
|
||||||
|
expect(message).toContain('duration=30s');
|
||||||
|
expect(message).toContain('pr=https://github.com/org/repo/pull/10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send Slack notification on failure when webhook URL is set', async () => {
|
it('should send Slack notification on failure when webhook URL is set', async () => {
|
||||||
@ -731,16 +755,36 @@ describe('runAllTasks concurrency', () => {
|
|||||||
mockClaimNextTasks
|
mockClaimNextTasks
|
||||||
.mockReturnValueOnce([task1])
|
.mockReturnValueOnce([task1])
|
||||||
.mockReturnValueOnce([]);
|
.mockReturnValueOnce([]);
|
||||||
|
mockListAllTaskItems.mockReturnValue([
|
||||||
|
{
|
||||||
|
kind: 'failed',
|
||||||
|
name: 'task-1',
|
||||||
|
createdAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
filePath: '/tasks/task-1.yaml',
|
||||||
|
content: 'Task: task-1',
|
||||||
|
startedAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
completedAt: '2026-02-19T00:00:45.000Z',
|
||||||
|
branch: 'feat/task-1',
|
||||||
|
data: { task: 'task-1', piece: 'review' },
|
||||||
|
failure: { movement: 'ai_review', error: 'Lint failed', last_message: 'Fix attempt timed out' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runAllTasks('/project');
|
await runAllTasks('/project');
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||||
webhookUrl,
|
expect(url).toBe(webhookUrl);
|
||||||
'TAKT Run finished with errors: 1 failed out of 1 tasks',
|
expect(message).toContain('TAKT Run');
|
||||||
);
|
expect(message).toContain('total=1');
|
||||||
|
expect(message).toContain('failed=1');
|
||||||
|
expect(message).toContain('task-1');
|
||||||
|
expect(message).toContain('piece=review');
|
||||||
|
expect(message).toContain('duration=45s');
|
||||||
|
expect(message).toContain('movement=ai_review');
|
||||||
|
expect(message).toContain('error=Lint failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send Slack notification on exception when webhook URL is set', async () => {
|
it('should send Slack notification on exception when webhook URL is set', async () => {
|
||||||
@ -753,14 +797,28 @@ describe('runAllTasks concurrency', () => {
|
|||||||
.mockImplementationOnce(() => {
|
.mockImplementationOnce(() => {
|
||||||
throw poolError;
|
throw poolError;
|
||||||
});
|
});
|
||||||
|
mockListAllTaskItems.mockReturnValue([
|
||||||
|
{
|
||||||
|
kind: 'completed',
|
||||||
|
name: 'task-1',
|
||||||
|
createdAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
filePath: '/tasks/task-1.yaml',
|
||||||
|
content: 'Task: task-1',
|
||||||
|
startedAt: '2026-02-19T00:00:00.000Z',
|
||||||
|
completedAt: '2026-02-19T00:00:15.000Z',
|
||||||
|
data: { task: 'task-1', piece: 'default' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// When / Then
|
// When / Then
|
||||||
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
|
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||||
webhookUrl,
|
expect(url).toBe(webhookUrl);
|
||||||
'TAKT Run error: worker pool crashed',
|
expect(message).toContain('TAKT Run');
|
||||||
);
|
expect(message).toContain('task-1');
|
||||||
|
expect(message).toContain('piece=default');
|
||||||
|
expect(message).toContain('duration=15s');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not send Slack notification when webhook URL is not set', async () => {
|
it('should not send Slack notification when webhook URL is not set', async () => {
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { sendSlackNotification, getSlackWebhookUrl } from '../shared/utils/slackWebhook.js';
|
import { sendSlackNotification, getSlackWebhookUrl, buildSlackRunSummary } from '../shared/utils/slackWebhook.js';
|
||||||
|
import type { SlackRunSummaryParams, SlackTaskDetail } from '../shared/utils/slackWebhook.js';
|
||||||
|
|
||||||
describe('sendSlackNotification', () => {
|
describe('sendSlackNotification', () => {
|
||||||
const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx';
|
const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx';
|
||||||
@ -133,3 +134,240 @@ describe('getSlackWebhookUrl', () => {
|
|||||||
expect(url).toBeUndefined();
|
expect(url).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildSlackRunSummary', () => {
|
||||||
|
function makeTask(overrides: Partial<SlackTaskDetail> & { name: string }): SlackTaskDetail {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
piece: 'default',
|
||||||
|
durationSec: 30,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeParams(overrides?: Partial<SlackRunSummaryParams>): SlackRunSummaryParams {
|
||||||
|
return {
|
||||||
|
runId: 'run-20260219-105815',
|
||||||
|
total: 3,
|
||||||
|
success: 2,
|
||||||
|
failed: 1,
|
||||||
|
durationSec: 120,
|
||||||
|
concurrency: 2,
|
||||||
|
tasks: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should include summary header with runId, counts, duration, and concurrency', () => {
|
||||||
|
// Given
|
||||||
|
const params = makeParams({ tasks: [] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('\uD83C\uDFC3 TAKT Run run-20260219-105815');
|
||||||
|
expect(result).toContain('total=3');
|
||||||
|
expect(result).toContain('success=2');
|
||||||
|
expect(result).toContain('failed=1');
|
||||||
|
expect(result).toContain('duration=120s');
|
||||||
|
expect(result).toContain('concurrency=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display successful task with piece and issue', () => {
|
||||||
|
// Given
|
||||||
|
const task = makeTask({
|
||||||
|
name: 'task-a',
|
||||||
|
piece: 'default',
|
||||||
|
issueNumber: 42,
|
||||||
|
durationSec: 30,
|
||||||
|
branch: 'feat/task-a',
|
||||||
|
worktreePath: '.worktrees/task-a',
|
||||||
|
prUrl: 'https://github.com/org/repo/pull/10',
|
||||||
|
});
|
||||||
|
const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('\u2705 task-a | piece=default | issue=#42 | duration=30s');
|
||||||
|
expect(result).toContain('branch=feat/task-a');
|
||||||
|
expect(result).toContain('worktree=.worktrees/task-a');
|
||||||
|
expect(result).toContain('pr=https://github.com/org/repo/pull/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display failed task with error details', () => {
|
||||||
|
// Given
|
||||||
|
const task = makeTask({
|
||||||
|
name: 'task-b',
|
||||||
|
success: false,
|
||||||
|
piece: 'review',
|
||||||
|
durationSec: 45,
|
||||||
|
branch: 'feat/task-b',
|
||||||
|
failureMovement: 'ai_review',
|
||||||
|
failureError: 'Lint failed',
|
||||||
|
failureLastMessage: 'Fix attempt timed out',
|
||||||
|
});
|
||||||
|
const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('\u274C task-b | piece=review | duration=45s');
|
||||||
|
expect(result).toContain('movement=ai_review');
|
||||||
|
expect(result).toContain('error=Lint failed');
|
||||||
|
expect(result).toContain('last=Fix attempt timed out');
|
||||||
|
expect(result).toContain('branch=feat/task-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit issue when issueNumber is undefined', () => {
|
||||||
|
// Given
|
||||||
|
const task = makeTask({ name: 'task-no-issue', piece: 'default', durationSec: 10 });
|
||||||
|
const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).not.toContain('issue=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit second line when no detail fields exist for success task', () => {
|
||||||
|
// Given
|
||||||
|
const task = makeTask({ name: 'task-minimal', piece: 'default', durationSec: 5 });
|
||||||
|
const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const taskLines = result.split('\n').filter((line) => line.includes('task-minimal'));
|
||||||
|
expect(taskLines).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve task submission order', () => {
|
||||||
|
// Given
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ name: 'first', durationSec: 10 }),
|
||||||
|
makeTask({ name: 'second', success: false, durationSec: 20, failureError: 'err' }),
|
||||||
|
makeTask({ name: 'third', durationSec: 30 }),
|
||||||
|
];
|
||||||
|
const params = makeParams({ total: 3, success: 2, failed: 1, tasks });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const firstIdx = result.indexOf('first');
|
||||||
|
const secondIdx = result.indexOf('second');
|
||||||
|
const thirdIdx = result.indexOf('third');
|
||||||
|
expect(firstIdx).toBeLessThan(secondIdx);
|
||||||
|
expect(secondIdx).toBeLessThan(thirdIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate and add "...and N more" when exceeding character limit', () => {
|
||||||
|
// Given
|
||||||
|
const tasks: SlackTaskDetail[] = [];
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
tasks.push(makeTask({
|
||||||
|
name: `long-task-name-number-${String(i).padStart(3, '0')}`,
|
||||||
|
piece: 'default',
|
||||||
|
durationSec: 60,
|
||||||
|
branch: `feat/long-branch-name-for-testing-purposes-${String(i)}`,
|
||||||
|
worktreePath: `.worktrees/long-task-name-number-${String(i).padStart(3, '0')}`,
|
||||||
|
prUrl: `https://github.com/organization/repository/pull/${String(i + 100)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const params = makeParams({ total: 50, success: 50, failed: 0, tasks });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result.length).toBeLessThanOrEqual(3800);
|
||||||
|
expect(result).toMatch(/\.\.\.and \d+ more$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize newlines in error messages', () => {
|
||||||
|
// Given
|
||||||
|
const task = makeTask({
|
||||||
|
name: 'task-err',
|
||||||
|
success: false,
|
||||||
|
failureError: 'Line one\nLine two\r\nLine three',
|
||||||
|
});
|
||||||
|
const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('error=Line one Line two Line three');
|
||||||
|
expect(result).not.toContain('\n error=Line one\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long error text at 120 characters', () => {
|
||||||
|
// Given
|
||||||
|
const longError = 'A'.repeat(200);
|
||||||
|
const task = makeTask({
|
||||||
|
name: 'task-long-err',
|
||||||
|
success: false,
|
||||||
|
failureError: longError,
|
||||||
|
});
|
||||||
|
const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain(`error=${'A'.repeat(117)}...`);
|
||||||
|
expect(result).not.toContain('A'.repeat(200));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed success and failure tasks with PR present only on some', () => {
|
||||||
|
// Given
|
||||||
|
const tasks = [
|
||||||
|
makeTask({
|
||||||
|
name: 'with-pr',
|
||||||
|
prUrl: 'https://github.com/org/repo/pull/1',
|
||||||
|
branch: 'feat/with-pr',
|
||||||
|
}),
|
||||||
|
makeTask({
|
||||||
|
name: 'no-pr',
|
||||||
|
branch: 'feat/no-pr',
|
||||||
|
}),
|
||||||
|
makeTask({
|
||||||
|
name: 'failed-with-pr',
|
||||||
|
success: false,
|
||||||
|
branch: 'feat/failed',
|
||||||
|
prUrl: 'https://github.com/org/repo/pull/2',
|
||||||
|
failureError: 'build failed',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const params = makeParams({ total: 3, success: 2, failed: 1, tasks });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('pr=https://github.com/org/repo/pull/1');
|
||||||
|
expect(result).toContain('pr=https://github.com/org/repo/pull/2');
|
||||||
|
const lines = result.split('\n');
|
||||||
|
const noPrLine = lines.find((l) => l.includes('no-pr'));
|
||||||
|
expect(noPrLine).not.toContain('pr=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty tasks list', () => {
|
||||||
|
// Given
|
||||||
|
const params = makeParams({ total: 0, success: 0, failed: 0, tasks: [] });
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = buildSlackRunSummary(params);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toContain('\uD83C\uDFC3 TAKT Run');
|
||||||
|
expect(result).toContain('total=0');
|
||||||
|
expect(result).not.toContain('...and');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, m
|
|||||||
|
|
||||||
vi.mock('../features/tasks/execute/resolveTask.js', () => ({
|
vi.mock('../features/tasks/execute/resolveTask.js', () => ({
|
||||||
resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args),
|
resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args),
|
||||||
|
resolveTaskIssue: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
||||||
|
|||||||
@ -42,10 +42,14 @@ export interface PostExecutionOptions {
|
|||||||
repo?: string;
|
repo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostExecutionResult {
|
||||||
|
prUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-commit, push, and optionally create a PR after successful task execution.
|
* Auto-commit, push, and optionally create a PR after successful task execution.
|
||||||
*/
|
*/
|
||||||
export async function postExecutionFlow(options: PostExecutionOptions): Promise<void> {
|
export async function postExecutionFlow(options: PostExecutionOptions): Promise<PostExecutionResult> {
|
||||||
const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options;
|
const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options;
|
||||||
|
|
||||||
const commitResult = autoCommitAndPush(execCwd, task, projectCwd);
|
const commitResult = autoCommitAndPush(execCwd, task, projectCwd);
|
||||||
@ -69,6 +73,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise<
|
|||||||
const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody);
|
const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody);
|
||||||
if (commentResult.success) {
|
if (commentResult.success) {
|
||||||
success(`PR updated with comment: ${existingPr.url}`);
|
success(`PR updated with comment: ${existingPr.url}`);
|
||||||
|
return { prUrl: existingPr.url };
|
||||||
} else {
|
} else {
|
||||||
error(`PR comment failed: ${commentResult.error}`);
|
error(`PR comment failed: ${commentResult.error}`);
|
||||||
}
|
}
|
||||||
@ -84,9 +89,12 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise<
|
|||||||
});
|
});
|
||||||
if (prResult.success) {
|
if (prResult.success) {
|
||||||
success(`PR created: ${prResult.url}`);
|
success(`PR created: ${prResult.url}`);
|
||||||
|
return { prUrl: prResult.url };
|
||||||
} else {
|
} else {
|
||||||
error(`PR creation failed: ${prResult.error}`);
|
error(`PR creation failed: ${prResult.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,13 @@ import * as fs from 'node:fs';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { resolvePieceConfigValue } from '../../../infra/config/index.js';
|
import { resolvePieceConfigValue } from '../../../infra/config/index.js';
|
||||||
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
|
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
|
||||||
|
import { fetchIssue, checkGhCli } from '../../../infra/github/index.js';
|
||||||
import { withProgress } from '../../../shared/ui/index.js';
|
import { withProgress } from '../../../shared/ui/index.js';
|
||||||
|
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||||
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
|
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
|
||||||
|
|
||||||
|
const log = createLogger('task');
|
||||||
|
|
||||||
export interface ResolvedTaskExecution {
|
export interface ResolvedTaskExecution {
|
||||||
execCwd: string;
|
execCwd: string;
|
||||||
execPiece: string;
|
execPiece: string;
|
||||||
@ -60,6 +64,30 @@ function throwIfAborted(signal?: AbortSignal): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a GitHub issue from task data's issue number.
|
||||||
|
* Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable.
|
||||||
|
*/
|
||||||
|
export function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fetchIssue>[] | undefined {
|
||||||
|
if (issueNumber === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghStatus = checkGhCli();
|
||||||
|
if (!ghStatus.available) {
|
||||||
|
log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issue = fetchIssue(issueNumber);
|
||||||
|
return [issue];
|
||||||
|
} catch (e) {
|
||||||
|
log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve execution directory and piece from task data.
|
* Resolve execution directory and piece from task data.
|
||||||
* If the task has worktree settings, create a shared clone and use it as cwd.
|
* If the task has worktree settings, create a shared clone and use it as cwd.
|
||||||
|
|||||||
36
src/features/tasks/execute/slackSummaryAdapter.ts
Normal file
36
src/features/tasks/execute/slackSummaryAdapter.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Adapts TaskListItem to SlackTaskDetail for Slack run summary notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||||
|
import type { SlackTaskDetail } from '../../../shared/utils/index.js';
|
||||||
|
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||||
|
|
||||||
|
export function generateRunId(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number, len: number): string => String(n).padStart(len, '0');
|
||||||
|
return `run-${pad(now.getFullYear(), 4)}${pad(now.getMonth() + 1, 2)}${pad(now.getDate(), 2)}-${pad(now.getHours(), 2)}${pad(now.getMinutes(), 2)}${pad(now.getSeconds(), 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTaskDurationSec(item: TaskListItem): number {
|
||||||
|
if (!item.startedAt || !item.completedAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round((new Date(item.completedAt).getTime() - new Date(item.startedAt).getTime()) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSlackTaskDetail(item: TaskListItem): SlackTaskDetail {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
success: item.kind === 'completed',
|
||||||
|
piece: item.data?.piece ?? DEFAULT_PIECE_NAME,
|
||||||
|
issueNumber: item.data?.issue,
|
||||||
|
durationSec: computeTaskDurationSec(item),
|
||||||
|
branch: item.branch,
|
||||||
|
worktreePath: item.worktreePath,
|
||||||
|
prUrl: item.prUrl,
|
||||||
|
failureMovement: item.failure?.movement,
|
||||||
|
failureError: item.failure?.error,
|
||||||
|
failureLastMessage: item.failure?.last_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -11,45 +11,21 @@ import {
|
|||||||
status,
|
status,
|
||||||
blankLine,
|
blankLine,
|
||||||
} from '../../../shared/ui/index.js';
|
} from '../../../shared/ui/index.js';
|
||||||
import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } 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';
|
||||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||||
import { fetchIssue, checkGhCli } from '../../../infra/github/index.js';
|
|
||||||
import { runWithWorkerPool } from './parallelExecution.js';
|
import { runWithWorkerPool } from './parallelExecution.js';
|
||||||
import { resolveTaskExecution } from './resolveTask.js';
|
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
||||||
import { postExecutionFlow } from './postExecution.js';
|
import { postExecutionFlow } from './postExecution.js';
|
||||||
import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
|
import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
|
||||||
|
import { generateRunId, toSlackTaskDetail } from './slackSummaryAdapter.js';
|
||||||
|
|
||||||
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
||||||
|
|
||||||
const log = createLogger('task');
|
const log = createLogger('task');
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a GitHub issue from task data's issue number.
|
|
||||||
* Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable.
|
|
||||||
*/
|
|
||||||
function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fetchIssue>[] | undefined {
|
|
||||||
if (issueNumber === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ghStatus = checkGhCli();
|
|
||||||
if (!ghStatus.available) {
|
|
||||||
log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber });
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const issue = fetchIssue(issueNumber);
|
|
||||||
return [issue];
|
|
||||||
} catch (e) {
|
|
||||||
log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) });
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
||||||
const {
|
const {
|
||||||
task,
|
task,
|
||||||
@ -190,9 +166,10 @@ export async function executeAndCompleteTask(
|
|||||||
const taskSuccess = taskRunResult.success;
|
const taskSuccess = taskRunResult.success;
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
let prUrl: string | undefined;
|
||||||
if (taskSuccess && isWorktree) {
|
if (taskSuccess && isWorktree) {
|
||||||
const issues = resolveTaskIssue(issueNumber);
|
const issues = resolveTaskIssue(issueNumber);
|
||||||
await postExecutionFlow({
|
const postResult = await postExecutionFlow({
|
||||||
execCwd,
|
execCwd,
|
||||||
projectCwd: cwd,
|
projectCwd: cwd,
|
||||||
task: task.name,
|
task: task.name,
|
||||||
@ -202,6 +179,7 @@ export async function executeAndCompleteTask(
|
|||||||
pieceIdentifier: execPiece,
|
pieceIdentifier: execPiece,
|
||||||
issues,
|
issues,
|
||||||
});
|
});
|
||||||
|
prUrl = postResult.prUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskResult = buildTaskResult({
|
const taskResult = buildTaskResult({
|
||||||
@ -211,6 +189,7 @@ export async function executeAndCompleteTask(
|
|||||||
completedAt,
|
completedAt,
|
||||||
branch,
|
branch,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
prUrl,
|
||||||
});
|
});
|
||||||
persistTaskResult(taskRunner, taskResult);
|
persistTaskResult(taskRunner, taskResult);
|
||||||
|
|
||||||
@ -261,11 +240,31 @@ export async function runAllTasks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runId = generateRunId();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
header('Running tasks');
|
header('Running tasks');
|
||||||
if (concurrency > 1) {
|
if (concurrency > 1) {
|
||||||
info(`Concurrency: ${concurrency}`);
|
info(`Concurrency: ${concurrency}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendSlackSummary = async (): Promise<void> => {
|
||||||
|
if (!slackWebhookUrl) return;
|
||||||
|
const durationSec = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
const tasks = taskRunner.listAllTaskItems().map(toSlackTaskDetail);
|
||||||
|
const successCount = tasks.filter((t) => t.success).length;
|
||||||
|
const message = buildSlackRunSummary({
|
||||||
|
runId,
|
||||||
|
total: tasks.length,
|
||||||
|
success: successCount,
|
||||||
|
failed: tasks.length - successCount,
|
||||||
|
durationSec,
|
||||||
|
concurrency,
|
||||||
|
tasks,
|
||||||
|
});
|
||||||
|
await sendSlackNotification(slackWebhookUrl, message);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
||||||
|
|
||||||
@ -279,28 +278,19 @@ 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 sendSlackSummary();
|
||||||
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 sendSlackSummary();
|
||||||
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 sendSlackSummary();
|
||||||
await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`);
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backward compatibility with existing consumers
|
|
||||||
export { resolveTaskExecution } from './resolveTask.js';
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ interface BuildTaskResultParams {
|
|||||||
completedAt: string;
|
completedAt: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worktreePath?: string;
|
worktreePath?: string;
|
||||||
|
prUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BuildBooleanTaskResultParams {
|
interface BuildBooleanTaskResultParams {
|
||||||
@ -33,7 +34,7 @@ interface PersistTaskErrorOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildTaskResult(params: BuildTaskResultParams): TaskResult {
|
export function buildTaskResult(params: BuildTaskResultParams): TaskResult {
|
||||||
const { task, runResult, startedAt, completedAt, branch, worktreePath } = params;
|
const { task, runResult, startedAt, completedAt, branch, worktreePath, prUrl } = params;
|
||||||
const taskSuccess = runResult.success;
|
const taskSuccess = runResult.success;
|
||||||
|
|
||||||
if (!taskSuccess && !runResult.reason) {
|
if (!taskSuccess && !runResult.reason) {
|
||||||
@ -51,6 +52,7 @@ export function buildTaskResult(params: BuildTaskResultParams): TaskResult {
|
|||||||
completedAt,
|
completedAt,
|
||||||
...(branch ? { branch } : {}),
|
...(branch ? { branch } : {}),
|
||||||
...(worktreePath ? { worktreePath } : {}),
|
...(worktreePath ? { worktreePath } : {}),
|
||||||
|
...(prUrl ? { prUrl } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
export { executePiece, type PieceExecutionResult, type PieceExecutionOptions } from './execute/pieceExecution.js';
|
export { executePiece, type PieceExecutionResult, type PieceExecutionOptions } from './execute/pieceExecution.js';
|
||||||
export { executeTask, runAllTasks, type TaskExecutionOptions } from './execute/taskExecution.js';
|
export { executeTask, runAllTasks, type TaskExecutionOptions } from './execute/taskExecution.js';
|
||||||
export { executeAndCompleteTask, resolveTaskExecution } from './execute/taskExecution.js';
|
export { executeAndCompleteTask } from './execute/taskExecution.js';
|
||||||
|
export { resolveTaskExecution } from './execute/resolveTask.js';
|
||||||
export { withPersonaSession } from './execute/session.js';
|
export { withPersonaSession } from './execute/session.js';
|
||||||
export type { PipelineExecutionOptions } from './execute/types.js';
|
export type { PipelineExecutionOptions } from './execute/types.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -120,6 +120,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
|
|||||||
summary: task.summary,
|
summary: task.summary,
|
||||||
branch: task.branch,
|
branch: task.branch,
|
||||||
worktreePath: task.worktree_path,
|
worktreePath: task.worktree_path,
|
||||||
|
prUrl: task.pr_url,
|
||||||
startedAt: task.started_at ?? undefined,
|
startedAt: task.started_at ?? undefined,
|
||||||
completedAt: task.completed_at ?? undefined,
|
completedAt: task.completed_at ?? undefined,
|
||||||
ownerPid: task.owner_pid ?? undefined,
|
ownerPid: task.owner_pid ?? undefined,
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
worktree_path: z.string().optional(),
|
worktree_path: z.string().optional(),
|
||||||
|
pr_url: z.string().optional(),
|
||||||
content: z.string().min(1).optional(),
|
content: z.string().min(1).optional(),
|
||||||
content_file: z.string().min(1).optional(),
|
content_file: z.string().min(1).optional(),
|
||||||
task_dir: z.string().optional(),
|
task_dir: z.string().optional(),
|
||||||
|
|||||||
@ -121,6 +121,7 @@ export class TaskLifecycleService {
|
|||||||
failure: undefined,
|
failure: undefined,
|
||||||
branch: result.branch ?? target.branch,
|
branch: result.branch ?? target.branch,
|
||||||
worktree_path: result.worktreePath ?? target.worktree_path,
|
worktree_path: result.worktreePath ?? target.worktree_path,
|
||||||
|
pr_url: result.prUrl ?? target.pr_url,
|
||||||
};
|
};
|
||||||
const tasks = [...current.tasks];
|
const tasks = [...current.tasks];
|
||||||
tasks[index] = updated;
|
tasks[index] = updated;
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export interface TaskResult {
|
|||||||
completedAt: string;
|
completedAt: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worktreePath?: string;
|
worktreePath?: string;
|
||||||
|
prUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeOptions {
|
export interface WorktreeOptions {
|
||||||
@ -85,6 +86,7 @@ export interface TaskListItem {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worktreePath?: string;
|
worktreePath?: string;
|
||||||
|
prUrl?: string;
|
||||||
data?: TaskFileData;
|
data?: TaskFileData;
|
||||||
failure?: TaskFailure;
|
failure?: TaskFailure;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
|||||||
@ -41,3 +41,107 @@ export async function sendSlackNotification(webhookUrl: string, message: string)
|
|||||||
export function getSlackWebhookUrl(): string | undefined {
|
export function getSlackWebhookUrl(): string | undefined {
|
||||||
return process.env[WEBHOOK_ENV_KEY];
|
return process.env[WEBHOOK_ENV_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SlackTaskDetail {
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
piece: string;
|
||||||
|
issueNumber?: number;
|
||||||
|
durationSec: number;
|
||||||
|
branch?: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
prUrl?: string;
|
||||||
|
failureMovement?: string;
|
||||||
|
failureError?: string;
|
||||||
|
failureLastMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlackRunSummaryParams {
|
||||||
|
runId: string;
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
durationSec: number;
|
||||||
|
concurrency: number;
|
||||||
|
tasks: SlackTaskDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAR_LIMIT = 3_800;
|
||||||
|
const TRUNCATE_LENGTH = 120;
|
||||||
|
|
||||||
|
function normalizeText(text: string): string {
|
||||||
|
return text.replace(/[\r\n]+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskLines(task: SlackTaskDetail): string {
|
||||||
|
const icon = task.success ? '\u2705' : '\u274C';
|
||||||
|
const parts = [
|
||||||
|
`${icon} ${task.name}`,
|
||||||
|
`piece=${task.piece}`,
|
||||||
|
];
|
||||||
|
if (task.issueNumber !== undefined) {
|
||||||
|
parts.push(`issue=#${String(task.issueNumber)}`);
|
||||||
|
}
|
||||||
|
parts.push(`duration=${String(task.durationSec)}s`);
|
||||||
|
const line1 = parts.join(' | ');
|
||||||
|
|
||||||
|
const line2Parts: string[] = [];
|
||||||
|
if (task.success) {
|
||||||
|
if (task.branch) line2Parts.push(`branch=${task.branch}`);
|
||||||
|
if (task.worktreePath) line2Parts.push(`worktree=${task.worktreePath}`);
|
||||||
|
if (task.prUrl) line2Parts.push(`pr=${task.prUrl}`);
|
||||||
|
} else {
|
||||||
|
if (task.failureMovement) line2Parts.push(`movement=${task.failureMovement}`);
|
||||||
|
if (task.failureError) {
|
||||||
|
line2Parts.push(`error=${truncateText(normalizeText(task.failureError), TRUNCATE_LENGTH)}`);
|
||||||
|
}
|
||||||
|
if (task.failureLastMessage) {
|
||||||
|
line2Parts.push(`last=${truncateText(normalizeText(task.failureLastMessage), TRUNCATE_LENGTH)}`);
|
||||||
|
}
|
||||||
|
if (task.branch) line2Parts.push(`branch=${task.branch}`);
|
||||||
|
if (task.prUrl) line2Parts.push(`pr=${task.prUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line2Parts.length === 0) {
|
||||||
|
return line1;
|
||||||
|
}
|
||||||
|
return `${line1}\n ${line2Parts.join(' | ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSlackRunSummary(params: SlackRunSummaryParams): string {
|
||||||
|
const headerLine = `\uD83C\uDFC3 TAKT Run ${params.runId}`;
|
||||||
|
const statsLine = `total=${String(params.total)} | success=${String(params.success)} | failed=${String(params.failed)} | duration=${String(params.durationSec)}s | concurrency=${String(params.concurrency)}`;
|
||||||
|
const summaryBlock = `${headerLine}\n${statsLine}`;
|
||||||
|
|
||||||
|
let result = summaryBlock;
|
||||||
|
let includedCount = 0;
|
||||||
|
|
||||||
|
for (const task of params.tasks) {
|
||||||
|
const taskBlock = formatTaskLines(task);
|
||||||
|
const candidate = `${result}\n\n${taskBlock}`;
|
||||||
|
|
||||||
|
const remaining = params.tasks.length - includedCount - 1;
|
||||||
|
const suffixLength = remaining > 0 ? `\n...and ${String(remaining)} more`.length : 0;
|
||||||
|
|
||||||
|
if (candidate.length + suffixLength > CHAR_LIMIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = candidate;
|
||||||
|
includedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const omitted = params.tasks.length - includedCount;
|
||||||
|
if (omitted > 0) {
|
||||||
|
result = `${result}\n...and ${String(omitted)} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user