CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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 | |
| 40d3e3f | 9 | * api/** @acme/backend # Block B3: team reference |
| 3ef4c9d | 10 | * |
| 40d3e3f | 11 | * Ownership is resolved by last-matching rule (GitHub parity). |
| 12 | * | |
| 13 | * Tokens containing a `/` are treated as team references of the form | |
| 14 | * `@orgSlug/teamSlug`. They are stored as-is and expanded to the team's | |
| 15 | * current membership at review-request time. | |
| 3ef4c9d | 16 | */ |
| 17 | ||
| 40d3e3f | 18 | import { and, eq } from "drizzle-orm"; |
| 3ef4c9d | 19 | import { db } from "../db"; |
| 40d3e3f | 20 | import { |
| 21 | codeOwners, | |
| 22 | organizations, | |
| 23 | teams, | |
| 24 | teamMembers, | |
| 25 | users, | |
| 26 | } from "../db/schema"; | |
| 3ef4c9d | 27 | |
| 28 | export interface OwnerRule { | |
| 29 | pattern: string; | |
| 40d3e3f | 30 | /** |
| 31 | * Owner tokens. Usernames are stored without the leading `@`; | |
| 32 | * team references are stored as `org/team` (also no `@`). | |
| 33 | * Use `isTeamToken(tok)` to distinguish. | |
| 34 | */ | |
| 35 | owners: string[]; | |
| 36 | } | |
| 37 | ||
| 38 | export function isTeamToken(token: string): boolean { | |
| 39 | return token.includes("/"); | |
| 3ef4c9d | 40 | } |
| 41 | ||
| 42 | export function parseCodeowners(content: string): OwnerRule[] { | |
| 43 | const rules: OwnerRule[] = []; | |
| 44 | for (const rawLine of content.split("\n")) { | |
| 45 | const line = rawLine.replace(/#.*$/, "").trim(); | |
| 46 | if (!line) continue; | |
| 47 | const parts = line.split(/\s+/); | |
| 48 | if (parts.length < 2) continue; | |
| 49 | const pattern = parts[0]; | |
| 50 | const owners = parts | |
| 51 | .slice(1) | |
| 52 | .map((o) => o.replace(/^@/, "").trim()) | |
| 53 | .filter(Boolean); | |
| 54 | if (owners.length === 0) continue; | |
| 55 | rules.push({ pattern, owners }); | |
| 56 | } | |
| 57 | return rules; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`. | |
| 62 | * Patterns anchored at the repo root if they start with `/`. | |
| 63 | */ | |
| 64 | function patternToRegex(pattern: string): RegExp { | |
| 65 | const anchored = pattern.startsWith("/"); | |
| 66 | let p = anchored ? pattern.slice(1) : pattern; | |
| 67 | // Escape regex metacharacters except * and / | |
| 68 | p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); | |
| 69 | p = p.replace(/\*\*/g, "__DOUBLEGLOB__"); | |
| 70 | p = p.replace(/\*/g, "[^/]*"); | |
| 71 | p = p.replace(/__DOUBLEGLOB__/g, ".*"); | |
| 72 | const prefix = anchored ? "^" : "^(?:.*/)?"; | |
| 73 | const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$"; | |
| 74 | return new RegExp(prefix + p + suffix); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Return owner usernames for a given file path. Last matching rule wins. | |
| 79 | */ | |
| 80 | export function ownersForPath( | |
| 81 | path: string, | |
| 82 | rules: OwnerRule[] | |
| 83 | ): string[] { | |
| 84 | let matched: string[] = []; | |
| 85 | for (const r of rules) { | |
| 86 | if (patternToRegex(r.pattern).test(path)) { | |
| 87 | matched = r.owners; | |
| 88 | } | |
| 89 | } | |
| 90 | return matched; | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Replace all rules for a repo in the DB. | |
| 95 | */ | |
| 96 | export async function syncCodeowners( | |
| 97 | repositoryId: string, | |
| 98 | rules: OwnerRule[] | |
| 99 | ): Promise<void> { | |
| 100 | try { | |
| 101 | await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId)); | |
| 102 | if (rules.length === 0) return; | |
| 103 | await db.insert(codeOwners).values( | |
| 104 | rules.map((r) => ({ | |
| 105 | repositoryId, | |
| 106 | pathPattern: r.pattern, | |
| 107 | ownerUsernames: r.owners.join(","), | |
| 108 | })) | |
| 109 | ); | |
| 110 | } catch (err) { | |
| 111 | console.error("[codeowners] sync failed:", err); | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 40d3e3f | 115 | /** |
| 116 | * Resolve a single `org/team` token to the set of usernames currently on | |
| 117 | * the team. Returns `[]` on unknown org, unknown team, or DB error — never | |
| 118 | * throws. Pure helper; exported for unit tests. | |
| 119 | */ | |
| 120 | export async function expandTeamToken(token: string): Promise<string[]> { | |
| 121 | if (!isTeamToken(token)) return []; | |
| 122 | const [orgSlug, teamSlug] = token.split("/", 2); | |
| 123 | if (!orgSlug || !teamSlug) return []; | |
| 124 | try { | |
| 125 | const [org] = await db | |
| 126 | .select({ id: organizations.id }) | |
| 127 | .from(organizations) | |
| 128 | .where(eq(organizations.slug, orgSlug)) | |
| 129 | .limit(1); | |
| 130 | if (!org) return []; | |
| 131 | const [team] = await db | |
| 132 | .select({ id: teams.id }) | |
| 133 | .from(teams) | |
| 134 | .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug))) | |
| 135 | .limit(1); | |
| 136 | if (!team) return []; | |
| 137 | const rows = await db | |
| 138 | .select({ username: users.username }) | |
| 139 | .from(teamMembers) | |
| 140 | .innerJoin(users, eq(users.id, teamMembers.userId)) | |
| 141 | .where(eq(teamMembers.teamId, team.id)); | |
| 142 | return rows.map((r) => r.username); | |
| 143 | } catch (err) { | |
| 144 | console.error("[codeowners] expandTeamToken:", err); | |
| 145 | return []; | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Expand a list of owner tokens to concrete usernames. | |
| 151 | * - Plain usernames pass through. | |
| 152 | * - `org/team` tokens are expanded to the team's current members. | |
| 153 | * - Unknown tokens are dropped. | |
| 154 | */ | |
| 155 | export async function expandOwnerTokens(tokens: string[]): Promise<string[]> { | |
| 156 | const out = new Set<string>(); | |
| 157 | for (const t of tokens) { | |
| 158 | if (!t) continue; | |
| 159 | if (isTeamToken(t)) { | |
| 160 | for (const u of await expandTeamToken(t)) out.add(u); | |
| 161 | } else { | |
| 162 | out.add(t); | |
| 163 | } | |
| 164 | } | |
| 165 | return [...out]; | |
| 166 | } | |
| 167 | ||
| 3ef4c9d | 168 | /** |
| 169 | * Given a PR's changed file list, return all unique owner usernames to | |
| 40d3e3f | 170 | * auto-request review from. Team references are expanded. |
| 3ef4c9d | 171 | */ |
| 172 | export async function reviewersForChangedFiles( | |
| 173 | repositoryId: string, | |
| 174 | paths: string[] | |
| 175 | ): Promise<string[]> { | |
| 176 | try { | |
| 177 | const rules = await db | |
| 178 | .select() | |
| 179 | .from(codeOwners) | |
| 180 | .where(eq(codeOwners.repositoryId, repositoryId)); | |
| 181 | const parsed: OwnerRule[] = rules.map((r) => ({ | |
| 182 | pattern: r.pathPattern, | |
| 183 | owners: r.ownerUsernames.split(",").filter(Boolean), | |
| 184 | })); | |
| 40d3e3f | 185 | const tokens = new Set<string>(); |
| 3ef4c9d | 186 | for (const p of paths) { |
| 40d3e3f | 187 | for (const u of ownersForPath(p, parsed)) tokens.add(u); |
| 3ef4c9d | 188 | } |
| 40d3e3f | 189 | return await expandOwnerTokens([...tokens]); |
| 3ef4c9d | 190 | } catch { |
| 191 | return []; | |
| 192 | } | |
| 193 | } |