Commit40d3e3funknown_key
feat(BLOCK-B3): team-based CODEOWNERS (@org/team expansion)
feat(BLOCK-B3): team-based CODEOWNERS (@org/team expansion) Tokens containing `/` in a CODEOWNERS file are parsed as team references (`@acme/backend`) and expanded to the team's current members at review time. Membership changes take effect immediately — we don't snapshot. - `isTeamToken` helper distinguishes user vs team tokens - `expandTeamToken` resolves org + team + members, returns usernames - `expandOwnerTokens` batches the expansion, drops unknown tokens silently so a typo never blocks PR creation - `reviewersForChangedFiles` now goes through expandOwnerTokens +2 tests (125 pass total).
2 files changed+107−840d3e3f7990d18273f28f527b52a991dbdf67130
2 changed files+107−8
Modifiedsrc/__tests__/green-ecosystem.test.ts+26−0View fileUnifiedSplit
@@ -12,6 +12,8 @@ import { scanForSecrets, SECRET_PATTERNS } from "../lib/security-scan";
1212import {
1313 parseCodeowners,
1414 ownersForPath,
15 isTeamToken,
16 expandOwnerTokens,
1517} from "../lib/codeowners";
1618import { generateCommitMessage } from "../lib/ai-generators";
1719import { isAiAvailable } from "../lib/ai-client";
@@ -111,6 +113,30 @@ describe("codeowners parser", () => {
111113 expect(rules.length).toBe(1);
112114 expect(rules[0].owners).toEqual(["ghost"]);
113115 });
116
117 it("preserves @org/team tokens (B3)", () => {
118 const rules = parseCodeowners(
119 "api/** @acme/backend @alice\nweb/** @acme/frontend\n"
120 );
121 expect(rules[0].owners).toEqual(["acme/backend", "alice"]);
122 expect(rules[1].owners).toEqual(["acme/frontend"]);
123 expect(isTeamToken("acme/backend")).toBe(true);
124 expect(isTeamToken("alice")).toBe(false);
125 });
126
127 it("expandOwnerTokens passes plain usernames through and drops unknown teams gracefully", async () => {
128 // Real team lookup requires DB rows. Without DB the helper must still
129 // resolve without throwing; plain usernames must always pass through.
130 const result = await expandOwnerTokens([
131 "alice",
132 "bob",
133 "nonexistent-org-xyz/some-team",
134 ]);
135 expect(result).toContain("alice");
136 expect(result).toContain("bob");
137 // Unknown team silently drops (no throw, no crash).
138 expect(result).not.toContain("nonexistent-org-xyz/some-team");
139 });
114140});
115141
116142describe("AI generator fallbacks", () => {
Modifiedsrc/lib/codeowners.ts+81−8View fileUnifiedSplit
@@ -6,17 +6,37 @@
66 * *
77 * src/api/** @bob @carol
88 * /docs @alice
9 * api/** @acme/backend # Block B3: team reference
910 *
10 * Ownership is resolved by last-matching rule (that's how GitHub does it).
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.
1116 */
1217
13import { eq } from "drizzle-orm";
18import { and, eq } from "drizzle-orm";
1419import { db } from "../db";
15import { codeOwners } from "../db/schema";
20import {
21 codeOwners,
22 organizations,
23 teams,
24 teamMembers,
25 users,
26} from "../db/schema";
1627
1728export interface OwnerRule {
1829 pattern: string;
19 owners: string[]; // usernames, stripped of leading @
30 /**
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("/");
2040}
2141
2242export function parseCodeowners(content: string): OwnerRule[] {
@@ -92,9 +112,62 @@ export async function syncCodeowners(
92112 }
93113}
94114
115/**
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
95168/**
96169 * Given a PR's changed file list, return all unique owner usernames to
97 * auto-request review from.
170 * auto-request review from. Team references are expanded.
98171 */
99172export async function reviewersForChangedFiles(
100173 repositoryId: string,
@@ -109,11 +182,11 @@ export async function reviewersForChangedFiles(
109182 pattern: r.pathPattern,
110183 owners: r.ownerUsernames.split(",").filter(Boolean),
111184 }));
112 const result = new Set<string>();
185 const tokens = new Set<string>();
113186 for (const p of paths) {
114 for (const u of ownersForPath(p, parsed)) result.add(u);
187 for (const u of ownersForPath(p, parsed)) tokens.add(u);
115188 }
116 return [...result];
189 return await expandOwnerTokens([...tokens]);
117190 } catch {
118191 return [];
119192 }
120193