import { env, waitUntil } from "cloudflare:workers"; import { all, chunks, MAX_IN_LIST, placeholders } from "./db"; import { withStore } from "./git/store"; import type { GitStore } from "./git/store"; import { diffTreesBounded } from "./git/diff"; import { compareCommits } from "./git/history"; import { globToRegExp } from "~/lib/glob"; import type { RepoRow } from "~/lib/types"; import type { PullRow } from "./pulls"; const CODEOWNERS_PATHS = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]; const MAX_CODEOWNERS_BYTES = 64 * 1024; const MAX_RULES = 500; const MAX_OWNED_PATHS = 300; const MAX_REQUESTED_OWNERS = 15; const OWNED_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; const USER_OWNER = /^@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/; const EMAIL_OWNER = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_PREFIX = "email:"; const decoder = new TextDecoder(); type Rule = { pattern: RegExp; owners: string[] }; type ParsedCodeOwners = { rules: Rule[]; incomplete: string | null }; /** Owner tokens: lowercase login, `email:
`, or the raw token (teams, malformed) which never resolves. */ function ownerToken(owner: string): string { const user = USER_OWNER.exec(owner); if (user) return user[1].toLowerCase(); if (EMAIL_OWNER.test(owner)) return `${EMAIL_PREFIX}${owner.toLowerCase()}`; return `unresolved:${owner}`; } function patternToRegExp(raw: string): RegExp { const directoryOnly = raw.endsWith("/"); let pattern = directoryOnly ? raw.slice(0, -1) : raw; const anchored = pattern.startsWith("/") || pattern.includes("/"); pattern = pattern.replace(/^\//, ""); const body = globToRegExp(pattern).source.slice(1, -1); const suffix = directoryOnly ? "/.*" : pattern.endsWith("/*") ? "" : "(?:/.*)?"; return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${body}${suffix}$`); } /** * Parses CODEOWNERS (gitignore-style patterns, last match wins). Team owners are kept as unresolvable tokens so * the paths they own fail closed instead of silently becoming unowned. */ export function parseCodeOwners(source: string): ParsedCodeOwners { const rules: Rule[] = []; for (const line of source.split("\n")) { const trimmed = line.replace(/(^|\s)#.*$/, "").trim(); if (!trimmed) continue; if (rules.length >= MAX_RULES) return { rules, incomplete: `CODEOWNERS has more than ${MAX_RULES} rules` }; const [pattern, ...owners] = trimmed.split(/\s+/); rules.push({ pattern: patternToRegExp(pattern), owners: owners.map(ownerToken) }); } return { rules, incomplete: null }; } export function ownersFor(rules: Rule[], path: string): string[] { for (let index = rules.length - 1; index >= 0; index--) { if (rules[index].pattern.test(path)) return rules[index].owners; } return []; } async function readCodeOwners(store: GitStore, sha: string): Promise { const commit = await store.commit(sha); if (!commit) return { rules: [], incomplete: "Base commit is unavailable for code owner evaluation" }; for (const path of CODEOWNERS_PATHS) { const entry = await store.entryAtPath(commit.treeSha, path); if (!entry || entry.type !== "blob") continue; const bytes = await store.blob(entry.sha); if (!bytes) return { rules: [], incomplete: "CODEOWNERS could not be read" }; if (bytes.byteLength > MAX_CODEOWNERS_BYTES) { return { rules: [], incomplete: `CODEOWNERS is larger than ${MAX_CODEOWNERS_BYTES / 1024} KiB` }; } return parseCodeOwners(decoder.decode(bytes)); } return { rules: [], incomplete: null }; } export type OwnedPaths = Map; /** `incomplete` is set when not every changed path could be evaluated; the merge gate must then fail closed. */ export type CodeOwnership = { owned: OwnedPaths; incomplete: string | null }; /** * Maps each changed file of the pull request to its code owners (rules read from the base branch). * Cached in KV per (base, head) commit pair because the merge gate evaluates it on every page view. */ export async function pullCodeOwners(pull: PullRow, baseRepo: RepoRow, headRepo: RepoRow): Promise { const key = `codeowners:v2:${baseRepo.artifacts_name}:${pull.base_sha}:${headRepo.artifacts_name}:${pull.head_sha}`; const cached = await env.KV.get<{ owned: Array<[string, string[]]>; incomplete: string | null }>(key, "json"); if (cached) return { owned: new Map(cached.owned), incomplete: cached.incomplete }; const result = await computeCodeOwners(pull, baseRepo, headRepo); waitUntil( env.KV.put(key, JSON.stringify({ owned: [...result.owned], incomplete: result.incomplete }), { expirationTtl: OWNED_CACHE_TTL_SECONDS, }), ); return result; } async function computeCodeOwners(pull: PullRow, baseRepo: RepoRow, headRepo: RepoRow): Promise { const parsed = await withStore(baseRepo.artifacts_name, (store) => readCodeOwners(store, pull.base_sha)); const { rules } = parsed; if (rules.length === 0) return { owned: new Map(), incomplete: parsed.incomplete }; const diff = await withStore(baseRepo.artifacts_name, (baseStore) => withStore(headRepo.artifacts_name, async (headStore) => { const comparison = await compareCommits(baseStore, pull.base_sha, headStore, pull.head_sha); const mergeBaseSha = comparison.mergeBase ?? pull.merge_base_sha; const [mergeBase, head] = await Promise.all([ mergeBaseSha ? headStore.commit(mergeBaseSha) : null, headStore.commit(pull.head_sha), ]); if (!head || (mergeBaseSha && !mergeBase)) return null; const { changes, truncated } = await diffTreesBounded(headStore, mergeBase?.treeSha ?? null, head.treeSha); const paths = changes.flatMap((change) => (change.oldPath ? [change.path, change.oldPath] : [change.path])); return { paths, truncated }; }), ); if (!diff) return { owned: new Map(), incomplete: "Pull request commits are unavailable for code owner evaluation" }; const owned: OwnedPaths = new Map(); const tooMany = diff.truncated || diff.paths.length > MAX_OWNED_PATHS; for (const path of diff.paths.slice(0, MAX_OWNED_PATHS)) { const owners = ownersFor(rules, path); if (owners.length > 0) owned.set(path, owners); } const incomplete = parsed.incomplete ?? (tooMany ? `Too many changed files to evaluate code owners (limit ${MAX_OWNED_PATHS})` : null); return { owned, incomplete }; } /** * Resolves owner tokens (logins, verified emails) to user account ids, bounded and chunked under the D1 * parameter limit. Tokens that do not resolve are absent from the map. */ export async function ownerAccountIds(owned: OwnedPaths): Promise> { const tokens = [...new Set([...owned.values()].flat())].slice(0, MAX_RULES); const names = tokens.filter((token) => !token.includes(":")); const emails = tokens.flatMap((token) => (token.startsWith(EMAIL_PREFIX) ? [token.slice(EMAIL_PREFIX.length)] : [])); const out = new Map(); for (const group of chunks(emails, MAX_IN_LIST / 2)) { const rows = await all<{ id: string; email: string }>( `SELECT account_id AS id, LOWER(email) AS email FROM users WHERE email_verified_at IS NOT NULL AND LOWER(email) IN (${placeholders(group.length)}) UNION SELECT user_id AS id, LOWER(email) AS email FROM user_emails WHERE verified_at IS NOT NULL AND LOWER(email) IN (${placeholders(group.length)})`, ...group, ...group, ); for (const row of rows) out.set(`${EMAIL_PREFIX}${row.email}`, row.id); } for (const group of chunks(names)) { const rows = await all<{ id: string; name: string }>( `SELECT a.id, LOWER(a.name) AS name FROM accounts a JOIN users u ON u.account_id = a.id WHERE LOWER(a.name) IN (${placeholders(group.length)})`, ...group, ); for (const row of rows) out.set(row.name, row.id); } return out; } /** Owner user ids to auto-request for review, excluding the author. */ export function ownersToRequest(owned: OwnedPaths, ids: Map, authorId: string): string[] { const requested = new Set(); for (const owners of owned.values()) { for (const owner of owners) { const id = ids.get(owner); if (id && id !== authorId) requested.add(id); } } return [...requested].slice(0, MAX_REQUESTED_OWNERS); } /** * Paths whose owners have not approved (an author owning a file does not count as approval). A path whose owners * include tokens that cannot be resolved to a user (teams, unknown logins, unverified emails) fails closed and waits * unless a resolved owner approved. */ export function pathsAwaitingOwners( owned: OwnedPaths, ids: Map, approvers: Set, authorId: string, ): string[] { const waiting: string[] = []; for (const [path, owners] of owned) { const resolved = owners.map((owner) => ids.get(owner) ?? null); const others = resolved.filter((id): id is string => id !== null && id !== authorId); const ownedOnlyByAuthor = others.length === 0 && resolved.every((id) => id === authorId); if (!ownedOnlyByAuthor && !others.some((id) => approvers.has(id))) waiting.push(path); } return waiting; }