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 | ||
| 91b054e | 18 | import { and, desc, eq } from "drizzle-orm"; |
| 3ef4c9d | 19 | import { db } from "../db"; |
| 40d3e3f | 20 | import { |
| 21 | codeOwners, | |
| 22 | organizations, | |
| 23 | teams, | |
| 24 | teamMembers, | |
| 25 | users, | |
| 91b054e | 26 | prReviews, |
| 40d3e3f | 27 | } from "../db/schema"; |
| ec9e3e3 | 28 | import { getBlob } from "../git/repository"; |
| 3ef4c9d | 29 | |
| 30 | export interface OwnerRule { | |
| 31 | pattern: string; | |
| 40d3e3f | 32 | /** |
| 33 | * Owner tokens. Usernames are stored without the leading `@`; | |
| 34 | * team references are stored as `org/team` (also no `@`). | |
| 35 | * Use `isTeamToken(tok)` to distinguish. | |
| 36 | */ | |
| 37 | owners: string[]; | |
| 38 | } | |
| 39 | ||
| ec9e3e3 | 40 | /** Public alias used by new callers (matches task spec interface). */ |
| 41 | export type CodeOwnerRule = OwnerRule; | |
| 42 | ||
| 40d3e3f | 43 | export function isTeamToken(token: string): boolean { |
| 44 | return token.includes("/"); | |
| 3ef4c9d | 45 | } |
| 46 | ||
| 47 | export function parseCodeowners(content: string): OwnerRule[] { | |
| 48 | const rules: OwnerRule[] = []; | |
| 49 | for (const rawLine of content.split("\n")) { | |
| 50 | const line = rawLine.replace(/#.*$/, "").trim(); | |
| 51 | if (!line) continue; | |
| 52 | const parts = line.split(/\s+/); | |
| 53 | if (parts.length < 2) continue; | |
| 54 | const pattern = parts[0]; | |
| 55 | const owners = parts | |
| 56 | .slice(1) | |
| 57 | .map((o) => o.replace(/^@/, "").trim()) | |
| 58 | .filter(Boolean); | |
| 59 | if (owners.length === 0) continue; | |
| 60 | rules.push({ pattern, owners }); | |
| 61 | } | |
| 62 | return rules; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`. | |
| 67 | * Patterns anchored at the repo root if they start with `/`. | |
| 68 | */ | |
| 69 | function patternToRegex(pattern: string): RegExp { | |
| 70 | const anchored = pattern.startsWith("/"); | |
| 71 | let p = anchored ? pattern.slice(1) : pattern; | |
| 72 | // Escape regex metacharacters except * and / | |
| 73 | p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); | |
| 74 | p = p.replace(/\*\*/g, "__DOUBLEGLOB__"); | |
| 75 | p = p.replace(/\*/g, "[^/]*"); | |
| 76 | p = p.replace(/__DOUBLEGLOB__/g, ".*"); | |
| 77 | const prefix = anchored ? "^" : "^(?:.*/)?"; | |
| 78 | const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$"; | |
| 79 | return new RegExp(prefix + p + suffix); | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Return owner usernames for a given file path. Last matching rule wins. | |
| 84 | */ | |
| 85 | export function ownersForPath( | |
| 86 | path: string, | |
| 87 | rules: OwnerRule[] | |
| 88 | ): string[] { | |
| 89 | let matched: string[] = []; | |
| 90 | for (const r of rules) { | |
| 91 | if (patternToRegex(r.pattern).test(path)) { | |
| 92 | matched = r.owners; | |
| 93 | } | |
| 94 | } | |
| 95 | return matched; | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Replace all rules for a repo in the DB. | |
| 100 | */ | |
| 101 | export async function syncCodeowners( | |
| 102 | repositoryId: string, | |
| 103 | rules: OwnerRule[] | |
| 104 | ): Promise<void> { | |
| 105 | try { | |
| 106 | await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId)); | |
| 107 | if (rules.length === 0) return; | |
| 108 | await db.insert(codeOwners).values( | |
| 109 | rules.map((r) => ({ | |
| 110 | repositoryId, | |
| 111 | pathPattern: r.pattern, | |
| 112 | ownerUsernames: r.owners.join(","), | |
| 113 | })) | |
| 114 | ); | |
| 115 | } catch (err) { | |
| 116 | console.error("[codeowners] sync failed:", err); | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 40d3e3f | 120 | /** |
| 121 | * Resolve a single `org/team` token to the set of usernames currently on | |
| 122 | * the team. Returns `[]` on unknown org, unknown team, or DB error — never | |
| 123 | * throws. Pure helper; exported for unit tests. | |
| 124 | */ | |
| 125 | export async function expandTeamToken(token: string): Promise<string[]> { | |
| 126 | if (!isTeamToken(token)) return []; | |
| 127 | const [orgSlug, teamSlug] = token.split("/", 2); | |
| 128 | if (!orgSlug || !teamSlug) return []; | |
| 129 | try { | |
| 130 | const [org] = await db | |
| 131 | .select({ id: organizations.id }) | |
| 132 | .from(organizations) | |
| 133 | .where(eq(organizations.slug, orgSlug)) | |
| 134 | .limit(1); | |
| 135 | if (!org) return []; | |
| 136 | const [team] = await db | |
| 137 | .select({ id: teams.id }) | |
| 138 | .from(teams) | |
| 139 | .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug))) | |
| 140 | .limit(1); | |
| 141 | if (!team) return []; | |
| 142 | const rows = await db | |
| 143 | .select({ username: users.username }) | |
| 144 | .from(teamMembers) | |
| 145 | .innerJoin(users, eq(users.id, teamMembers.userId)) | |
| 146 | .where(eq(teamMembers.teamId, team.id)); | |
| 147 | return rows.map((r) => r.username); | |
| 148 | } catch (err) { | |
| 149 | console.error("[codeowners] expandTeamToken:", err); | |
| 150 | return []; | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Expand a list of owner tokens to concrete usernames. | |
| 156 | * - Plain usernames pass through. | |
| 157 | * - `org/team` tokens are expanded to the team's current members. | |
| 158 | * - Unknown tokens are dropped. | |
| 159 | */ | |
| 160 | export async function expandOwnerTokens(tokens: string[]): Promise<string[]> { | |
| 161 | const out = new Set<string>(); | |
| 162 | for (const t of tokens) { | |
| 163 | if (!t) continue; | |
| 164 | if (isTeamToken(t)) { | |
| 165 | for (const u of await expandTeamToken(t)) out.add(u); | |
| 166 | } else { | |
| 167 | out.add(t); | |
| 168 | } | |
| 169 | } | |
| 170 | return [...out]; | |
| 171 | } | |
| 172 | ||
| 3ef4c9d | 173 | /** |
| 174 | * Given a PR's changed file list, return all unique owner usernames to | |
| 40d3e3f | 175 | * auto-request review from. Team references are expanded. |
| 3ef4c9d | 176 | */ |
| 177 | export async function reviewersForChangedFiles( | |
| 178 | repositoryId: string, | |
| 179 | paths: string[] | |
| 180 | ): Promise<string[]> { | |
| 181 | try { | |
| 182 | const rules = await db | |
| 183 | .select() | |
| 184 | .from(codeOwners) | |
| 185 | .where(eq(codeOwners.repositoryId, repositoryId)); | |
| 186 | const parsed: OwnerRule[] = rules.map((r) => ({ | |
| 187 | pattern: r.pathPattern, | |
| 188 | owners: r.ownerUsernames.split(",").filter(Boolean), | |
| 189 | })); | |
| 40d3e3f | 190 | const tokens = new Set<string>(); |
| 3ef4c9d | 191 | for (const p of paths) { |
| 40d3e3f | 192 | for (const u of ownersForPath(p, parsed)) tokens.add(u); |
| 3ef4c9d | 193 | } |
| 40d3e3f | 194 | return await expandOwnerTokens([...tokens]); |
| 3ef4c9d | 195 | } catch { |
| 196 | return []; | |
| 197 | } | |
| 198 | } | |
| ec9e3e3 | 199 | |
| 200 | /** | |
| 201 | * matchOwners — public alias for `reviewersForChangedFiles` using in-memory | |
| 202 | * rules instead of DB. Accepts the pre-parsed rules array directly. | |
| 203 | * Deduplicated; team tokens are NOT expanded here (use expandOwnerTokens). | |
| 204 | */ | |
| 205 | export function matchOwners(filePaths: string[], rules: OwnerRule[]): string[] { | |
| 206 | const out = new Set<string>(); | |
| 207 | for (const p of filePaths) { | |
| 208 | for (const u of ownersForPath(p, rules)) out.add(u); | |
| 209 | } | |
| 210 | return Array.from(out); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Fetch and parse the CODEOWNERS file for a repo from git. | |
| 215 | * Checks three canonical locations in order: | |
| 216 | * 1. `CODEOWNERS` | |
| 217 | * 2. `.github/CODEOWNERS` | |
| 218 | * 3. `docs/CODEOWNERS` | |
| 219 | * Returns [] if none found. | |
| 220 | */ | |
| 221 | export async function getCodeownersForRepo( | |
| 222 | owner: string, | |
| 223 | repo: string, | |
| 224 | branch: string | |
| 225 | ): Promise<OwnerRule[]> { | |
| 226 | const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]; | |
| 227 | ||
| 228 | for (const path of candidates) { | |
| 229 | try { | |
| 230 | const blob = await getBlob(owner, repo, branch, path); | |
| 231 | if (blob && !blob.isBinary && blob.content) { | |
| 232 | return parseCodeowners(blob.content); | |
| 233 | } | |
| 234 | } catch { | |
| 235 | // file not found — try next candidate | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | return []; | |
| 240 | } | |
| 91b054e | 241 | |
| 242 | /** | |
| 243 | * Default `loadApprovedUsernames` dependency for `requiredOwnersApproved` — | |
| 244 | * returns the set of usernames whose *latest* non-"commented" `pr_reviews` | |
| 245 | * row for this PR is `state === 'approved'`. Same dedup rule as | |
| 246 | * `countHumanApprovals()` in branch-protection.ts (most recent review per | |
| 247 | * reviewer wins; a stale "approved" followed by "changes_requested" does | |
| 248 | * NOT count as approved). | |
| 249 | */ | |
| 250 | async function loadApprovedUsernames(pullRequestId: string): Promise<Set<string>> { | |
| 251 | const rows = await db | |
| 252 | .select({ state: prReviews.state, username: users.username }) | |
| 253 | .from(prReviews) | |
| 254 | .innerJoin(users, eq(users.id, prReviews.reviewerId)) | |
| 255 | .where( | |
| 256 | and(eq(prReviews.pullRequestId, pullRequestId), eq(prReviews.isAi, false)) | |
| 257 | ) | |
| 258 | .orderBy(desc(prReviews.createdAt)); | |
| 259 | ||
| 260 | const latestByUsername = new Map<string, string>(); | |
| 261 | for (const r of rows) { | |
| 262 | if (r.state !== "commented" && !latestByUsername.has(r.username)) { | |
| 263 | latestByUsername.set(r.username, r.state); | |
| 264 | } | |
| 265 | } | |
| 266 | const approved = new Set<string>(); | |
| 267 | for (const [username, state] of latestByUsername) { | |
| 268 | if (state === "approved") approved.add(username); | |
| 269 | } | |
| 270 | return approved; | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Merge-time CODEOWNERS enforcement. | |
| 275 | * | |
| 276 | * `reviewersForChangedFiles()` above only ever *requests* CODEOWNERS | |
| 277 | * reviewers at PR-open time — nothing previously checked whether they | |
| 278 | * actually approved before a merge was allowed. This is the missing check: | |
| 279 | * given a PR's changed files, resolve the CODEOWNERS-required owners (same | |
| 280 | * pattern-matching + team-expansion helpers as auto-assign) and report which | |
| 281 | * of them have NOT approved via `pr_reviews`. | |
| 282 | * | |
| 283 | * "Satisfied" (no missing owners) also covers the no-CODEOWNERS-file case | |
| 284 | * and the no-owner-matched-these-files case — this function only enforces | |
| 285 | * what CODEOWNERS actually touches, it never invents a requirement. | |
| 286 | * | |
| 287 | * Fails open (`satisfied: true`) on any internal error (git fetch, parse, or | |
| 288 | * DB failure) — a bug in this check must never hard-block every merge | |
| 289 | * platform-wide. Matches the `[codeowners] auto-assign failed` fail-soft | |
| 290 | * style used at PR-creation time. | |
| 291 | * | |
| 292 | * `deps` is injectable for tests, mirroring the pattern in | |
| 293 | * push-workflow-sync.ts — avoids a global `mock.module()` on `../git/repository` | |
| 294 | * or `../db`, both of which are imported by dozens of unrelated test files. | |
| 295 | */ | |
| 296 | export async function requiredOwnersApproved( | |
| 297 | owner: string, | |
| 298 | repo: string, | |
| 299 | codeownersBranch: string, | |
| 300 | pullRequestId: string, | |
| 301 | changedFilePaths: string[], | |
| 302 | deps: { | |
| 303 | getCodeownersForRepo: typeof getCodeownersForRepo; | |
| 304 | loadApprovedUsernames: (pullRequestId: string) => Promise<Set<string>>; | |
| 305 | } = { getCodeownersForRepo, loadApprovedUsernames } | |
| 306 | ): Promise<{ satisfied: boolean; missingOwners: string[] }> { | |
| 307 | try { | |
| 308 | const rules = await deps.getCodeownersForRepo(owner, repo, codeownersBranch); | |
| 309 | if (rules.length === 0) return { satisfied: true, missingOwners: [] }; | |
| 310 | ||
| 311 | const tokens = matchOwners(changedFilePaths, rules); | |
| 312 | if (tokens.length === 0) return { satisfied: true, missingOwners: [] }; | |
| 313 | ||
| 314 | const requiredOwners = await expandOwnerTokens(tokens); | |
| 315 | if (requiredOwners.length === 0) return { satisfied: true, missingOwners: [] }; | |
| 316 | ||
| 317 | const approved = await deps.loadApprovedUsernames(pullRequestId); | |
| 318 | const missingOwners = requiredOwners.filter((u) => !approved.has(u)); | |
| 319 | return { satisfied: missingOwners.length === 0, missingOwners }; | |
| 320 | } catch (err) { | |
| 321 | console.warn( | |
| 322 | "[codeowners] requiredOwnersApproved failed:", | |
| 323 | err instanceof Error ? err.message : err | |
| 324 | ); | |
| 325 | return { satisfied: true, missingOwners: [] }; | |
| 326 | } | |
| 327 | } |