Vectrum
Sign In
84 lines · 3.9 KB · TypeScript
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 }]);
}