Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

codeowners.tsBlame327 lines · 2 contributors
3ef4c9dClaude1/**
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
40d3e3fClaude9 * api/** @acme/backend # Block B3: team reference
3ef4c9dClaude10 *
40d3e3fClaude11 * 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.
3ef4c9dClaude16 */
17
91b054eccanty labs18import { and, desc, eq } from "drizzle-orm";
3ef4c9dClaude19import { db } from "../db";
40d3e3fClaude20import {
21 codeOwners,
22 organizations,
23 teams,
24 teamMembers,
25 users,
91b054eccanty labs26 prReviews,
40d3e3fClaude27} from "../db/schema";
ec9e3e3Claude28import { getBlob } from "../git/repository";
3ef4c9dClaude29
30export interface OwnerRule {
31 pattern: string;
40d3e3fClaude32 /**
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
ec9e3e3Claude40/** Public alias used by new callers (matches task spec interface). */
41export type CodeOwnerRule = OwnerRule;
42
40d3e3fClaude43export function isTeamToken(token: string): boolean {
44 return token.includes("/");
3ef4c9dClaude45}
46
47export 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 */
69function 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 */
85export 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 */
101export 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
40d3e3fClaude120/**
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 */
125export 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 */
160export 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
3ef4c9dClaude173/**
174 * Given a PR's changed file list, return all unique owner usernames to
40d3e3fClaude175 * auto-request review from. Team references are expanded.
3ef4c9dClaude176 */
177export 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 }));
40d3e3fClaude190 const tokens = new Set<string>();
3ef4c9dClaude191 for (const p of paths) {
40d3e3fClaude192 for (const u of ownersForPath(p, parsed)) tokens.add(u);
3ef4c9dClaude193 }
40d3e3fClaude194 return await expandOwnerTokens([...tokens]);
3ef4c9dClaude195 } catch {
196 return [];
197 }
198}
ec9e3e3Claude199
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 */
205export 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 */
221export 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}
91b054eccanty labs241
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 */
250async 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 */
296export 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}