feat: add host-driven GitHub Actions CI watcher (Level 3)
Replace LLM-per-tick polling with direct `gh api` calls for GitHub Actions watchers. New `ci_provider` discriminator column routes tasks to either the host-driven path (zero LLM tokens) or the existing generic LLM path. GitHub watchers poll at 15s intervals (min 10s) via `checkGitHubActionsRun()` in the scheduler, with completion messages sent directly to chat on terminal state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
199
src/github-ci.ts
Normal file
199
src/github-ci.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGitHubCiMetadata(
|
||||
metadata: GitHubCiMetadata,
|
||||
): string {
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user