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<BranchRule, "id" | "pattern" | "createdAt"> & { 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}<name> 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<CheckState, string> = {
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<number>();
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);
}