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:
Eyejoker
2026-03-25 22:31:42 +09:00
parent 07c1437055
commit 33fcf9c788
13 changed files with 841 additions and 14 deletions

199
src/github-ci.ts Normal file
View 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'),
};
}