// Behavioral adaptation of oh-my-pi's GitHub Actions run watcher. // Copyright (c) 2025 Mario Zechner and oh-my-pi contributors. // Used under the MIT License; see NOTICE.md. import type { CommandResult, FailedJobLog, FailedLogDetails, GithubActionsWatchInput, GithubJobSnapshot, GithubRunSnapshot, PreparedGithubActionsWatch, RunWatchDetails, RunWatchJobDetails, RunWatchRunDetails, WatchConfig, WatchOutcome, WatchResult, WatchRuntime, } from "./types.ts"; export const DEFAULT_WATCH_CONFIG: WatchConfig = { fastIntervalMs: 3_000, slowIntervalMs: 15_000, fastWindowMs: 60_000, noRunsTimeoutMs: 90_000, failureGraceMs: 5_000, maxPollFailures: 5, }; const DEFAULT_TAIL = 15; const MAX_TAIL = 200; const PAGE_SIZE = 100; const RUN_URL = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/i; const REPO_SLUG = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]); const FAILURE_CONCLUSIONS = new Set([ "failure", "timed_out", "cancelled", "action_required", "startup_failure", ]); const TRANSIENT_ERROR = /rate.?limit|HTTP\s*(?:429|5\d\d)|\b429\b|\b50[0234]\b|timeout|timed out|connection|network|temporary|try again|EOF/i; interface RunReference { repo?: string; runId?: number; } interface RunApi { id?: unknown; name?: unknown; display_title?: unknown; status?: unknown; conclusion?: unknown; head_branch?: unknown; head_sha?: unknown; created_at?: unknown; updated_at?: unknown; html_url?: unknown; } interface JobApi { id?: unknown; name?: unknown; status?: unknown; conclusion?: unknown; started_at?: unknown; completed_at?: unknown; html_url?: unknown; } export class GithubWatchError extends Error { readonly transient: boolean; constructor(message: string, options?: { transient?: boolean; cause?: unknown }) { super(message, { cause: options?.cause }); this.name = "GithubWatchError"; this.transient = options?.transient ?? false; } } function clean(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } function errorText(result: CommandResult): string { return clean(result.stderr) ?? clean(result.stdout) ?? `exit code ${result.code}`; } function boundedError(value: string): string { const singleLine = value.replace(/\s+/g, " ").trim(); return singleLine.length > 500 ? `${singleLine.slice(0, 497)}...` : singleLine; } function throwIfAborted(signal?: AbortSignal): void { if (!signal?.aborted) return; const error = new Error("GitHub Actions watch cancelled"); error.name = "AbortError"; throw error; } export function abortableSleep(milliseconds: number, signal?: AbortSignal): Promise { throwIfAborted(signal); if (milliseconds <= 0) return Promise.resolve(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, milliseconds); const onAbort = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); const error = new Error("GitHub Actions watch cancelled"); error.name = "AbortError"; reject(error); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } class GithubClient { constructor( private readonly runtime: WatchRuntime, private readonly signal: AbortSignal | undefined, ) {} async raw(command: string, args: string[], timeout?: number): Promise { throwIfAborted(this.signal); let result: CommandResult; try { result = await this.runtime.exec(command, args, { cwd: this.runtime.cwd, signal: this.signal, timeout, }); } catch (error) { throwIfAborted(this.signal); const message = error instanceof Error ? error.message : String(error); throw new GithubWatchError(`Could not run ${command}: ${boundedError(message)}`, { transient: TRANSIENT_ERROR.test(message), cause: error, }); } throwIfAborted(this.signal); return result; } async requireSuccess(command: string, args: string[], label: string, timeout?: number): Promise { const result = await this.raw(command, args, timeout); if (result.code !== 0) { const detail = boundedError(errorText(result)); throw new GithubWatchError(`${label}: ${detail}`, { transient: TRANSIENT_ERROR.test(detail), }); } return result.stdout; } async json(args: string[], label: string): Promise { const stdout = await this.requireSuccess("gh", args, label); try { return JSON.parse(stdout) as T; } catch (error) { throw new GithubWatchError(`${label}: GitHub CLI returned invalid JSON`, { cause: error }); } } async api(endpoint: string, fields: string[] = []): Promise { const args = ["api", "--method", "GET", endpoint]; for (const field of fields) args.push("-F", field); return this.json(args, `GitHub API request failed (${endpoint})`); } } export function parseRunReference(value: string | undefined): RunReference { const run = clean(value); if (!run) return {}; if (/^\d+$/.test(run)) { const runId = Number(run); if (!Number.isSafeInteger(runId) || runId <= 0) { throw new GithubWatchError("run must be a positive workflow run ID"); } return { runId }; } const match = run.match(RUN_URL); if (!match) { throw new GithubWatchError("run must be a numeric workflow run ID or a full GitHub Actions run URL"); } const runId = Number(match[2]); if (!Number.isSafeInteger(runId) || runId <= 0) { throw new GithubWatchError("run URL contains an invalid workflow run ID"); } return { repo: match[1], runId }; } export function repoSlugEquals(left: string | undefined, right: string | undefined): boolean { return left !== undefined && right !== undefined && left.toLowerCase() === right.toLowerCase(); } function validateRepo(value: string | undefined): string | undefined { const repo = clean(value); if (!repo) return undefined; if (!REPO_SLUG.test(repo)) throw new GithubWatchError("repo must use the owner/repo form"); const [owner, name] = repo.split("/"); if (owner === "." || owner === ".." || name === "." || name === "..") { throw new GithubWatchError("repo must use the owner/repo form"); } return repo; } export function resolveTail(value: number | undefined): number { if (value === undefined) return DEFAULT_TAIL; if (!Number.isFinite(value) || value <= 0) throw new GithubWatchError("tail must be a positive number"); return Math.min(Math.floor(value), MAX_TAIL); } export function getRunOutcome(run: Pick): WatchOutcome { if (run.status !== "completed") return "pending"; if (run.conclusion && SUCCESS_CONCLUSIONS.has(run.conclusion)) return "success"; if (run.conclusion && FAILURE_CONCLUSIONS.has(run.conclusion)) return "failure"; return "pending"; } export function isFailedJob(job: Pick): boolean { return job.conclusion !== undefined && FAILURE_CONCLUSIONS.has(job.conclusion); } /** * A single failed job never settles the watch on its own: as long as any run in the * collection is still queued or in progress, the outcome stays `pending` so concurrent * jobs (builds, publishes) keep running and land in one final report. */ export function getRunCollectionOutcome(runs: GithubRunSnapshot[]): WatchOutcome { if (runs.length === 0) return "pending"; let pending = false; let failed = false; for (const run of runs) { const outcome = getRunOutcome(run); if (outcome === "pending") pending = true; if (outcome === "failure" || run.jobs.some(isFailedJob)) failed = true; } if (pending) return "pending"; return failed ? "failure" : "success"; } /** True when at least one job or run has already failed, even if the collection is still running. */ export function hasObservedFailure(runs: GithubRunSnapshot[]): boolean { return runs.some((run) => getRunOutcome(run) === "failure" || run.jobs.some(isFailedJob)); } function normalizeJob(raw: JobApi): GithubJobSnapshot | undefined { if (typeof raw.id !== "number" || !Number.isSafeInteger(raw.id)) return undefined; return { id: raw.id, name: clean(raw.name) ?? `job-${raw.id}`, status: clean(raw.status), conclusion: clean(raw.conclusion), startedAt: clean(raw.started_at), completedAt: clean(raw.completed_at), url: clean(raw.html_url), }; } function normalizeRun(raw: RunApi, jobs: GithubJobSnapshot[]): GithubRunSnapshot { if (typeof raw.id !== "number" || !Number.isSafeInteger(raw.id)) { throw new GithubWatchError("GitHub Actions run response did not include a valid run ID"); } return { id: raw.id, workflowName: clean(raw.name), displayTitle: clean(raw.display_title), status: clean(raw.status), conclusion: clean(raw.conclusion), branch: clean(raw.head_branch), headSha: clean(raw.head_sha), createdAt: clean(raw.created_at), updatedAt: clean(raw.updated_at), url: clean(raw.html_url), jobs, }; } async function checkPrerequisites(client: GithubClient): Promise { const version = await client.raw("gh", ["--version"], 10_000); if (version.code !== 0) { throw new GithubWatchError("GitHub CLI (gh) is not installed or not executable"); } const auth = await client.raw("gh", ["auth", "status", "--hostname", "github.com"], 15_000); if (auth.code !== 0) { throw new GithubWatchError( `GitHub CLI is not authenticated. Run \`gh auth login\` first. (${boundedError(errorText(auth))})`, ); } } async function currentRepo(client: GithubClient): Promise { try { const value = await client.requireSuccess( "gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], "Could not resolve the current GitHub repository", ); return clean(value); } catch (error) { if (error instanceof GithubWatchError && !error.transient) return undefined; throw error; } } async function resolveRepo(client: GithubClient, explicit: string | undefined, runRepo: string | undefined): Promise { if (explicit && runRepo && !repoSlugEquals(explicit, runRepo)) { throw new GithubWatchError("run URL repository does not match the provided repo"); } if (explicit) return explicit; if (runRepo) return runRepo; const repo = await currentRepo(client); if (!repo) throw new GithubWatchError("Could not infer a GitHub repository from the current checkout; pass repo or run"); return repo; } async function gitValue(client: GithubClient, args: string[], label: string): Promise { const value = await client.requireSuccess("git", args, label); const normalized = clean(value); if (!normalized) throw new GithubWatchError(label); return normalized; } async function resolveCommitTarget( client: GithubClient, repo: string, branchInput: string | undefined, ): Promise<{ branch: string; headSha: string }> { if (branchInput) { const response = await client.api<{ commit?: { sha?: unknown } }>( `/repos/${repo}/branches/${encodeURIComponent(branchInput)}`, ); const headSha = clean(response.commit?.sha); if (!headSha) throw new GithubWatchError(`Could not resolve head SHA for branch ${branchInput}`); return { branch: branchInput, headSha }; } const cwdRepo = await currentRepo(client); if (!repoSlugEquals(cwdRepo, repo)) { throw new GithubWatchError( `Cannot infer the watched commit for ${repo}: current checkout is ${cwdRepo ?? "not a GitHub repository"}. Pass branch or run to scope the watch.`, ); } const branch = await gitValue( client, ["branch", "--show-current"], "Current git branch is unavailable. Pass branch or run explicitly.", ); const headSha = await gitValue( client, ["rev-parse", "HEAD"], "Current git HEAD is unavailable. Pass run explicitly.", ); return { branch, headSha }; } async function fetchJobs(client: GithubClient, repo: string, runId: number): Promise { const jobs: GithubJobSnapshot[] = []; for (let page = 1; ; page += 1) { const response = await client.api<{ total_count?: unknown; jobs?: JobApi[] }>( `/repos/${repo}/actions/runs/${runId}/jobs`, [`per_page=${PAGE_SIZE}`, `page=${page}`], ); const rawJobs = Array.isArray(response.jobs) ? response.jobs : []; for (const raw of rawJobs) { const job = normalizeJob(raw); if (job) jobs.push(job); } if (rawJobs.length < PAGE_SIZE) break; if (typeof response.total_count === "number" && jobs.length >= response.total_count) break; } return jobs; } async function fetchRun(client: GithubClient, repo: string, runId: number): Promise { const [run, jobs] = await Promise.all([ client.api(`/repos/${repo}/actions/runs/${runId}`), fetchJobs(client, repo, runId), ]); return normalizeRun(run, jobs); } async function repositoryHasActionsWorkflows(client: GithubClient, repo: string): Promise { try { const response = await client.api<{ total_count?: unknown; workflows?: unknown[] }>( `/repos/${repo}/actions/workflows`, ["per_page=1"], ); if (typeof response.total_count === "number") return response.total_count > 0; if (Array.isArray(response.workflows)) return response.workflows.length > 0; return undefined; } catch (error) { if (error instanceof Error && error.name === "AbortError") throw error; // Workflow inventory is only an early-exit optimization. If GitHub does // not expose it, retain the bounded polling fallback below. return undefined; } } async function fetchRunsForCommit( client: GithubClient, repo: string, headSha: string, completedJobs: Map, ): Promise { const rawRuns: RunApi[] = []; for (let page = 1; ; page += 1) { const response = await client.api<{ total_count?: unknown; workflow_runs?: RunApi[] }>( `/repos/${repo}/actions/runs`, [`head_sha=${headSha}`, `per_page=${PAGE_SIZE}`, `page=${page}`], ); const pageRuns = Array.isArray(response.workflow_runs) ? response.workflow_runs : []; rawRuns.push(...pageRuns); if (pageRuns.length < PAGE_SIZE) break; if (typeof response.total_count === "number" && rawRuns.length >= response.total_count) break; } return Promise.all( rawRuns .filter((run): run is RunApi & { id: number } => typeof run.id === "number") .map(async (run) => { const complete = run.status === "completed"; if (!complete) completedJobs.delete(run.id); let jobs = complete ? completedJobs.get(run.id) : undefined; if (!jobs) { jobs = await fetchJobs(client, repo, run.id); if (complete) completedJobs.set(run.id, jobs); } return normalizeRun(run, jobs); }), ); } function normalizeLog(log: string): string | undefined { const normalized = log.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); return normalized || undefined; } function tailLines(log: string | undefined, count: number): string | undefined { if (!log) return undefined; return log.split("\n").slice(-count).join("\n").trimEnd() || undefined; } async function fetchFailedLogs( client: GithubClient, repo: string, pairs: Array<{ run: GithubRunSnapshot; job: GithubJobSnapshot }>, tail: number, ): Promise { return Promise.all( pairs.map(async ({ run, job }) => { try { const result = await client.raw("gh", ["api", `/repos/${repo}/actions/jobs/${job.id}/logs`]); const full = result.code === 0 ? normalizeLog(result.stdout) : undefined; return { run, job, full, tail: tailLines(full, tail), available: Boolean(full) }; } catch (error) { if (error instanceof Error && error.name === "AbortError") throw error; return { run, job, available: false }; } }), ); } function durationSeconds(job: GithubJobSnapshot, observedAt: number): number | undefined { if (!job.startedAt) return undefined; const start = Date.parse(job.startedAt); if (Number.isNaN(start)) return undefined; const parsedEnd = job.completedAt ? Date.parse(job.completedAt) : Number.NaN; const end = Number.isNaN(parsedEnd) ? observedAt : parsedEnd; return Math.max(0, Math.floor((end - start) / 1000)); } function jobDetails(job: GithubJobSnapshot, observedAt: number): RunWatchJobDetails { return { id: job.id, name: job.name, status: job.status, conclusion: job.conclusion, durationSeconds: durationSeconds(job, observedAt), url: job.url, }; } function runDetails(run: GithubRunSnapshot, observedAt: number): RunWatchRunDetails { return { id: run.id, workflowName: run.workflowName, displayTitle: run.displayTitle, status: run.status, conclusion: run.conclusion, branch: run.branch, headSha: run.headSha, url: run.url, jobs: run.jobs.map((job) => jobDetails(job, observedAt)), }; } function failedLogDetails(logs: FailedJobLog[]): FailedLogDetails[] { return logs.map(({ run, job, tail, available }) => ({ runId: run.id, workflowName: run.workflowName, jobId: job.id, jobName: job.name, conclusion: job.conclusion, tail, available, })); } function buildDetails(options: { mode: "run" | "commit"; state: RunWatchDetails["state"]; outcome: WatchOutcome; repo: string; branch?: string; headSha?: string; pollCount: number; note?: string; runs: GithubRunSnapshot[]; logs?: FailedJobLog[]; artifactPath?: string; observedAt: number; }): RunWatchDetails { return { schemaVersion: 1, mode: options.mode, state: options.state, outcome: options.outcome, repo: options.repo, branch: options.branch, headSha: options.headSha, pollCount: options.pollCount, note: options.note, observedAt: new Date(options.observedAt).toISOString(), runs: options.runs.map((run) => runDetails(run, options.observedAt)), failedLogs: failedLogDetails(options.logs ?? []), artifactPath: options.artifactPath, }; } function formatFullLogs(repo: string, logs: FailedJobLog[]): string { const lines = [`GitHub Actions failed-job logs`, `Repository: ${repo}`, ""]; for (const entry of logs) { lines.push(`${entry.run.workflowName ?? "GitHub Actions"} / ${entry.job.name} (run #${entry.run.id})`); lines.push("=".repeat(Math.min(100, Math.max(8, lines[lines.length - 1].length)))); lines.push(entry.full ?? "Full log unavailable.", ""); } return lines.join("\n").trimEnd() + "\n"; } function runSignature(runs: GithubRunSnapshot[]): string { return runs .map((run) => run.id) .sort((left, right) => left - right) .join(","); } function failedPairs(runs: GithubRunSnapshot[]): Array<{ run: GithubRunSnapshot; job: GithubJobSnapshot }> { return runs.flatMap((run) => run.jobs.filter(isFailedJob).map((job) => ({ run, job }))); } function shortSha(sha: string | undefined): string { return sha?.slice(0, 7) ?? "unknown"; } async function persistLogs(runtime: WatchRuntime, repo: string, logs: FailedJobLog[]): Promise { if (!runtime.persistLogs || logs.length === 0) return undefined; try { return await runtime.persistLogs(formatFullLogs(repo, logs)); } catch { return undefined; } } export async function prepareGithubActionsWatch( input: GithubActionsWatchInput, runtime: WatchRuntime, ): Promise { const signal = runtime.signal; const now = runtime.now ?? Date.now; const client = new GithubClient(runtime, signal); throwIfAborted(signal); await checkPrerequisites(client); const explicitRepo = validateRepo(input.repo); const branchInput = clean(input.branch); const runReference = parseRunReference(input.run); const runRepo = validateRepo(runReference.repo); const repo = await resolveRepo(client, explicitRepo, runRepo); const tail = resolveTail(input.tail); const watchStartedAt = now(); if (runReference.runId !== undefined) { return { mode: "run", repo, runId: runReference.runId, tail, watchStartedAt }; } const target = await resolveCommitTarget(client, repo, branchInput); return { mode: "commit", repo, branch: target.branch, headSha: target.headSha, tail, watchStartedAt }; } export function preparedWatchTargetKey(prepared: PreparedGithubActionsWatch): string { return prepared.mode === "run" ? `run:${prepared.repo.toLowerCase()}:${prepared.runId}` : `commit:${prepared.repo.toLowerCase()}:${prepared.headSha.toLowerCase()}`; } export async function watchGithubActions( input: GithubActionsWatchInput, runtime: WatchRuntime, onUpdate?: (details: RunWatchDetails) => void, ): Promise { const prepared = await prepareGithubActionsWatch(input, runtime); return watchPreparedGithubActions(prepared, runtime, onUpdate); } export async function watchPreparedGithubActions( prepared: PreparedGithubActionsWatch, runtime: WatchRuntime, onUpdate?: (details: RunWatchDetails) => void, ): Promise { const signal = runtime.signal; const now = runtime.now ?? Date.now; const sleep = runtime.sleep ?? abortableSleep; const config = { ...DEFAULT_WATCH_CONFIG, ...runtime.config }; const client = new GithubClient(runtime, signal); const { repo, tail, watchStartedAt } = prepared; throwIfAborted(signal); const currentInterval = () => now() - watchStartedAt < config.fastWindowMs ? config.fastIntervalMs : config.slowIntervalMs; let pollFailures = 0; const handlePollFailure = async (error: unknown): Promise => { throwIfAborted(signal); pollFailures += 1; if (!(error instanceof GithubWatchError) || !error.transient || pollFailures >= config.maxPollFailures) { throw error; } await sleep(config.slowIntervalMs, signal); }; if (prepared.mode === "run") { const runId = prepared.runId; let pollCount = 0; while (true) { throwIfAborted(signal); pollCount += 1; let run: GithubRunSnapshot; try { run = await fetchRun(client, repo, runId); } catch (error) { await handlePollFailure(error); continue; } pollFailures = 0; let outcome = getRunCollectionOutcome([run]); onUpdate?.( buildDetails({ mode: "run", state: "watching", outcome, repo, branch: run.branch, headSha: run.headSha, pollCount, runs: [run], observedAt: now(), }), ); if (outcome === "failure") { const original = run; const note = `Failure detected. Waiting ${Math.round(config.failureGraceMs / 1000)}s to capture concurrent failures before fetching logs.`; onUpdate?.( buildDetails({ mode: "run", state: "watching", outcome, repo, branch: run.branch, headSha: run.headSha, pollCount, note, runs: [run], observedAt: now(), }), ); if (config.failureGraceMs > 0) await sleep(config.failureGraceMs, signal); try { const refetched = await fetchRun(client, repo, runId); if (getRunCollectionOutcome([refetched]) === "failure") run = refetched; else run = original; } catch (error) { throwIfAborted(signal); if (error instanceof GithubWatchError && error.transient) run = original; else throw error; } outcome = "failure"; const logs = await fetchFailedLogs(client, repo, failedPairs([run]), tail); const artifactPath = await persistLogs(runtime, repo, logs); const details = buildDetails({ mode: "run", state: "failed", outcome, repo, branch: run.branch, headSha: run.headSha, pollCount, runs: [run], logs, artifactPath, observedAt: now(), }); const failed = logs.map((log) => log.job.name).join(", "); const artifact = artifactPath ? ` Full logs: ${artifactPath}` : ""; return { details, summary: `GitHub Actions run #${run.id} failed${failed ? ` (${failed})` : ""}.${artifact}`, }; } if (outcome === "success") { const details = buildDetails({ mode: "run", state: "completed", outcome, repo, branch: run.branch, headSha: run.headSha, pollCount, runs: [run], observedAt: now(), }); return { details, summary: `GitHub Actions run #${run.id} completed successfully.` }; } await sleep(currentInterval(), signal); } } const target = { branch: prepared.branch, headSha: prepared.headSha }; let pollCount = 0; let everSawRuns = false; let checkedWorkflowInventory = false; let settledSuccessSignature: string | undefined; const completedJobs = new Map(); while (true) { throwIfAborted(signal); pollCount += 1; let runs: GithubRunSnapshot[]; try { runs = await fetchRunsForCommit(client, repo, target.headSha, completedJobs); } catch (error) { await handlePollFailure(error); continue; } pollFailures = 0; if (runs.length > 0) everSawRuns = true; if (!everSawRuns && !checkedWorkflowInventory) { checkedWorkflowInventory = true; const hasWorkflows = await repositoryHasActionsWorkflows(client, repo); if (hasWorkflows === false) { const note = "This repository has no GitHub Actions workflows configured."; const details = buildDetails({ mode: "commit", state: "no-runs", outcome: "pending", repo, branch: target.branch, headSha: target.headSha, pollCount, note, runs, observedAt: now(), }); onUpdate?.(details); return { details, summary: `${repo} has no GitHub Actions workflows configured; there are no checks to watch.`, }; } } let outcome = getRunCollectionOutcome(runs); onUpdate?.( buildDetails({ mode: "commit", state: "watching", outcome, repo, branch: target.branch, headSha: target.headSha, pollCount, runs, observedAt: now(), }), ); if (outcome === "failure") { const originalRuns = runs; const note = `Failure detected. Waiting ${Math.round(config.failureGraceMs / 1000)}s to capture concurrent failures before fetching logs.`; onUpdate?.( buildDetails({ mode: "commit", state: "watching", outcome, repo, branch: target.branch, headSha: target.headSha, pollCount, note, runs, observedAt: now(), }), ); if (config.failureGraceMs > 0) await sleep(config.failureGraceMs, signal); try { const refetched = await fetchRunsForCommit(client, repo, target.headSha, completedJobs); if (getRunCollectionOutcome(refetched) === "failure") runs = refetched; else runs = originalRuns; } catch (error) { throwIfAborted(signal); if (error instanceof GithubWatchError && error.transient) runs = originalRuns; else throw error; } outcome = "failure"; const logs = await fetchFailedLogs(client, repo, failedPairs(runs), tail); const artifactPath = await persistLogs(runtime, repo, logs); const details = buildDetails({ mode: "commit", state: "failed", outcome, repo, branch: target.branch, headSha: target.headSha, pollCount, runs, logs, artifactPath, observedAt: now(), }); const failed = logs.map((log) => `${log.run.workflowName ?? `run #${log.run.id}`}: ${log.job.name}`).join(", "); const artifact = artifactPath ? ` Full logs: ${artifactPath}` : ""; return { details, summary: `GitHub Actions failed for ${repo}@${shortSha(target.headSha)}${failed ? ` (${failed})` : ""}.${artifact}`, }; } if (outcome === "success") { const signature = runSignature(runs); if (signature === settledSuccessSignature) { const details = buildDetails({ mode: "commit", state: "completed", outcome, repo, branch: target.branch, headSha: target.headSha, pollCount, runs, observedAt: now(), }); return { details, summary: `All ${runs.length} GitHub Actions workflow run${runs.length === 1 ? "" : "s"} passed for ${repo}@${shortSha(target.headSha)}.`, }; } settledSuccessSignature = signature; const waitMs = currentInterval(); const note = `All known workflow runs completed successfully. Waiting ${Math.round(waitMs / 1000)}s to ensure no additional runs appear for this commit.`; onUpdate?.( buildDetails({ mode: "commit", state: "watching", outcome, repo, branch: target.branch, headSha: target.headSha, pollCount, note, runs, observedAt: now(), }), ); await sleep(waitMs, signal); continue; } settledSuccessSignature = undefined; if (!everSawRuns && now() - watchStartedAt >= config.noRunsTimeoutMs) { const elapsedSeconds = Math.round((now() - watchStartedAt) / 1000); const note = `No workflow runs found after ${elapsedSeconds}s (${pollCount} polls). The commit may not trigger Actions, or Actions may be disabled.`; const details = buildDetails({ mode: "commit", state: "no-runs", outcome: "pending", repo, branch: target.branch, headSha: target.headSha, pollCount, note, runs, observedAt: now(), }); return { details, summary: `No GitHub Actions workflow runs appeared for ${repo}@${shortSha(target.headSha)} after ${elapsedSeconds}s.`, }; } await sleep(currentInterval(), signal); } }