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"; | |
| ec9e3e3 | 27 | import { getBlob } from "../git/repository"; |
| 3ef4c9d | 28 | |
| 29 | export interface OwnerRule { | |
| 30 | pattern: string; | |
| 40d3e3f | 31 | /** |
| 32 | * Owner tokens. Usernames are stored without the leading `@`; | |
| 33 | * team references are stored as `org/team` (also no `@`). | |
| 34 | * Use `isTeamToken(tok)` to distinguish. | |
| 35 | */ | |
| 36 | owners: string[]; | |
| 37 | } | |
| 38 | ||
| ec9e3e3 | 39 | /** Public alias used by new callers (matches task spec interface). */ |
| 40 | export type CodeOwnerRule = OwnerRule; | |
| 41 | ||
| 40d3e3f | 42 | export function isTeamToken(token: string): boolean { |
| 43 | return token.includes("/"); | |
| 3ef4c9d | 44 | } |
| 45 | ||
| 46 | export function parseCodeowners(content: string): OwnerRule[] { | |
| 47 | const rules: OwnerRule[] = []; | |
| 48 | for (const rawLine of content.split("\n")) { | |
| 49 | const line = rawLine.replace(/#.*$/, "").trim(); | |
| 50 | if (!line) continue; | |
| 51 | const parts = line.split(/\s+/); | |
| 52 | if (parts.length < 2) continue; | |
| 53 | const pattern = parts[0]; | |
| 54 | const owners = parts | |
| 55 | .slice(1) | |
| 56 | .map((o) => o.replace(/^@/, "").trim()) | |
| 57 | .filter(Boolean); | |
| 58 | if (owners.length === 0) continue; | |
| 59 | rules.push({ pattern, owners }); | |
| 60 | } | |
| 61 | return rules; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`. | |
| 66 | * Patterns anchored at the repo root if they start with `/`. | |
| 67 | */ | |
| 68 | function patternToRegex(pattern: string): RegExp { | |
| 69 | const anchored = pattern.startsWith("/"); | |
| 70 | let p = anchored ? pattern.slice(1) : pattern; | |
| 71 | // Escape regex metacharacters except * and / | |
| 72 | p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); | |
| 73 | p = p.replace(/\*\*/g, "__DOUBLEGLOB__"); | |
| 74 | p = p.replace(/\*/g, "[^/]*"); | |
| 75 | p = p.replace(/__DOUBLEGLOB__/g, ".*"); | |
| 76 | const prefix = anchored ? "^" : "^(?:.*/)?"; | |
| 77 | const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$"; | |
| 78 | return new RegExp(prefix + p + suffix); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Return owner usernames for a given file path. Last matching rule wins. | |
| 83 | */ | |
| 84 | export function ownersForPath( | |
| 85 | path: string, | |
| 86 | rules: OwnerRule[] | |
| 87 | ): string[] { | |
| 88 | let matched: string[] = []; | |
| 89 | for (const r of rules) { | |
| 90 | if (patternToRegex(r.pattern).test(path)) { | |
| 91 | matched = r.owners; | |
| 92 | } | |
| 93 | } | |
| 94 | return matched; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Replace all rules for a repo in the DB. | |
| 99 | */ | |
| 100 | export async function syncCodeowners( | |
| 101 | repositoryId: string, | |
| 102 | rules: OwnerRule[] | |
| 103 | ): Promise<void> { | |
| 104 | try { | |
| 105 | await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId)); | |
| 106 | if (rules.length === 0) return; | |
| 107 | await db.insert(codeOwners).values( | |
| 108 | rules.map((r) => ({ | |
| 109 | repositoryId, | |
| 110 | pathPattern: r.pattern, | |
| 111 | ownerUsernames: r.owners.join(","), | |
| 112 | })) | |
| 113 | ); | |
| 114 | } catch (err) { | |
| 115 | console.error("[codeowners] sync failed:", err); | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 40d3e3f | 119 | /** |
| 120 | * Resolve a single `org/team` token to the set of usernames currently on | |
| 121 | * the team. Returns `[]` on unknown org, unknown team, or DB error — never | |
| 122 | * throws. Pure helper; exported for unit tests. | |
| 123 | */ | |
| 124 | export async function expandTeamToken(token: string): Promise<string[]> { | |
| 125 | if (!isTeamToken(token)) return []; | |
| 126 | const [orgSlug, teamSlug] = token.split("/", 2); | |
| 127 | if (!orgSlug || !teamSlug) return []; | |
| 128 | try { | |
| 129 | const [org] = await db | |
| 130 | .select({ id: organizations.id }) | |
| 131 | .from(organizations) | |
| 132 | .where(eq(organizations.slug, orgSlug)) | |
| 133 | .limit(1); | |
| 134 | if (!org) return []; | |
| 135 | const [team] = await db | |
| 136 | .select({ id: teams.id }) | |
| 137 | .from(teams) | |
| 138 | .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug))) | |
| 139 | .limit(1); | |
| 140 | if (!team) return []; | |
| 141 | const rows = await db | |
| 142 | .select({ username: users.username }) | |
| 143 | .from(teamMembers) | |
| 144 | .innerJoin(users, eq(users.id, teamMembers.userId)) | |
| 145 | .where(eq(teamMembers.teamId, team.id)); | |
| 146 | return rows.map((r) => r.username); | |
| 147 | } catch (err) { | |
| 148 | console.error("[codeowners] expandTeamToken:", err); | |
| 149 | return []; | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Expand a list of owner tokens to concrete usernames. | |
| 155 | * - Plain usernames pass through. | |
| 156 | * - `org/team` tokens are expanded to the team's current members. | |
| 157 | * - Unknown tokens are dropped. | |
| 158 | */ | |
| 159 | export async function expandOwnerTokens(tokens: string[]): Promise<string[]> { | |
| 160 | const out = new Set<string>(); | |
| 161 | for (const t of tokens) { | |
| 162 | if (!t) continue; | |
| 163 | if (isTeamToken(t)) { | |
| 164 | for (const u of await expandTeamToken(t)) out.add(u); | |
| 165 | } else { | |
| 166 | out.add(t); | |
| 167 | } | |
| 168 | } | |
| 169 | return [...out]; | |
| 170 | } | |
| 171 | ||
| 3ef4c9d | 172 | /** |
| 173 | * Given a PR's changed file list, return all unique owner usernames to | |
| 40d3e3f | 174 | * auto-request review from. Team references are expanded. |
| 3ef4c9d | 175 | */ |
| 176 | export async function reviewersForChangedFiles( | |
| 177 | repositoryId: string, | |
| 178 | paths: string[] | |
| 179 | ): Promise<string[]> { | |
| 180 | try { | |
| 181 | const rules = await db | |
| 182 | .select() | |
| 183 | .from(codeOwners) | |
| 184 | .where(eq(codeOwners.repositoryId, repositoryId)); | |
| 185 | const parsed: OwnerRule[] = rules.map((r) => ({ | |
| 186 | pattern: r.pathPattern, | |
| 187 | owners: r.ownerUsernames.split(",").filter(Boolean), | |
| 188 | })); | |
| 40d3e3f | 189 | const tokens = new Set<string>(); |
| 3ef4c9d | 190 | for (const p of paths) { |
| 40d3e3f | 191 | for (const u of ownersForPath(p, parsed)) tokens.add(u); |
| 3ef4c9d | 192 | } |
| 40d3e3f | 193 | return await expandOwnerTokens([...tokens]); |
| 3ef4c9d | 194 | } catch { |
| 195 | return []; | |
| 196 | } | |
| 197 | } | |
| ec9e3e3 | 198 | |
| 199 | /** | |
| 200 | * matchOwners — public alias for `reviewersForChangedFiles` using in-memory | |
| 201 | * rules instead of DB. Accepts the pre-parsed rules array directly. | |
| 202 | * Deduplicated; team tokens are NOT expanded here (use expandOwnerTokens). | |
| 203 | */ | |
| 204 | export function matchOwners(filePaths: string[], rules: OwnerRule[]): string[] { | |
| 205 | const out = new Set<string>(); | |
| 206 | for (const p of filePaths) { | |
| 207 | for (const u of ownersForPath(p, rules)) out.add(u); | |
| 208 | } | |
| 209 | return Array.from(out); | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Fetch and parse the CODEOWNERS file for a repo from git. | |
| 214 | * Checks three canonical locations in order: | |
| 215 | * 1. `CODEOWNERS` | |
| 216 | * 2. `.github/CODEOWNERS` | |
| 217 | * 3. `docs/CODEOWNERS` | |
| 218 | * Returns [] if none found. | |
| 219 | */ | |
| 220 | export async function getCodeownersForRepo( | |
| 221 | owner: string, | |
| 222 | repo: string, | |
| 223 | branch: string | |
| 224 | ): Promise<OwnerRule[]> { | |
| 225 | const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]; | |
| 226 | ||
| 227 | for (const path of candidates) { | |
| 228 | try { | |
| 229 | const blob = await getBlob(owner, repo, branch, path); | |
| 230 | if (blob && !blob.isBinary && blob.content) { | |
| 231 | return parseCodeowners(blob.content); | |
| 232 | } | |
| 233 | } catch { | |
| 234 | // file not found — try next candidate | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | return []; | |
| 239 | } |