Files
EJClaw/src/github-ci.ts

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'),
};
}