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.tsBlame193 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";
3ef4c9dClaude27
28export interface OwnerRule {
29 pattern: string;
40d3e3fClaude30 /**
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
38export function isTeamToken(token: string): boolean {
39 return token.includes("/");
3ef4c9dClaude40}
41
42export 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 */
64function 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 */
80export 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 */
96export 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
40d3e3fClaude115/**
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 */
120export 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 */
155export 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
3ef4c9dClaude168/**
169 * Given a PR's changed file list, return all unique owner usernames to
40d3e3fClaude170 * auto-request review from. Team references are expanded.
3ef4c9dClaude171 */
172export 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 }));
40d3e3fClaude185 const tokens = new Set<string>();
3ef4c9dClaude186 for (const p of paths) {
40d3e3fClaude187 for (const u of ownersForPath(p, parsed)) tokens.add(u);
3ef4c9dClaude188 }
40d3e3fClaude189 return await expandOwnerTokens([...tokens]);
3ef4c9dClaude190 } catch {
191 return [];
192 }
193}