230 lines
5.8 KiB
TypeScript
230 lines
5.8 KiB
TypeScript
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<string> {
|
|
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<GitHubCiMetadata>;
|
|
try {
|
|
parsed = JSON.parse(raw) as Partial<GitHubCiMetadata>;
|
|
} 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<ScheduledTask, 'schedule_value' | 'created_at'>,
|
|
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<string[]> {
|
|
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<ScheduledTask, 'prompt' | 'ci_metadata'>,
|
|
): Promise<GitHubRunCheckResult> {
|
|
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'),
|
|
};
|
|
}
|