import { execFile } from 'child_process'; import { extractWatchCiTarget } from './task-watch-status.js'; import type { ScheduledTask } from './types.js'; export interface GitHubCiMetadata { repo: string; run_id: number; poll_count?: number; consecutive_errors?: number; last_checked_at?: string; } interface GitHubActionsRunResponse { status?: string | null; conclusion?: string | null; name?: string | null; display_title?: string | null; html_url?: string | null; head_branch?: string | null; head_sha?: string | null; event?: string | null; } interface GitHubActionsJobsResponse { jobs?: Array<{ name?: string | null; conclusion?: string | null; }>; } export interface GitHubRunCheckResult { terminal: boolean; resultSummary: string; completionMessage?: string; } export const MAX_GITHUB_CONSECUTIVE_ERRORS = 5; export const GITHUB_WATCH_BACKOFF_STEPS = [ { afterMs: 60 * 60 * 1000, delayMs: 60_000 }, { afterMs: 10 * 60 * 1000, delayMs: 30_000 }, ] as const; function execGhApi(args: string[]): Promise { return new Promise((resolve, reject) => { execFile( 'gh', ['api', ...args], { maxBuffer: 10 * 1024 * 1024, env: process.env, }, (error, stdout, stderr) => { if (error) { const details = stderr?.trim() || stdout?.trim() || error.message; reject(new Error(`gh api failed: ${details}`)); return; } resolve(stdout); }, ); }); } export function parseGitHubCiMetadata( raw: string | null | undefined, ): GitHubCiMetadata | null { if (!raw) return null; let parsed: Partial; try { parsed = JSON.parse(raw) as Partial; } catch { return null; } if (typeof parsed.repo !== 'string' || parsed.repo.trim() === '') { return null; } const runId = Number(parsed.run_id); if (!Number.isInteger(runId) || runId <= 0) { return null; } return { repo: parsed.repo, run_id: runId, poll_count: Number.isInteger(parsed.poll_count) && parsed.poll_count! >= 0 ? parsed.poll_count : undefined, consecutive_errors: Number.isInteger(parsed.consecutive_errors) && parsed.consecutive_errors! >= 0 ? parsed.consecutive_errors : undefined, last_checked_at: typeof parsed.last_checked_at === 'string' && parsed.last_checked_at.trim() !== '' ? parsed.last_checked_at : undefined, }; } export function serializeGitHubCiMetadata(metadata: GitHubCiMetadata): string { return JSON.stringify(metadata); } export function computeGitHubWatcherDelayMs( task: Pick, nowMs: number, ): number { const baseDelayMs = Number.parseInt(task.schedule_value, 10); const normalizedBaseDelayMs = Number.isFinite(baseDelayMs) && baseDelayMs > 0 ? baseDelayMs : 15_000; const createdAtMs = new Date(task.created_at).getTime(); const elapsedMs = Number.isFinite(createdAtMs) ? Math.max(0, nowMs - createdAtMs) : 0; for (const step of GITHUB_WATCH_BACKOFF_STEPS) { if (elapsedMs >= step.afterMs) { return Math.max(normalizedBaseDelayMs, step.delayMs); } } return normalizedBaseDelayMs; } function formatConclusionLabel(conclusion: string | null | undefined): string { switch (conclusion) { case 'success': return '성공'; case 'failure': return '실패'; case 'cancelled': return '취소됨'; case 'timed_out': return '시간 초과'; case 'action_required': return '조치 필요'; case 'neutral': return '중립'; case 'skipped': return '건너뜀'; case 'stale': return '오래됨'; default: return conclusion || '완료'; } } async function fetchFailedJobs(metadata: GitHubCiMetadata): Promise { const stdout = await execGhApi([ `repos/${metadata.repo}/actions/runs/${metadata.run_id}/jobs?per_page=100`, ]); const parsed = JSON.parse(stdout) as GitHubActionsJobsResponse; return (parsed.jobs || []) .filter( (job) => job.conclusion && ['failure', 'cancelled', 'timed_out', 'startup_failure'].includes( job.conclusion, ), ) .map((job) => job.name?.trim()) .filter((name): name is string => Boolean(name)) .slice(0, 3); } export async function checkGitHubActionsRun( task: Pick, ): Promise { const metadata = parseGitHubCiMetadata(task.ci_metadata); if (!metadata) { throw new Error('Task is missing valid GitHub CI metadata'); } const stdout = await execGhApi([ `repos/${metadata.repo}/actions/runs/${metadata.run_id}`, ]); const run = JSON.parse(stdout) as GitHubActionsRunResponse; const status = run.status || 'unknown'; if (status !== 'completed') { return { terminal: false, resultSummary: `GitHub Actions run ${metadata.run_id} is ${status}`, }; } let failedJobs: string[]; try { failedJobs = await fetchFailedJobs(metadata); } catch { failedJobs = []; } const target = extractWatchCiTarget(task.prompt) || `GitHub Actions run ${metadata.run_id}`; const conclusionLabel = formatConclusionLabel(run.conclusion); const lines = [ `CI 완료: ${target}`, `판정: ${conclusionLabel}`, `- 저장소: ${metadata.repo}`, ]; if (run.name) { lines.push(`- 워크플로: ${run.name}`); } if (run.head_branch) { lines.push(`- 브랜치: ${run.head_branch}`); } if (failedJobs.length > 0) { lines.push(`- 실패 job: ${failedJobs.join(', ')}`); } if (run.html_url) { lines.push(`- 링크: ${run.html_url}`); } return { terminal: true, resultSummary: `${conclusionLabel}: ${metadata.repo} run ${metadata.run_id}`, completionMessage: lines.join('\n'), }; }