import { matchesGlob } from "./workflows"; // Branch protection, as plain rules: what may happen to a branch, and what a pull request needs // before it can be merged into one. No database access here (see lib/protection.ts), so it can be // tested and shared by every way a branch changes: git pushes (through the pre-receive hook), the // web editor, agents' commits and merges. export type BranchRule = { id: string; pattern: string; requirePr: boolean; requiredApprovals: number; requireCi: boolean; blockForcePush: boolean; blockDeletion: boolean; agentsMayMerge: boolean; createdAt: number; }; /** * Where agents may push: anywhere their person may (the default: what they push is labeled as * theirs and easy to revert), any branch no rule protects, or under agent/ only. */ export type AgentPush = "agent_branches" | "unprotected" | "any"; export const AGENT_BRANCH_PREFIX = "agent/"; export const AGENT_PUSH_CHOICES: { value: AgentPush; title: string; description: string }[] = [ { value: "any", title: "Like their person", description: "Agents push wherever the person they act for may, main included, under the same branch rules. Their commits are labeled, and each push can be reverted in one click.", }, { value: "unprotected", title: "Unprotected branches", description: "Agents push to any branch that no branch rule protects." }, { value: "agent_branches", title: "Only agent/ branches", description: "Agents push to branches named agent/…, and their work reaches other branches through pull requests.", }, ]; /** What every rule that matches a branch asks for together (the strictest of each). */ export type Protection = Omit & { patterns: string[] }; export function protectionFor(rules: BranchRule[], branch: string): Protection | null { const matching = rules.filter((r) => r.pattern === branch || matchesGlob(r.pattern, branch)); if (matching.length === 0) return null; return { patterns: matching.map((r) => r.pattern), requirePr: matching.some((r) => r.requirePr), requiredApprovals: Math.max(...matching.map((r) => r.requiredApprovals)), requireCi: matching.some((r) => r.requireCi), blockForcePush: matching.some((r) => r.blockForcePush), blockDeletion: matching.some((r) => r.blockDeletion), agentsMayMerge: matching.every((r) => r.agentsMayMerge), }; } /** A valid rule pattern: a branch name, optionally with * and ** globs. */ export function isValidPattern(pattern: string) { return /^[A-Za-z0-9._\-/*]{1,255}$/.test(pattern) && !pattern.startsWith("/") && !pattern.startsWith("-") && !pattern.includes("//"); } /** Whether an agent may write to a branch itself (push, commit, merge into it, delete it). */ export function agentMayWrite(agentPush: AgentPush, rules: BranchRule[], branch: string) { if (agentPush === "any") return true; if (agentPush === "unprotected") return protectionFor(rules, branch) === null; return branch.startsWith(AGENT_BRANCH_PREFIX) && branch.length > AGENT_BRANCH_PREFIX.length; } export function agentPushHint(agentPush: AgentPush) { return agentPush === "unprotected" ? "Agents can only push to branches that no branch rule protects here." : `Agents can only push to branches under ${AGENT_BRANCH_PREFIX} here. Push to ${AGENT_BRANCH_PREFIX} and open a pull request.`; } export type RefUpdate = { /** refs/heads/…, refs/tags/… or any other ref a push names */ ref: string; /** null when the ref is created */ old: string | null; /** null when the ref is deleted */ new: string | null; /** Whether new contains old (unknown, and so true, when either is missing). */ fastForward: boolean; }; export type PushContext = { rules: BranchRule[]; agentPush: AgentPush; /** The pusher is an agent. */ agent: boolean; /** The repository has no branches yet: its first push may create a protected branch. */ empty: boolean; /** The update merges a pull request, which is how protected branches change. */ merge?: boolean; }; /** Why an update may not happen, or null if it may. */ export function refusal(ctx: PushContext, update: RefUpdate): string | null { if (update.ref.startsWith("refs/tags/")) { const tag = update.ref.slice(10); if (ctx.agent && update.old) return `Agents can't move or delete tags (${tag}).`; return null; } if (update.ref.startsWith("refs/pull/")) return `${update.ref}: refs/pull/ holds the commits of pull requests from forks, and only Vectrum changes it.`; if (!update.ref.startsWith("refs/heads/")) return null; const branch = update.ref.slice(11); const protection = protectionFor(ctx.rules, branch); // A rule can let agents merge pull requests into a branch they can't push to. const agentMerge = ctx.merge && !!update.old && !!update.new && !!protection?.agentsMayMerge; if (ctx.agent && !agentMerge && !agentMayWrite(ctx.agentPush, ctx.rules, branch)) return `${branch}: ${agentPushHint(ctx.agentPush)}`; if (!protection) return null; if (!update.new) return protection.blockDeletion ? `${branch} is protected and can't be deleted.` : null; if (update.old && !update.fastForward && protection.blockForcePush) { return `${branch} is protected: force pushes (rewriting its history) aren't allowed.`; } if (update.old && protection.requirePr && !ctx.merge && !ctx.empty) { return `${branch} is protected: changes reach it through pull requests. Push to another branch and open one.`; } return null; } // --------------------------------------------------------------------------- // Merging export type CheckState = "success" | "failure" | "pending" | "none"; export type MergeFacts = { state: "open" | "closed" | "merged"; draft: boolean; /** The head branch still exists. */ headExists: boolean; /** Commits on head that base doesn't have. */ ahead: number; conflicts: string[] | null; checks: CheckState; /** People with push access who approved the latest commit (not counting the author of their own work). */ approvals: number; /** People whose latest review asks for changes. */ changesRequestedBy: string[]; protection: Protection | null; /** Who would merge: an agent, and whether it may write to the base branch itself. */ byAgent: boolean; agentMayWriteBase: boolean; }; export type Requirement = { key: string; ok: boolean; label: string; detail?: string }; /** What has to be true before a pull request can be merged, each with whether it is. */ export function mergeRequirements(f: MergeFacts): Requirement[] { const out: Requirement[] = []; out.push({ key: "open", ok: f.state === "open", label: f.state === "open" ? "Open" : `The pull request is ${f.state}` }); if (f.draft) out.push({ key: "draft", ok: false, label: "It's a draft", detail: "Mark it ready for review first." }); out.push({ key: "head", ok: f.headExists, label: f.headExists ? "Head branch exists" : "The head branch was deleted" }); if (f.headExists) out.push({ key: "commits", ok: f.ahead > 0, label: f.ahead > 0 ? `${f.ahead} commit${f.ahead === 1 ? "" : "s"} to merge` : "Nothing to merge" }); if (f.conflicts !== null) { out.push({ key: "conflicts", ok: f.conflicts.length === 0, label: f.conflicts.length === 0 ? "No conflicts with the base branch" : "Conflicts with the base branch", detail: f.conflicts.length ? f.conflicts.slice(0, 20).join(", ") : undefined, }); } const p = f.protection; if (p?.requireCi || f.checks !== "none") { const required = !!p?.requireCi; const labels: Record = { success: "Checks passed", failure: "Checks failed", pending: "Checks are running", none: "No workflows run on this branch", }; // Checks only block when a rule requires them; otherwise they're shown for information. const ok = !required || f.checks === "success" || f.checks === "none"; out.push({ key: "checks", ok, label: labels[f.checks], detail: required ? "Required by a branch rule." : undefined }); } if (p && p.requiredApprovals > 0) { out.push({ key: "approvals", ok: f.approvals >= p.requiredApprovals, label: `${f.approvals} of ${p.requiredApprovals} required approval${p.requiredApprovals === 1 ? "" : "s"}`, detail: "From people with push access, on the latest commit.", }); } if (f.changesRequestedBy.length > 0) { out.push({ key: "changes", ok: false, label: `Changes requested by ${f.changesRequestedBy.join(", ")}` }); } if (f.byAgent && !f.agentMayWriteBase && !p?.agentsMayMerge) { out.push({ key: "agent", ok: false, label: "A person has to merge this", detail: "Agents can't merge into this branch. A branch rule can allow it." }); } return out; } export const canMerge = (requirements: Requirement[]) => requirements.every((r) => r.ok); // --------------------------------------------------------------------------- // Linked issues const LINK_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/gi; /** Issue numbers a pull request closes when merged: "Fixes #12", "closes #3, resolves #4". */ export function linkedIssueNumbers(...texts: string[]) { const out = new Set(); for (const text of texts) for (const m of text.matchAll(LINK_RE)) out.add(Number(m[1])); return [...out].filter((n) => n > 0).sort((a, b) => a - b); }