import { repoDb } from "./scoped-db"; import { randomId, sha256Hex } from "./crypto"; import { onCommitStatus } from "./merge-automation"; import { withStore } from "./git/store"; import { readWorkflowFiles } from "./ci/scheduler"; import { parseWorkflow } from "./ci/spec"; import type { RepoRow, StatusState } from "~/lib/types"; const decoder = new TextDecoder(); /** `ci`: written by gitfor Actions (bound to a workflow file); `api`: posted by an external integration. */ export type StatusSource = "ci" | "api"; export type CommitStatusRow = { id: string; repo_id: string; sha: string; context: string; state: StatusState; description: string | null; target_url: string | null; workflow_path: string | null; workflow_hash: string | null; source: StatusSource | null; created_at: number; updated_at: number; }; export async function setCommitStatus(input: { repoId: string; sha: string; context: string; state: StatusState; description: string | null; targetUrl: string | null; /** Workflow file that produced the status; its content hash lets branch protection verify the definition. */ workflowPath?: string | null; workflowSource?: string | null; source: StatusSource; }): Promise { const now = Date.now(); const workflowPath = input.workflowPath ?? null; const workflowHash = workflowPath && input.workflowSource != null ? await sha256Hex(input.workflowSource) : null; await repoDb(input.repoId).run( `INSERT INTO commit_statuses (id, repo_id, sha, context, state, description, target_url, workflow_path, workflow_hash, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (repo_id, sha, context) DO UPDATE SET state = excluded.state, description = excluded.description, target_url = excluded.target_url, workflow_path = COALESCE(excluded.workflow_path, commit_statuses.workflow_path), workflow_hash = COALESCE(excluded.workflow_hash, commit_statuses.workflow_hash), source = excluded.source, updated_at = excluded.updated_at WHERE NOT (commit_statuses.source = 'ci' AND excluded.source = 'api')`, randomId(), input.repoId, input.sha, input.context, input.state, input.description, input.targetUrl, workflowPath, workflowHash, input.source, now, now, ); if (input.state !== "pending") { await onCommitStatus(input.repoId, input.sha).catch((error) => console.error("Merge automation wake failed", error), ); } } /** * A workflow that failed to compile leaves a workflow-level `error` status (context = the workflow's name or * path). Once a later run of that workflow on the same commit compiles, its job statuses speak for it, so the * stale error must go or the commit stays red after the re-run passes. */ export async function clearWorkflowCompileError( repoId: string, sha: string, contexts: string[], workflowPath: string, ): Promise { const unique = [...new Set(contexts)]; if (unique.length === 0) return; const deleted = await repoDb(repoId).run( `DELETE FROM commit_statuses WHERE repo_id = ? AND sha = ? AND source = 'ci' AND state = 'error' AND workflow_path = ? AND context IN (${unique.map(() => "?").join(", ")})`, repoId, sha, workflowPath, ...unique, ); if (deleted.meta.changes > 0) { await onCommitStatus(repoId, sha).catch((error) => console.error("Merge automation wake failed", error)); } } export async function listCommitStatuses(repoId: string, sha: string): Promise { return repoDb(repoId).all( "SELECT * FROM commit_statuses WHERE repo_id = ? AND sha = ? ORDER BY context", repoId, sha, ); } export function combinedState(statuses: Array<{ state: StatusState }>): StatusState | null { if (statuses.length === 0) return null; if (statuses.some((status) => status.state === "failure" || status.state === "error")) return "failure"; if (statuses.some((status) => status.state === "pending")) return "pending"; return "success"; } /** * `untrusted`: the status did not come from a workflow definition we can bind to the base branch. `stale`: it came * from gitfor CI, but from a workflow file whose content differs from the one at the current base tip, so the check * must be re-run against the current base before it counts. */ export type RequiredCheckResult = StatusState | "missing" | "untrusted" | "stale"; /** * Resolves each required check for branch protection. A gitfor CI `success` only counts when it came from a workflow * whose file content matches the one at `baseSha`, the current base tip, so a pull request cannot satisfy a required * check by rewriting the workflow that reports it, nor by branching from an old base commit whose weaker workflow * version is still in history. Statuses written before sources were recorded (`source` NULL) never count; a re-run * rebinds them. External (`api`) statuses cannot overwrite CI-owned contexts and never satisfy a context that a * workflow on the base tip reports. */ export async function requiredCheckResults( baseRepo: RepoRow, baseSha: string | null, statuses: CommitStatusRow[], required: string[], ): Promise { const baseHashes = new Map>(); const baseHash = (path: string) => { let hash = baseHashes.get(path); if (!hash) { hash = baseSha ? withStore(baseRepo.artifacts_name, async (store) => { const commit = await store.commit(baseSha); const entry = commit ? await store.entryAtPath(commit.treeSha, path) : null; if (!entry || entry.type !== "blob") return null; const bytes = await store.blob(entry.sha); return bytes ? sha256Hex(decoder.decode(bytes)) : null; }).catch(() => null) : Promise.resolve(null); baseHashes.set(path, hash); } return hash; }; let ciContexts: Promise<(context: string) => boolean> | null = null; const producedByCi = (context: string) => { ciContexts ??= (baseSha ? readWorkflowFiles(baseRepo, baseSha) : Promise.resolve([])) .then((files) => { const prefixes = files.map((file) => { const parsed = file.tooLarge ? null : parseWorkflow(file.source); const name = (parsed?.ok ? parsed.spec.name : undefined) ?? file.path.split("/").pop() ?? file.path; return { path: file.path, prefix: `${name} / ` }; }); return (candidate: string) => prefixes.some(({ path, prefix }) => candidate === path || candidate.startsWith(prefix)); }) .catch(() => () => true); return ciContexts.then((matches) => matches(context)); }; return Promise.all( required.map(async (context): Promise => { const status = statuses.find((candidate) => candidate.context === context); if (!status) return "missing"; if (status.state !== "success") return status.state; if (status.source === "api") return (await producedByCi(context)) ? "untrusted" : "success"; if (status.source !== "ci") return "untrusted"; if (!status.workflow_path || !status.workflow_hash) return "untrusted"; const expected = await baseHash(status.workflow_path); if (expected === null) return "untrusted"; return expected === status.workflow_hash ? "success" : "stale"; }), ); }