src/approval-executor.ts
160 lines and 8 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| readOpenApprovals | 38 to 47 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:46> | 46 to 46 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| close | 62 to 96 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| executeApproval | 99 to 146 | 14 | 91% | WatchCRAP 14.2 | No data: mutation testing has not run on this repo yet |
| end | 107 to 110 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| closeAsAutoMerged | 153 to 155 | 1 | 33% | OkCRAP 1.3 | No data: mutation testing has not run on this repo yet |
| expireStaleApprovals | 159 to 171 | 3 | 92% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:168> | 168 to 168 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/auto-merge-policy.ts, src/doc-statements.ts, src/env.ts, src/github/refs.ts, src/merge-class.ts, src/time.ts. Imported by src/auto-merge-tick.ts, src/merge-safety.ts, src/trends.ts.
Source
import { ciGreenOn, type PrFacts } from "./auto-merge-policy";
import type { Env } from "./env";
import { HeadMovedError, managePr } from "./github/refs";
import { systemAudit } from "./doc-statements";
import type { Classification } from "./merge-class";
import { DAY_MS } from "./time";
// THE EXECUTOR (docs/design/design-merge-pipeline.md section 4, PR 4; Dustin's DECIDE of
// 2026-10-09: approvals void on a new push, red CI or 7 days). The auto-merge tick
// (src/auto-merge-tick.ts) calls it for a pull request the signed policy did not merge and
// that holds an open approval (src/pr-approvals.ts). In order:
// 1. the head is not the approved sha: void, "new push";
// 2. the approval is older than 7 days: expire;
// 3. CI on that sha, by the policy's own ci_green test (ciGreenOn): not finished waits,
// red voids, "CI red", and a namespace with no required steps refuses;
// 4. a head off the base repo, a base that is not the default branch, or a money path
// approved without its typed confirmation refuses;
// 5. otherwise managePr merge pinned to the approved sha: a 409 voids, "head moved", and
// success moves the row to merged with the merge sha.
// It is the only thing that merges an approved pull request. Every state move is
// `UPDATE ... WHERE id = ? AND status = 'approved' RETURNING id`
// (path mutation invariant, test/path-mutation.test.ts), in one batch with its audit row, written as SYSTEM_ACTOR (src/doc-statements.ts) like the tick's own rows.
const APPROVAL_DAYS = 7;
export interface OpenApproval {
id: number;
pr: number;
head_sha: string;
class: string;
approved_by: string;
surface: string;
batch_id: string | null;
approved_at: string;
}
/** The open approvals on one repo, by pull request number. */
export async function readOpenApprovals(db: D1Database, repo: string): Promise<Map<number, OpenApproval>> {
const { results } = await db
.prepare(
`SELECT id, pr, head_sha, class, approved_by, surface, batch_id, approved_at FROM pr_approvals
WHERE repo = ?1 AND status = 'approved'`
)
.bind(repo)
.all<OpenApproval>();
return new Map((results ?? []).map((r) => [r.pr, r]));
}
export type ExecutorResult =
| { done: "merged"; merge_sha: string | null; why: string }
| { done: "voided" | "expired" | "refused"; why: string }
| { done: "waiting"; why: string };
const AUDIT: Record<"voided" | "expired" | "refused" | "merged", string> = {
voided: "pr-approval-voided",
expired: "pr-approval-expired",
refused: "pr-approval-refused",
merged: "approved-merged",
};
/** One state move and its audit row, in one batch. False when the row had already moved. */
async function close(
env: Env,
namespace: string,
approval: OpenApproval,
facts: Pick<PrFacts, "repo" | "number" | "headSha">,
status: "voided" | "expired" | "refused" | "merged",
reason: string,
now: Date,
mergeSha: string | null = null
): Promise<boolean> {
const at = now.toISOString();
const [moved] = await env.DB.batch([
env.DB.prepare(`UPDATE pr_approvals SET status = ?2, closed_at = ?3, close_reason = ?4, merge_sha = ?5 WHERE id = ?1 AND status = 'approved' RETURNING id`).bind(
approval.id,
status,
at,
reason,
mergeSha
),
systemAudit(env.DB, AUDIT[status], namespace, {
repo: facts.repo,
pr: facts.number,
approved_sha: approval.head_sha,
head_sha: facts.headSha,
class: approval.class,
approved_by: approval.approved_by,
surface: approval.surface,
batch_id: approval.batch_id,
reason,
...(mergeSha ? { merge_sha: mergeSha } : {}),
at,
}),
]);
return (moved?.results?.length ?? 0) > 0;
}
/** Acts on one open approval of a pull request the policy left open. */
export async function executeApproval(
env: Env,
namespace: string,
facts: PrFacts,
classified: Classification | null,
approval: OpenApproval,
now: Date
): Promise<ExecutorResult> {
const end = async (done: "voided" | "expired" | "refused", why: string): Promise<ExecutorResult> => {
await close(env, namespace, approval, facts, done, why, now);
return { done, why };
};
if (facts.headSha !== approval.head_sha) {
return end("voided", `new push: approved at ${approval.head_sha.slice(0, 7)}, the head is ${facts.headSha.slice(0, 7)} now`);
}
if (now.getTime() - Date.parse(approval.approved_at) > APPROVAL_DAYS * DAY_MS) {
return end("expired", `approved ${APPROVAL_DAYS} days ago or more without merging`);
}
const ci = ciGreenOn(facts);
if (ci.state === "wait") return { done: "waiting", why: ci.why };
if (ci.state === "red") return end("voided", `CI red: ${ci.why}`);
if (ci.state === "never") return end("refused", ci.why);
if (facts.headRepo === null || facts.headRepo.toLowerCase() !== facts.repo.toLowerCase()) {
return end("refused", "head_in_base_repo: the head is not on the base repo");
}
if (facts.baseRef !== facts.defaultBranch) {
return end("refused", `base_is_default_branch: the base is ${facts.baseRef}, not ${facts.defaultBranch}`);
}
// A money path is approved only by typing (design section 8). The class is read again
// from this tick's files, so a pull request that became one since is not merged on an
// approval that never typed it.
if (classified?.class === "typed" && approval.surface !== "typed") {
return end("refused", "a money path, approved without its typed confirmation");
}
let mergeSha: string | null;
try {
// merge_method "merge", as the tick's own merges, so the approved sha stays in history;
// pinned to it, so a push since this read gets a 409 and nothing merges.
const result = (await managePr(env, namespace, facts.number, "merge", "merge", undefined, undefined, approval.head_sha)) as { sha?: string };
mergeSha = result.sha ?? null;
} catch (err) {
if (!(err instanceof HeadMovedError)) throw err;
return end("voided", `head moved: GitHub refused the merge pinned to ${approval.head_sha.slice(0, 7)}`);
}
const why = `merged on the approval of ${approval.approved_by} (${approval.surface}) at ${approval.head_sha.slice(0, 7)}`;
await close(env, namespace, approval, facts, "merged", why, now, mergeSha);
return { done: "merged", merge_sha: mergeSha, why };
}
// The reason on the approved-merged audit row closeAsAutoMerged writes. The same merge
// also has its auto-merged row, so src/trends.ts reads this to count the merge once.
export const AUTO_MERGED_APPROVAL_REASON = "the signed policy merged it on its own";
/** A pull request the policy merged on its own: its open approval is closed as merged. */
export async function closeAsAutoMerged(env: Env, namespace: string, facts: PrFacts, approval: OpenApproval, mergeSha: string | null, now: Date): Promise<void> {
await close(env, namespace, approval, facts, "merged", AUTO_MERGED_APPROVAL_REASON, now, mergeSha);
}
/** Every approval older than 7 days, on any pull request, open or not: expired. The tick
* sees only open pull requests, so one closed or merged by hand would otherwise stay open. */
export async function expireStaleApprovals(env: Env, now: Date): Promise<number> {
const at = now.toISOString();
const { results } = await env.DB.prepare(
`UPDATE pr_approvals SET status = 'expired', closed_at = ?1, close_reason = ?2 WHERE status = 'approved' AND approved_at < ?3 RETURNING id, repo, pr, head_sha`
)
.bind(at, `approved ${APPROVAL_DAYS} days ago or more without merging`, new Date(now.getTime() - APPROVAL_DAYS * DAY_MS).toISOString())
.all<{ id: number; repo: string; pr: number; head_sha: string }>();
const rows = results ?? [];
if (rows.length) {
await env.DB.batch(rows.map((r) => systemAudit(env.DB, "pr-approval-expired", null, { repo: r.repo, pr: r.pr, approved_sha: r.head_sha, reason: "7 days", at })));
}
return rows.length;
}