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 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | /**
* 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, desc, eq } from "drizzle-orm";
import { db } from "../db";
import {
codeOwners,
organizations,
teams,
teamMembers,
users,
prReviews,
} 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 [];
}
/**
* Default `loadApprovedUsernames` dependency for `requiredOwnersApproved` —
* returns the set of usernames whose *latest* non-"commented" `pr_reviews`
* row for this PR is `state === 'approved'`. Same dedup rule as
* `countHumanApprovals()` in branch-protection.ts (most recent review per
* reviewer wins; a stale "approved" followed by "changes_requested" does
* NOT count as approved).
*/
async function loadApprovedUsernames(pullRequestId: string): Promise<Set<string>> {
const rows = await db
.select({ state: prReviews.state, username: users.username })
.from(prReviews)
.innerJoin(users, eq(users.id, prReviews.reviewerId))
.where(
and(eq(prReviews.pullRequestId, pullRequestId), eq(prReviews.isAi, false))
)
.orderBy(desc(prReviews.createdAt));
const latestByUsername = new Map<string, string>();
for (const r of rows) {
if (r.state !== "commented" && !latestByUsername.has(r.username)) {
latestByUsername.set(r.username, r.state);
}
}
const approved = new Set<string>();
for (const [username, state] of latestByUsername) {
if (state === "approved") approved.add(username);
}
return approved;
}
/**
* Merge-time CODEOWNERS enforcement.
*
* `reviewersForChangedFiles()` above only ever *requests* CODEOWNERS
* reviewers at PR-open time — nothing previously checked whether they
* actually approved before a merge was allowed. This is the missing check:
* given a PR's changed files, resolve the CODEOWNERS-required owners (same
* pattern-matching + team-expansion helpers as auto-assign) and report which
* of them have NOT approved via `pr_reviews`.
*
* "Satisfied" (no missing owners) also covers the no-CODEOWNERS-file case
* and the no-owner-matched-these-files case — this function only enforces
* what CODEOWNERS actually touches, it never invents a requirement.
*
* Fails open (`satisfied: true`) on any internal error (git fetch, parse, or
* DB failure) — a bug in this check must never hard-block every merge
* platform-wide. Matches the `[codeowners] auto-assign failed` fail-soft
* style used at PR-creation time.
*
* `deps` is injectable for tests, mirroring the pattern in
* push-workflow-sync.ts — avoids a global `mock.module()` on `../git/repository`
* or `../db`, both of which are imported by dozens of unrelated test files.
*/
export async function requiredOwnersApproved(
owner: string,
repo: string,
codeownersBranch: string,
pullRequestId: string,
changedFilePaths: string[],
deps: {
getCodeownersForRepo: typeof getCodeownersForRepo;
loadApprovedUsernames: (pullRequestId: string) => Promise<Set<string>>;
} = { getCodeownersForRepo, loadApprovedUsernames }
): Promise<{ satisfied: boolean; missingOwners: string[] }> {
try {
const rules = await deps.getCodeownersForRepo(owner, repo, codeownersBranch);
if (rules.length === 0) return { satisfied: true, missingOwners: [] };
const tokens = matchOwners(changedFilePaths, rules);
if (tokens.length === 0) return { satisfied: true, missingOwners: [] };
const requiredOwners = await expandOwnerTokens(tokens);
if (requiredOwners.length === 0) return { satisfied: true, missingOwners: [] };
const approved = await deps.loadApprovedUsernames(pullRequestId);
const missingOwners = requiredOwners.filter((u) => !approved.has(u));
return { satisfied: missingOwners.length === 0, missingOwners };
} catch (err) {
console.warn(
"[codeowners] requiredOwnersApproved failed:",
err instanceof Error ? err.message : err
);
return { satisfied: true, missingOwners: [] };
}
}
|