takt: extend-slack-task-notification (#316)
This commit is contained in:
parent
64d06f96c0
commit
e70bceb4a8
@ -49,6 +49,7 @@ const {
|
||||
mockCompleteTask,
|
||||
mockFailTask,
|
||||
mockRecoverInterruptedRunningTasks,
|
||||
mockListAllTaskItems,
|
||||
mockNotifySuccess,
|
||||
mockNotifyError,
|
||||
mockSendSlackNotification,
|
||||
@ -58,6 +59,7 @@ const {
|
||||
mockCompleteTask: vi.fn(),
|
||||
mockFailTask: vi.fn(),
|
||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||
mockListAllTaskItems: vi.fn().mockReturnValue([]),
|
||||
mockNotifySuccess: vi.fn(),
|
||||
mockNotifyError: vi.fn(),
|
||||
mockSendSlackNotification: vi.fn(),
|
||||
@ -71,6 +73,7 @@ vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
completeTask: mockCompleteTask,
|
||||
failTask: mockFailTask,
|
||||
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
||||
listAllTaskItems: mockListAllTaskItems,
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -711,16 +714,37 @@ describe('runAllTasks concurrency', () => {
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.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
|
||||
await runAllTasks('/project');
|
||||
|
||||
// Then
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
||||
webhookUrl,
|
||||
'TAKT Run complete: 1 tasks succeeded',
|
||||
);
|
||||
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||
expect(url).toBe(webhookUrl);
|
||||
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 () => {
|
||||
@ -731,16 +755,36 @@ describe('runAllTasks concurrency', () => {
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.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
|
||||
await runAllTasks('/project');
|
||||
|
||||
// Then
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
||||
webhookUrl,
|
||||
'TAKT Run finished with errors: 1 failed out of 1 tasks',
|
||||
);
|
||||
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||
expect(url).toBe(webhookUrl);
|
||||
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 () => {
|
||||
@ -753,14 +797,28 @@ describe('runAllTasks concurrency', () => {
|
||||
.mockImplementationOnce(() => {
|
||||
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
|
||||
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
|
||||
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
||||
webhookUrl,
|
||||
'TAKT Run error: worker pool crashed',
|
||||
);
|
||||
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
|
||||
expect(url).toBe(webhookUrl);
|
||||
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 () => {
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
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', () => {
|
||||
const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx';
|
||||
@ -133,3 +134,240 @@ describe('getSlackWebhookUrl', () => {
|
||||
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', () => ({
|
||||
resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args),
|
||||
resolveTaskIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
||||
|
||||
@ -42,10 +42,14 @@ export interface PostExecutionOptions {
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export interface PostExecutionResult {
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 commitResult = autoCommitAndPush(execCwd, task, projectCwd);
|
||||
@ -69,6 +73,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise<
|
||||
const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody);
|
||||
if (commentResult.success) {
|
||||
success(`PR updated with comment: ${existingPr.url}`);
|
||||
return { prUrl: existingPr.url };
|
||||
} else {
|
||||
error(`PR comment failed: ${commentResult.error}`);
|
||||
}
|
||||
@ -84,9 +89,12 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise<
|
||||
});
|
||||
if (prResult.success) {
|
||||
success(`PR created: ${prResult.url}`);
|
||||
return { prUrl: prResult.url };
|
||||
} else {
|
||||
error(`PR creation failed: ${prResult.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -6,9 +6,13 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolvePieceConfigValue } from '../../../infra/config/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 { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
|
||||
|
||||
const log = createLogger('task');
|
||||
|
||||
export interface ResolvedTaskExecution {
|
||||
execCwd: 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.
|
||||
* 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,
|
||||
blankLine,
|
||||
} 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 { executePiece } from './pieceExecution.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||
import { fetchIssue, checkGhCli } from '../../../infra/github/index.js';
|
||||
import { runWithWorkerPool } from './parallelExecution.js';
|
||||
import { resolveTaskExecution } from './resolveTask.js';
|
||||
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
||||
import { postExecutionFlow } from './postExecution.js';
|
||||
import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
|
||||
import { generateRunId, toSlackTaskDetail } from './slackSummaryAdapter.js';
|
||||
|
||||
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
||||
|
||||
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> {
|
||||
const {
|
||||
task,
|
||||
@ -190,9 +166,10 @@ export async function executeAndCompleteTask(
|
||||
const taskSuccess = taskRunResult.success;
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
let prUrl: string | undefined;
|
||||
if (taskSuccess && isWorktree) {
|
||||
const issues = resolveTaskIssue(issueNumber);
|
||||
await postExecutionFlow({
|
||||
const postResult = await postExecutionFlow({
|
||||
execCwd,
|
||||
projectCwd: cwd,
|
||||
task: task.name,
|
||||
@ -202,6 +179,7 @@ export async function executeAndCompleteTask(
|
||||
pieceIdentifier: execPiece,
|
||||
issues,
|
||||
});
|
||||
prUrl = postResult.prUrl;
|
||||
}
|
||||
|
||||
const taskResult = buildTaskResult({
|
||||
@ -211,6 +189,7 @@ export async function executeAndCompleteTask(
|
||||
completedAt,
|
||||
branch,
|
||||
worktreePath,
|
||||
prUrl,
|
||||
});
|
||||
persistTaskResult(taskRunner, taskResult);
|
||||
|
||||
@ -261,11 +240,31 @@ export async function runAllTasks(
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = generateRunId();
|
||||
const startTime = Date.now();
|
||||
|
||||
header('Running tasks');
|
||||
if (concurrency > 1) {
|
||||
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 {
|
||||
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
||||
|
||||
@ -279,28 +278,19 @@ 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`);
|
||||
}
|
||||
await sendSlackSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldNotifyRunComplete) {
|
||||
notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) }));
|
||||
}
|
||||
if (slackWebhookUrl) {
|
||||
await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`);
|
||||
}
|
||||
await sendSlackSummary();
|
||||
} catch (e) {
|
||||
if (shouldNotifyRunAbort) {
|
||||
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) }));
|
||||
}
|
||||
if (slackWebhookUrl) {
|
||||
await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`);
|
||||
}
|
||||
await sendSlackSummary();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for backward compatibility with existing consumers
|
||||
export { resolveTaskExecution } from './resolveTask.js';
|
||||
|
||||
@ -10,6 +10,7 @@ interface BuildTaskResultParams {
|
||||
completedAt: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
interface BuildBooleanTaskResultParams {
|
||||
@ -33,7 +34,7 @@ interface PersistTaskErrorOptions {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!taskSuccess && !runResult.reason) {
|
||||
@ -51,6 +52,7 @@ export function buildTaskResult(params: BuildTaskResultParams): TaskResult {
|
||||
completedAt,
|
||||
...(branch ? { branch } : {}),
|
||||
...(worktreePath ? { worktreePath } : {}),
|
||||
...(prUrl ? { prUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
|
||||
export { executePiece, type PieceExecutionResult, type PieceExecutionOptions } from './execute/pieceExecution.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 type { PipelineExecutionOptions } from './execute/types.js';
|
||||
export {
|
||||
|
||||
@ -120,6 +120,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
|
||||
summary: task.summary,
|
||||
branch: task.branch,
|
||||
worktreePath: task.worktree_path,
|
||||
prUrl: task.pr_url,
|
||||
startedAt: task.started_at ?? undefined,
|
||||
completedAt: task.completed_at ?? undefined,
|
||||
ownerPid: task.owner_pid ?? undefined,
|
||||
|
||||
@ -44,6 +44,7 @@ export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
||||
slug: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
worktree_path: z.string().optional(),
|
||||
pr_url: z.string().optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
content_file: z.string().min(1).optional(),
|
||||
task_dir: z.string().optional(),
|
||||
|
||||
@ -121,6 +121,7 @@ export class TaskLifecycleService {
|
||||
failure: undefined,
|
||||
branch: result.branch ?? target.branch,
|
||||
worktree_path: result.worktreePath ?? target.worktree_path,
|
||||
pr_url: result.prUrl ?? target.pr_url,
|
||||
};
|
||||
const tasks = [...current.tasks];
|
||||
tasks[index] = updated;
|
||||
|
||||
@ -30,6 +30,7 @@ export interface TaskResult {
|
||||
completedAt: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeOptions {
|
||||
@ -85,6 +86,7 @@ export interface TaskListItem {
|
||||
summary?: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
prUrl?: string;
|
||||
data?: TaskFileData;
|
||||
failure?: TaskFailure;
|
||||
startedAt?: string;
|
||||
|
||||
@ -41,3 +41,107 @@ export async function sendSlackNotification(webhookUrl: string, message: string)
|
||||
export function getSlackWebhookUrl(): string | undefined {
|
||||
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