import { env, waitUntil } from "cloudflare:workers"; import { all, chunks, 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 decoder = new TextDecoder(); type Rule = { pattern: RegExp; owners: string[] }; 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 and email owners are ignored. */ export function parseCodeOwners(source: string): Rule[] { const rules: Rule[] = []; for (const line of source.split("\n")) { const trimmed = line.replace(/(^|\s)#.*$/, "").trim(); if (!trimmed) continue; const [pattern, ...owners] = trimmed.split(/\s+/); rules.push({ pattern: patternToRegExp(pattern), owners: owners.flatMap((owner) => { const match = USER_OWNER.exec(owner); return match ? [match[1].toLowerCase()] : []; }), }); if (rules.length >= MAX_RULES) break; } return rules; } 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 []; 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 || bytes.byteLength > MAX_CODEOWNERS_BYTES) return []; return parseCodeOwners(decoder.decode(bytes)); } return []; } export type OwnedPaths = Map; /** * 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:v1:${baseRepo.artifacts_name}:${pull.base_sha}:${headRepo.artifacts_name}:${pull.head_sha}`; const cached = await env.KV.get>(key, "json"); if (cached) return new Map(cached); const owned = await computeCodeOwners(pull, baseRepo, headRepo); waitUntil(env.KV.put(key, JSON.stringify([...owned]), { expirationTtl: OWNED_CACHE_TTL_SECONDS })); return owned; } async function computeCodeOwners(pull: PullRow, baseRepo: RepoRow, headRepo: RepoRow): Promise { const rules = await withStore(baseRepo.artifacts_name, (store) => readCodeOwners(store, pull.base_sha)); if (rules.length === 0) return new Map(); const paths = 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) return []; const { changes } = await diffTreesBounded(headStore, mergeBase?.treeSha ?? null, head.treeSha); return changes.flatMap((change) => (change.oldPath ? [change.path, change.oldPath] : [change.path])); }), ); const owned: OwnedPaths = new Map(); for (const path of paths.slice(0, MAX_OWNED_PATHS)) { const owners = ownersFor(rules, path); if (owners.length > 0) owned.set(path, owners); } return owned; } /** Resolves owner logins to user account ids, bounded and chunked under the D1 parameter limit. */ export async function ownerAccountIds(owned: OwnedPaths): Promise> { const names = [...new Set([...owned.values()].flat())].slice(0, MAX_RULES); const out = new Map(); 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). */ export function pathsAwaitingOwners( owned: OwnedPaths, ids: Map, approvers: Set, authorId: string, ): string[] { const waiting: string[] = []; for (const [path, owners] of owned) { const ownerIds = owners.flatMap((owner) => { const id = ids.get(owner); return id && id !== authorId ? [id] : []; }); if (ownerIds.length > 0 && !ownerIds.some((id) => approvers.has(id))) waiting.push(path); } return waiting; }