import type { Actor } from "./actor"; import { defaultBranch } from "./git"; import { db, filter, findFirst, isUniqueViolation, type RecordModel } from "./db"; import { isValidPattern, refusal, type BranchRule, type PushContext, type RefUpdate } from "./branch-rules"; import type { RepoRecord } from "./repos"; // Branch rules in the database, and the check every change to a branch goes through. The rules // themselves are in lib/branch-rules.ts. Used by the SSH server too, so nothing from next/* here. export class ProtectionError extends Error {} const toRule = (r: RecordModel): BranchRule => ({ id: r.id, pattern: r.pattern, requirePr: r.require_pr, requiredApprovals: r.required_approvals, requireCi: r.require_ci, blockForcePush: r.block_force_push, blockDeletion: r.block_deletion, agentsMayMerge: r.agents_may_merge, createdAt: r.created_at, }); export async function listBranchRules(repoId: string) { const rows = await (await db()).collection("branch_rules").getFullList({ filter: filter("repo = {:repoId}", { repoId }), sort: "pattern" }); return rows.map(toRule); } export type BranchRuleInput = Omit; function ruleFields(input: BranchRuleInput) { const pattern = input.pattern.trim(); if (!isValidPattern(pattern)) throw new ProtectionError("Use a branch name, or a pattern like release/* (* within a segment, ** across segments)."); const approvals = Math.trunc(input.requiredApprovals); if (!(approvals >= 0 && approvals <= 10)) throw new ProtectionError("Required approvals must be between 0 and 10."); return { pattern, require_pr: input.requirePr || approvals > 0, required_approvals: approvals, require_ci: input.requireCi, block_force_push: input.blockForcePush, block_deletion: input.blockDeletion, agents_may_merge: input.agentsMayMerge, }; } export async function saveBranchRule(repoId: string, input: BranchRuleInput, ruleId?: string) { const fields = ruleFields(input); const rules = (await db()).collection("branch_rules"); try { if (ruleId) { const row = await findFirst("branch_rules", filter("id = {:ruleId} && repo = {:repoId}", { ruleId, repoId }), { fields: "id" }); if (!row) throw new ProtectionError("That rule was deleted in the meantime."); return toRule(await rules.update(row.id, fields)); } return toRule(await rules.create({ repo: repoId, ...fields, created_at: Date.now() })); } catch (e) { if (isUniqueViolation(e)) throw new ProtectionError(`There's already a rule for ${fields.pattern}.`); throw e; } } export async function deleteBranchRule(repoId: string, ruleId: string) { const row = await findFirst("branch_rules", filter("id = {:ruleId} && repo = {:repoId}", { ruleId, repoId }), { fields: "id" }); if (row) await (await db()).collection("branch_rules").delete(row.id); } /** What a change to the repository's refs is checked against. */ export async function pushContext(repo: RepoRecord, actor: Actor | null, opts: { merge?: boolean } = {}): Promise { const [rules, branch] = await Promise.all([listBranchRules(repo.id), defaultBranch(repo.dir)]); return { rules, agentPush: repo.agentPush, agent: !!actor?.agent, empty: branch === null, merge: opts.merge }; } /** Refuses (with the reasons) ref updates the repository's rules don't allow for this actor. */ export async function assertRefUpdates(repo: RepoRecord, actor: Actor | null, updates: RefUpdate[], opts: { merge?: boolean } = {}) { const ctx = await pushContext(repo, actor, opts); const reasons = updates.map((u) => refusal(ctx, u)).filter((r) => r !== null); if (reasons.length) throw new ProtectionError(reasons.join("\n")); } /** Shorthand for a commit on top of a branch's current head (or a new branch). */ export function assertCanCommit(repo: RepoRecord, actor: Actor | null, branch: string, head: string | null) { return assertRefUpdates(repo, actor, [{ ref: `refs/heads/${branch}`, old: head, new: "pending", fastForward: true }]); }