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