import { repoDb } from "./scoped-db"; import { matchesGlob } from "~/lib/glob"; import type { Permission } from "~/lib/types"; export type BranchProtectionRow = { id: string; repo_id: string; pattern: string; require_pull_request: number; required_approvals: number; required_checks: string; dismiss_stale_reviews: number; allow_deletions: number; allow_force_pushes: number; enforce_admins: number; require_code_owner_review: number; created_at: number; }; export type BranchProtection = { id: string; pattern: string; requirePullRequest: boolean; requiredApprovals: number; requiredChecks: string[]; dismissStaleReviews: boolean; allowDeletions: boolean; allowForcePushes: boolean; enforceAdmins: boolean; requireCodeOwnerReview: boolean; }; export function toProtection(row: BranchProtectionRow): BranchProtection { return { id: row.id, pattern: row.pattern, requirePullRequest: row.require_pull_request === 1, requiredApprovals: row.required_approvals, requiredChecks: JSON.parse(row.required_checks), dismissStaleReviews: row.dismiss_stale_reviews === 1, allowDeletions: row.allow_deletions === 1, allowForcePushes: row.allow_force_pushes === 1, enforceAdmins: row.enforce_admins === 1, requireCodeOwnerReview: row.require_code_owner_review === 1, }; } export async function loadProtections(repoId: string): Promise { const rows = await repoDb(repoId).all( "SELECT * FROM branch_protections WHERE repo_id = ? ORDER BY created_at", repoId, ); return rows.map(toProtection); } const TAG_PREFIX = "refs/tags/"; /** Merges every matching rule, keeping the strictest value of each setting, so rule order never weakens one. */ function mergeProtections(matches: BranchProtection[]): BranchProtection | null { if (matches.length === 0) return null; return matches.reduce((merged, rule) => ({ ...merged, requirePullRequest: merged.requirePullRequest || rule.requirePullRequest, requiredApprovals: Math.max(merged.requiredApprovals, rule.requiredApprovals), requiredChecks: [...new Set([...merged.requiredChecks, ...rule.requiredChecks])], dismissStaleReviews: merged.dismissStaleReviews || rule.dismissStaleReviews, allowDeletions: merged.allowDeletions && rule.allowDeletions, allowForcePushes: merged.allowForcePushes && rule.allowForcePushes, enforceAdmins: merged.enforceAdmins || rule.enforceAdmins, requireCodeOwnerReview: merged.requireCodeOwnerReview || rule.requireCodeOwnerReview, })); } /** Rules for a branch. Patterns starting with `refs/tags/` only ever apply to tags. */ export function protectionFor(protections: BranchProtection[], branch: string): BranchProtection | null { const exact = protections.filter((protection) => protection.pattern === branch); const globbed = protections.filter( (protection) => protection.pattern !== branch && !protection.pattern.startsWith(TAG_PREFIX) && matchesGlob(protection.pattern, branch), ); return mergeProtections([...exact, ...globbed]); } /** Rules for a full `refs/tags/...` name, from patterns that start with `refs/tags/`. */ export function tagProtectionFor(protections: BranchProtection[], ref: string): BranchProtection | null { return mergeProtections( protections.filter( (protection) => protection.pattern.startsWith(TAG_PREFIX) && (protection.pattern === ref || matchesGlob(protection.pattern, ref)), ), ); } export function bypassesProtection(protection: BranchProtection, permission: Permission): boolean { return permission === "admin" && !protection.enforceAdmins; } /** Returns a rejection reason if a non-fast-forward update of a protected ref is not allowed. */ export function forcePushViolation(protection: BranchProtection | null, permission: Permission): string | null { if (!protection || bypassesProtection(protection, permission) || protection.allowForcePushes) return null; return "protected ref: force pushes are not allowed"; } /** Returns a rejection reason if a direct (non-PR) ref update to a branch is not allowed. */ export function directUpdateViolation( protection: BranchProtection | null, permission: Permission, isDeletion: boolean, ): string | null { if (!protection || bypassesProtection(protection, permission)) return null; if (isDeletion && !protection.allowDeletions) return "protected branch: deletion is not allowed"; if (!isDeletion && protection.requirePullRequest) return "protected branch: changes must be made through a pull request"; return null; } /** * Rejection reason for a tag create, move or delete under a `refs/tags/` rule. Creation is restricted to * rule bypassers unless the rule allows force pushes (a rule that lets tags move cannot meaningfully * forbid new ones); otherwise delete-then-recreate would be a move that `allow_force_pushes = 0` forbids. * Deletion follows `allow_deletions`; moving an existing tag is a force push. */ export function tagUpdateViolation( protection: BranchProtection | null, permission: Permission, change: "create" | "move" | "delete", ): string | null { if (!protection || bypassesProtection(protection, permission)) return null; if (change === "delete") return protection.allowDeletions ? null : "protected tag: deletion is not allowed"; if (change === "move") return protection.allowForcePushes ? null : "protected tag: tags cannot be moved"; return protection.allowForcePushes ? null : "protected tag: only administrators may create tags matching this rule"; }