CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | /**
* CODEOWNERS parser + sync.
*
* Parses a CODEOWNERS file (GitHub-compatible syntax):
* # comments allowed
* * @alice
* src/api/** @bob @carol
* /docs @alice
* api/** @acme/backend # Block B3: team reference
*
* Ownership is resolved by last-matching rule (GitHub parity).
*
* Tokens containing a `/` are treated as team references of the form
* `@orgSlug/teamSlug`. They are stored as-is and expanded to the team's
* current membership at review-request time.
*/
import { and, eq } from "drizzle-orm";
import { db } from "../db";
import {
codeOwners,
organizations,
teams,
teamMembers,
users,
} from "../db/schema";
import { getBlob } from "../git/repository";
export interface OwnerRule {
pattern: string;
/**
* Owner tokens. Usernames are stored without the leading `@`;
* team references are stored as `org/team` (also no `@`).
* Use `isTeamToken(tok)` to distinguish.
*/
owners: string[];
}
/** Public alias used by new callers (matches task spec interface). */
export type CodeOwnerRule = OwnerRule;
export function isTeamToken(token: string): boolean {
return token.includes("/");
}
export function parseCodeowners(content: string): OwnerRule[] {
const rules: OwnerRule[] = [];
for (const rawLine of content.split("\n")) {
const line = rawLine.replace(/#.*$/, "").trim();
if (!line) continue;
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const pattern = parts[0];
const owners = parts
.slice(1)
.map((o) => o.replace(/^@/, "").trim())
.filter(Boolean);
if (owners.length === 0) continue;
rules.push({ pattern, owners });
}
return rules;
}
/**
* Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`.
* Patterns anchored at the repo root if they start with `/`.
*/
function patternToRegex(pattern: string): RegExp {
const anchored = pattern.startsWith("/");
let p = anchored ? pattern.slice(1) : pattern;
// Escape regex metacharacters except * and /
p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
p = p.replace(/\*\*/g, "__DOUBLEGLOB__");
p = p.replace(/\*/g, "[^/]*");
p = p.replace(/__DOUBLEGLOB__/g, ".*");
const prefix = anchored ? "^" : "^(?:.*/)?";
const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$";
return new RegExp(prefix + p + suffix);
}
/**
* Return owner usernames for a given file path. Last matching rule wins.
*/
export function ownersForPath(
path: string,
rules: OwnerRule[]
): string[] {
let matched: string[] = [];
for (const r of rules) {
if (patternToRegex(r.pattern).test(path)) {
matched = r.owners;
}
}
return matched;
}
/**
* Replace all rules for a repo in the DB.
*/
export async function syncCodeowners(
repositoryId: string,
rules: OwnerRule[]
): Promise<void> {
try {
await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId));
if (rules.length === 0) return;
await db.insert(codeOwners).values(
rules.map((r) => ({
repositoryId,
pathPattern: r.pattern,
ownerUsernames: r.owners.join(","),
}))
);
} catch (err) {
console.error("[codeowners] sync failed:", err);
}
}
/**
* Resolve a single `org/team` token to the set of usernames currently on
* the team. Returns `[]` on unknown org, unknown team, or DB error — never
* throws. Pure helper; exported for unit tests.
*/
export async function expandTeamToken(token: string): Promise<string[]> {
if (!isTeamToken(token)) return [];
const [orgSlug, teamSlug] = token.split("/", 2);
if (!orgSlug || !teamSlug) return [];
try {
const [org] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.slug, orgSlug))
.limit(1);
if (!org) return [];
const [team] = await db
.select({ id: teams.id })
.from(teams)
.where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
.limit(1);
if (!team) return [];
const rows = await db
.select({ username: users.username })
.from(teamMembers)
.innerJoin(users, eq(users.id, teamMembers.userId))
.where(eq(teamMembers.teamId, team.id));
return rows.map((r) => r.username);
} catch (err) {
console.error("[codeowners] expandTeamToken:", err);
return [];
}
}
/**
* Expand a list of owner tokens to concrete usernames.
* - Plain usernames pass through.
* - `org/team` tokens are expanded to the team's current members.
* - Unknown tokens are dropped.
*/
export async function expandOwnerTokens(tokens: string[]): Promise<string[]> {
const out = new Set<string>();
for (const t of tokens) {
if (!t) continue;
if (isTeamToken(t)) {
for (const u of await expandTeamToken(t)) out.add(u);
} else {
out.add(t);
}
}
return [...out];
}
/**
* Given a PR's changed file list, return all unique owner usernames to
* auto-request review from. Team references are expanded.
*/
export async function reviewersForChangedFiles(
repositoryId: string,
paths: string[]
): Promise<string[]> {
try {
const rules = await db
.select()
.from(codeOwners)
.where(eq(codeOwners.repositoryId, repositoryId));
const parsed: OwnerRule[] = rules.map((r) => ({
pattern: r.pathPattern,
owners: r.ownerUsernames.split(",").filter(Boolean),
}));
const tokens = new Set<string>();
for (const p of paths) {
for (const u of ownersForPath(p, parsed)) tokens.add(u);
}
return await expandOwnerTokens([...tokens]);
} catch {
return [];
}
}
/**
* matchOwners — public alias for `reviewersForChangedFiles` using in-memory
* rules instead of DB. Accepts the pre-parsed rules array directly.
* Deduplicated; team tokens are NOT expanded here (use expandOwnerTokens).
*/
export function matchOwners(filePaths: string[], rules: OwnerRule[]): string[] {
const out = new Set<string>();
for (const p of filePaths) {
for (const u of ownersForPath(p, rules)) out.add(u);
}
return Array.from(out);
}
/**
* Fetch and parse the CODEOWNERS file for a repo from git.
* Checks three canonical locations in order:
* 1. `CODEOWNERS`
* 2. `.github/CODEOWNERS`
* 3. `docs/CODEOWNERS`
* Returns [] if none found.
*/
export async function getCodeownersForRepo(
owner: string,
repo: string,
branch: string
): Promise<OwnerRule[]> {
const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
for (const path of candidates) {
try {
const blob = await getBlob(owner, repo, branch, path);
if (blob && !blob.isBinary && blob.content) {
return parseCodeowners(blob.content);
}
} catch {
// file not found — try next candidate
}
}
return [];
}
|