Blame · Line-by-line history
codeowners.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 2 | * CODEOWNERS parser + sync. | |
| 3 | * | |
| 4 | * Parses a CODEOWNERS file (GitHub-compatible syntax): | |
| 5 | * # comments allowed | |
| 6 | * * @alice | |
| 7 | * src/api/** @bob @carol | |
| 8 | * /docs @alice | |
| 9 | * | |
| 10 | * Ownership is resolved by last-matching rule (that's how GitHub does it). | |
| 11 | */ | |
| 12 | ||
| 13 | import { eq } from "drizzle-orm"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { codeOwners } from "../db/schema"; | |
| 16 | ||
| 17 | export interface OwnerRule { | |
| 18 | pattern: string; | |
| 19 | owners: string[]; // usernames, stripped of leading @ | |
| 20 | } | |
| 21 | ||
| 22 | export function parseCodeowners(content: string): OwnerRule[] { | |
| 23 | const rules: OwnerRule[] = []; | |
| 24 | for (const rawLine of content.split("\n")) { | |
| 25 | const line = rawLine.replace(/#.*$/, "").trim(); | |
| 26 | if (!line) continue; | |
| 27 | const parts = line.split(/\s+/); | |
| 28 | if (parts.length < 2) continue; | |
| 29 | const pattern = parts[0]; | |
| 30 | const owners = parts | |
| 31 | .slice(1) | |
| 32 | .map((o) => o.replace(/^@/, "").trim()) | |
| 33 | .filter(Boolean); | |
| 34 | if (owners.length === 0) continue; | |
| 35 | rules.push({ pattern, owners }); | |
| 36 | } | |
| 37 | return rules; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`. | |
| 42 | * Patterns anchored at the repo root if they start with `/`. | |
| 43 | */ | |
| 44 | function patternToRegex(pattern: string): RegExp { | |
| 45 | const anchored = pattern.startsWith("/"); | |
| 46 | let p = anchored ? pattern.slice(1) : pattern; | |
| 47 | // Escape regex metacharacters except * and / | |
| 48 | p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); | |
| 49 | p = p.replace(/\*\*/g, "__DOUBLEGLOB__"); | |
| 50 | p = p.replace(/\*/g, "[^/]*"); | |
| 51 | p = p.replace(/__DOUBLEGLOB__/g, ".*"); | |
| 52 | const prefix = anchored ? "^" : "^(?:.*/)?"; | |
| 53 | const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$"; | |
| 54 | return new RegExp(prefix + p + suffix); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Return owner usernames for a given file path. Last matching rule wins. | |
| 59 | */ | |
| 60 | export function ownersForPath( | |
| 61 | path: string, | |
| 62 | rules: OwnerRule[] | |
| 63 | ): string[] { | |
| 64 | let matched: string[] = []; | |
| 65 | for (const r of rules) { | |
| 66 | if (patternToRegex(r.pattern).test(path)) { | |
| 67 | matched = r.owners; | |
| 68 | } | |
| 69 | } | |
| 70 | return matched; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Replace all rules for a repo in the DB. | |
| 75 | */ | |
| 76 | export async function syncCodeowners( | |
| 77 | repositoryId: string, | |
| 78 | rules: OwnerRule[] | |
| 79 | ): Promise<void> { | |
| 80 | try { | |
| 81 | await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId)); | |
| 82 | if (rules.length === 0) return; | |
| 83 | await db.insert(codeOwners).values( | |
| 84 | rules.map((r) => ({ | |
| 85 | repositoryId, | |
| 86 | pathPattern: r.pattern, | |
| 87 | ownerUsernames: r.owners.join(","), | |
| 88 | })) | |
| 89 | ); | |
| 90 | } catch (err) { | |
| 91 | console.error("[codeowners] sync failed:", err); | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * Given a PR's changed file list, return all unique owner usernames to | |
| 97 | * auto-request review from. | |
| 98 | */ | |
| 99 | export async function reviewersForChangedFiles( | |
| 100 | repositoryId: string, | |
| 101 | paths: string[] | |
| 102 | ): Promise<string[]> { | |
| 103 | try { | |
| 104 | const rules = await db | |
| 105 | .select() | |
| 106 | .from(codeOwners) | |
| 107 | .where(eq(codeOwners.repositoryId, repositoryId)); | |
| 108 | const parsed: OwnerRule[] = rules.map((r) => ({ | |
| 109 | pattern: r.pathPattern, | |
| 110 | owners: r.ownerUsernames.split(",").filter(Boolean), | |
| 111 | })); | |
| 112 | const result = new Set<string>(); | |
| 113 | for (const p of paths) { | |
| 114 | for (const u of ownersForPath(p, parsed)) result.add(u); | |
| 115 | } | |
| 116 | return [...result]; | |
| 117 | } catch { | |
| 118 | return []; | |
| 119 | } | |
| 120 | } |