import { all, run } from "./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 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), ); } } export async function listCommitStatuses(repoId: string, sha: string): Promise { return 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"; } export type RequiredCheckResult = StatusState | "missing" | "untrusted"; /** * 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 one of `baseShas` (the base tip, and the merge base so a base-branch * workflow change does not strand older pull requests), so a pull request cannot satisfy a required check by * rewriting the workflow that reports it. 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 (`baseShas[0]`) reports. */ export async function requiredCheckResults( baseRepo: RepoRow, baseShas: Array, statuses: CommitStatusRow[], required: string[], ): Promise { const shas = [...new Set(baseShas.filter((sha): sha is string => Boolean(sha)))]; const baseHashes = new Map>>(); const baseHash = (path: string) => { let hashes = baseHashes.get(path); if (!hashes) { hashes = withStore(baseRepo.artifacts_name, async (store) => { const out = new Set(); for (const sha of shas) { const commit = await store.commit(sha); const entry = commit ? await store.entryAtPath(commit.treeSha, path) : null; if (!entry || entry.type !== "blob") continue; const bytes = await store.blob(entry.sha); if (bytes) out.add(await sha256Hex(decoder.decode(bytes))); } return out; }).catch(() => new Set()); baseHashes.set(path, hashes); } return hashes; }; let ciContexts: Promise<(context: string) => boolean> | null = null; const producedByCi = (context: string) => { ciContexts ??= (shas[0] ? readWorkflowFiles(baseRepo, shas[0]) : 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"; return (await baseHash(status.workflow_path)).has(status.workflow_hash) ? "success" : "untrusted"; }), ); }