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<BranchRule, "id" | "createdAt">;
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<PushContext> {
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 }]);
}