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

codeowners.tsBlame239 lines · 1 contributor
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
40d3e3fClaude18import { and, eq } from "drizzle-orm";
3ef4c9dClaude19import { db } from "../db";
40d3e3fClaude20import {
21 codeOwners,
22 organizations,
23 teams,
24 teamMembers,
25 users,
26} from "../db/schema";
ec9e3e3Claude27import { getBlob } from "../git/repository";
3ef4c9dClaude28
29export interface OwnerRule {
30 pattern: string;
40d3e3fClaude31 /**
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
ec9e3e3Claude39/** Public alias used by new callers (matches task spec interface). */
40export type CodeOwnerRule = OwnerRule;
41
40d3e3fClaude42export function isTeamToken(token: string): boolean {
43 return token.includes("/");
3ef4c9dClaude44}
45
46export 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 */
68function 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 */
84export 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 */
100export 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
40d3e3fClaude119/**
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 */
124export 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 */
159export 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
3ef4c9dClaude172/**
173 * Given a PR's changed file list, return all unique owner usernames to
40d3e3fClaude174 * auto-request review from. Team references are expanded.
3ef4c9dClaude175 */
176export 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 }));
40d3e3fClaude189 const tokens = new Set<string>();
3ef4c9dClaude190 for (const p of paths) {
40d3e3fClaude191 for (const u of ownersForPath(p, parsed)) tokens.add(u);
3ef4c9dClaude192 }
40d3e3fClaude193 return await expandOwnerTokens([...tokens]);
3ef4c9dClaude194 } catch {
195 return [];
196 }
197}
ec9e3e3Claude198
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 */
204export 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 */
220export 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}