takt/src/features/pipeline/execute.ts
nrs 1acd991e7e
feat: pipeline モードでの Slack 通知を強化 (#346) (#347)
* feat: pipeline モードでの Slack 通知を try/finally パターンで実装

- executePipeline の本体を try/finally で囲み、全終了パスで通知を送信
- PipelineResult でスプレッド演算子による不変状態追跡
- notifySlack ヘルパーで webhook 未設定時は即 return
- 既存の早期リターンパターンを保持したまま通知機能を追加

* refactor: executePipeline のオーケストレーションと各ステップを分離

- execute.ts: オーケストレーション + Slack 通知 (157行)
- steps.ts: 5つのステップ関数 + テンプレートヘルパー (233行)
- runPipeline で全ステップを同じ抽象レベルに揃えた
- buildResult ヘルパーで let 再代入を最小化

* test: commitAndPush の git 操作失敗時の exit code 4 テストを追加
2026-02-22 21:06:29 +09:00

158 lines
5.2 KiB
TypeScript

/**
* Pipeline orchestration
*
* Thin orchestrator that coordinates pipeline steps:
* 1. Resolve task content
* 2. Prepare execution environment
* 3. Run piece
* 4. Commit & push
* 5. Create PR
*
* Each step is implemented in steps.ts.
*/
import { resolveConfigValues } from '../../infra/config/index.js';
import { info, error, status, blankLine } from '../../shared/ui/index.js';
import { createLogger, getErrorMessage, getSlackWebhookUrl, sendSlackNotification, buildSlackRunSummary } from '../../shared/utils/index.js';
import type { SlackTaskDetail } from '../../shared/utils/index.js';
import { generateRunId } from '../tasks/execute/slackSummaryAdapter.js';
import type { PipelineExecutionOptions } from '../tasks/index.js';
import {
EXIT_ISSUE_FETCH_FAILED,
EXIT_PIECE_FAILED,
EXIT_GIT_OPERATION_FAILED,
EXIT_PR_CREATION_FAILED,
} from '../../shared/exitCodes.js';
import {
resolveTaskContent,
resolveExecutionContext,
runPiece,
commitAndPush,
submitPullRequest,
buildCommitMessage,
type ExecutionContext,
} from './steps.js';
export type { PipelineExecutionOptions };
const log = createLogger('pipeline');
// ---- Pipeline orchestration ----
interface PipelineOutcome {
exitCode: number;
result: PipelineResult;
}
async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineOutcome> {
const { cwd, piece, autoPr, skipGit } = options;
const pipelineConfig = resolveConfigValues(cwd, ['pipeline']).pipeline;
const buildResult = (overrides: Partial<PipelineResult> = {}): PipelineResult => ({
success: false, piece, issueNumber: options.issueNumber, ...overrides,
});
// Step 1: Resolve task content
const taskContent = resolveTaskContent(options);
if (!taskContent) return { exitCode: EXIT_ISSUE_FETCH_FAILED, result: buildResult() };
// Step 2: Prepare execution environment
let context: ExecutionContext;
try {
context = await resolveExecutionContext(cwd, taskContent.task, options, pipelineConfig);
} catch (err) {
error(`Failed to prepare execution environment: ${getErrorMessage(err)}`);
return { exitCode: EXIT_GIT_OPERATION_FAILED, result: buildResult() };
}
// Step 3: Run piece
log.info('Pipeline piece execution starting', { piece, branch: context.branch, skipGit, issueNumber: options.issueNumber });
const pieceOk = await runPiece(cwd, piece, taskContent.task, context.execCwd, options);
if (!pieceOk) return { exitCode: EXIT_PIECE_FAILED, result: buildResult({ branch: context.branch }) };
// Step 4: Commit & push
if (!skipGit && context.branch) {
const commitMessage = buildCommitMessage(pipelineConfig, taskContent.issue, options.task);
if (!commitAndPush(context.execCwd, cwd, context.branch, commitMessage, context.isWorktree)) {
return { exitCode: EXIT_GIT_OPERATION_FAILED, result: buildResult({ branch: context.branch }) };
}
}
// Step 5: Create PR
let prUrl: string | undefined;
if (autoPr && !skipGit && context.branch) {
prUrl = submitPullRequest(cwd, context.branch, context.baseBranch, taskContent, piece, pipelineConfig, options);
if (!prUrl) return { exitCode: EXIT_PR_CREATION_FAILED, result: buildResult({ branch: context.branch }) };
} else if (autoPr && skipGit) {
info('--auto-pr is ignored when --skip-git is specified (no push was performed)');
}
// Summary
blankLine();
status('Issue', taskContent.issue ? `#${taskContent.issue.number} "${taskContent.issue.title}"` : 'N/A');
status('Branch', context.branch ?? '(current)');
status('Piece', piece);
status('Result', 'Success', 'green');
return { exitCode: 0, result: buildResult({ success: true, branch: context.branch, prUrl }) };
}
// ---- Public API ----
/**
* Execute the full pipeline.
*
* Returns a process exit code (0 on success, 2-5 on specific failures).
*/
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
const startTime = Date.now();
const runId = generateRunId();
let pipelineResult: PipelineResult = { success: false, piece: options.piece, issueNumber: options.issueNumber };
try {
const outcome = await runPipeline(options);
pipelineResult = outcome.result;
return outcome.exitCode;
} finally {
await notifySlack(runId, startTime, pipelineResult);
}
}
// ---- Slack notification ----
interface PipelineResult {
success: boolean;
piece: string;
issueNumber?: number;
branch?: string;
prUrl?: string;
}
/** Send Slack notification if webhook is configured. Never throws. */
async function notifySlack(runId: string, startTime: number, result: PipelineResult): Promise<void> {
const webhookUrl = getSlackWebhookUrl();
if (!webhookUrl) return;
const durationSec = Math.round((Date.now() - startTime) / 1000);
const task: SlackTaskDetail = {
name: 'pipeline',
success: result.success,
piece: result.piece,
issueNumber: result.issueNumber,
durationSec,
branch: result.branch,
prUrl: result.prUrl,
};
const message = buildSlackRunSummary({
runId,
total: 1,
success: result.success ? 1 : 0,
failed: result.success ? 0 : 1,
durationSec,
concurrency: 1,
tasks: [task],
});
await sendSlackNotification(webhookUrl, message);
}