Coverage for src/pullapprove/config.py: 94%

487 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-27 14:35 -0500

1from __future__ import annotations 

2 

3import os 

4import posixpath 

5import re 

6import tomllib 

7import warnings 

8 

9with warnings.catch_warnings(): 

10 warnings.simplefilter("ignore", DeprecationWarning) 

11 import sre_parse 

12from collections.abc import Generator, Iterable 

13from enum import StrEnum 

14from pathlib import Path 

15from typing import Any, Self 

16 

17from pydantic import ( 

18 BaseModel, 

19 ConfigDict, 

20 Field, 

21 RootModel, 

22 field_validator, 

23 model_validator, 

24) 

25from wcmatch import glob 

26 

27from .checklists import Checklist 

28 

29 

30def _resolve_config_filename_prefix() -> str: 

31 """The name every config file starts with, for this process. 

32 

33 Defaults to CODEREVIEW. An instance can rename it with 

34 PULLAPPROVE_CONFIG_PREFIX, which renames the config file itself 

35 (REVIEW -> REVIEW.toml, REVIEW.template.toml). Because discovery matches 

36 on this prefix, an instance only ever sees files named for its own prefix 

37 -- so two instances can watch the same repo without seeing each other's 

38 configs, which is how unreleased config features get tried out on a repo 

39 that production also watches. 

40 

41 Read once at import: this is a per-process constant, not runtime state. 

42 

43 Two operational constraints this doesn't (and can't) enforce: 

44 

45 Prefixes must not be prefixes of one another. Matching is `startswith` 

46 (see is_config_filename), and a suffix after the prefix is legitimate -- 

47 CODEREVIEW.template.toml and CODEREVIEW-BASE.toml are both real config 

48 names -- so a REVIEW instance would also pick up REVIEW_DEV.toml. An 

49 instance only knows its own prefix, so pick names that don't overlap 

50 (REVIEW and DEV_REVIEW, not REVIEW and REVIEW_DEV). 

51 

52 Changing the prefix on a live instance means clearing its cached configs. 

53 Config rows are keyed by (repo, sha) with no record of which prefix 

54 discovered them, so a sha already processed under the old prefix keeps 

55 serving from cache and the new prefix's files are never fetched. 

56 """ 

57 # `or` rather than a get() default: an explicitly empty value 

58 # (`PULLAPPROVE_CONFIG_PREFIX=` in a .env or compose file -- a normal way to 

59 # write "unset") is not a missing key, and would name the config ".toml". 

60 prefix = os.environ.get("PULLAPPROVE_CONFIG_PREFIX", "").strip() or "CODEREVIEW" 

61 

62 # A bad prefix would silently match nothing (i.e. every repo looks 

63 # unconfigured), so refuse to start instead. 

64 if prefix.endswith(".toml"): 

65 raise ValueError( 

66 f"PULLAPPROVE_CONFIG_PREFIX should be a name without an extension, not {prefix!r}. " 

67 f"The config file is named after it (e.g. {prefix[: -len('.toml')]!r} -> {prefix!r})." 

68 ) 

69 if "\\" in prefix or prefix != posixpath.basename(prefix): 

70 raise ValueError( 

71 f"PULLAPPROVE_CONFIG_PREFIX should be a filename prefix, not a path: {prefix!r}" 

72 ) 

73 

74 return prefix 

75 

76 

77CONFIG_FILENAME_PREFIX = _resolve_config_filename_prefix() 

78CONFIG_FILENAME = f"{CONFIG_FILENAME_PREFIX}.toml" 

79 

80 

81def is_config_filename(basename: str) -> bool: 

82 """Whether a filename is a config file (CODEREVIEW.toml, 

83 CODEREVIEW.template.toml). 

84 

85 The prefix half is what isolates instances: an instance running a renamed 

86 prefix (see PULLAPPROVE_CONFIG_PREFIX) never even discovers another 

87 instance's configs. The extension half keeps a neighbor like CODEREVIEW.md 

88 from being treated as one. 

89 """ 

90 return basename.startswith(CONFIG_FILENAME_PREFIX) and basename.endswith(".toml") 

91 

92 

93_REPEAT_OPS = {sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT} 

94 

95 

96def _has_nested_quantifiers(data: Any) -> bool: 

97 """Detect patterns like (a+)+ that cause catastrophic backtracking.""" 

98 for op, av in data: 

99 if op in _REPEAT_OPS: 

100 if _contains_quantifier(av[2]): 

101 return True 

102 elif op == sre_parse.SUBPATTERN: 

103 if _has_nested_quantifiers(av[-1]): 

104 return True 

105 elif op == sre_parse.BRANCH: 

106 if any(_has_nested_quantifiers(branch) for branch in av[1]): 

107 return True 

108 return False 

109 

110 

111def _contains_quantifier(data: Any) -> bool: 

112 for op, av in data: 

113 if op in _REPEAT_OPS: 

114 return True 

115 elif op == sre_parse.SUBPATTERN: 

116 if _contains_quantifier(av[-1]): 

117 return True 

118 elif op == sre_parse.BRANCH: 

119 if any(_contains_quantifier(branch) for branch in av[1]): 

120 return True 

121 return False 

122 

123 

124_TEAM_REF_SEGMENT = r"[a-zA-Z0-9][a-zA-Z0-9\-_.]*" 

125_TEAM_REF_RE = re.compile(rf"^{_TEAM_REF_SEGMENT}(/{_TEAM_REF_SEGMENT})+$") 

126 

127# Fields that hold reviewer identities (plain usernames, `$aliases`, and 

128# `@team` refs) rather than paths/code/labels. Team refs only expand here. 

129USER_LIST_FIELDS = ("authors", "reviewers", "alternates", "cc") 

130 

131# Roster fields where "!" performs compile-time subtraction (remove from the 

132# resolved list) rather than surviving as a match-time predicate. `authors` 

133# is deliberately excluded — its "!" entries are consumed by `matches_author`. 

134ROSTER_FIELDS = ("reviewers", "alternates", "cc") 

135 

136 

137def _split_team_ref(value: str) -> tuple[str, str] | None: 

138 """Split a value into `(prefix, ref)` if it has team-reference shape 

139 (`@org/team` or `!@org/team`), `prefix` being `"!"` or `""`. Returns 

140 `None` for anything else, so callers fall back to their own handling of 

141 plain values. 

142 """ 

143 if value.startswith("!@"): 

144 return "!", value[2:] 

145 if value.startswith("@"): 

146 return "", value[1:] 

147 return None 

148 

149 

150def is_unexpanded_ref(value: str) -> bool: 

151 """True if the value is a `$alias` or `@team` reference (optionally 

152 negated with a leading `!`) that has not been expanded — as opposed to a 

153 plain username. Only an offline compile (`teams=None`) leaves such values 

154 in user-list fields. 

155 """ 

156 return value.removeprefix("!").startswith(("$", "@")) 

157 

158 

159def _validate_team_refs(values: list[str]) -> list[str]: 

160 """Team references (`@org/team`, `!@org/team`) need at least two 

161 slash-separated segments. This only checks shape — membership is resolved 

162 later, at compile time, against the caller-provided `teams` mapping. 

163 

164 Any other value containing "@" is rejected too — that shape is reserved 

165 for team references (and, in future, email-style identifiers), so it 

166 can't be confused with a plain username. 

167 """ 

168 for value in values: 

169 split = _split_team_ref(value) 

170 if split is None: 

171 if "@" in value: 

172 raise ValueError( 

173 f"Invalid value '{value}': email addresses are not " 

174 "supported here — use the platform username" 

175 ) 

176 continue 

177 

178 _prefix, ref = split 

179 if not _TEAM_REF_RE.match(ref): 

180 raise ValueError( 

181 f"Invalid team reference '{value}': team references need the " 

182 "'org/team' form" 

183 ) 

184 return values 

185 

186 

187def _expand_team_ref( 

188 ref: str, teams: dict[str, list[str]] | None, prefix: str 

189) -> list[str]: 

190 """Expand a single team reference (without its `@`/`!@`) to member usernames. 

191 

192 `teams=None` is the offline/CLI mode: the library never calls out to 

193 GitHub/GitLab itself, so with no mapping provided the reference passes 

194 through unexpanded rather than erroring. With a `teams` mapping (even an 

195 empty one), an unresolvable ref is a loud config error, matching how 

196 unknown `$aliases` are handled. 

197 """ 

198 if teams is None: 

199 return [f"{prefix}@{ref}"] 

200 

201 members = teams.get(ref.lower()) 

202 if members is None: 

203 raise ValueError(f"Unknown team: {prefix}@{ref}") 

204 

205 return [f"{prefix}{member}" for member in members] 

206 

207 

208def _expand_aliases( 

209 values: list[str], 

210 aliases: dict[str, list[str]], 

211 teams: dict[str, list[str]] | None = None, 

212 expand_teams: bool = False, 

213 _seen: set[str] | None = None, 

214 _path: list[str] | None = None, 

215) -> list[str]: 

216 """Replace alias references in a list with their mapped values recursively. 

217 

218 Team references (`@org/team`) are only expanded when `expand_teams` is 

219 True — user-list fields (reviewers, alternates, authors, cc). Elsewhere 

220 (paths, code, labels) a leading `@` is a literal string, e.g. npm-style 

221 scoped path patterns like `@vendor/pkg/**`. Teams are always leaf nodes — 

222 their values are plain usernames, never other refs — so expanding one 

223 never recurses further. 

224 """ 

225 if _seen is None: 

226 _seen = set() 

227 if _path is None: 

228 _path = [] 

229 

230 expanded: list[str] = [] 

231 for value in values: 

232 # Support negated aliases like "!$team" -> ["!alice", "!bob"] 

233 if value.startswith("!$"): 

234 prefix = "!" 

235 alias_ref = value[2:] 

236 elif value.startswith("$"): 

237 prefix = "" 

238 alias_ref = value[1:] 

239 elif expand_teams and (team_ref := _split_team_ref(value)) is not None: 

240 team_prefix, ref = team_ref 

241 expanded.extend(_expand_team_ref(ref=ref, teams=teams, prefix=team_prefix)) 

242 continue 

243 else: 

244 expanded.append(value) 

245 continue 

246 

247 if alias_ref in _seen: 

248 # Cycle detected, raise an error with the cycle path 

249 cycle_path = _path[_path.index(alias_ref) :] + [alias_ref] 

250 raise ValueError( 

251 f"Circular reference detected in aliases: {' -> '.join(cycle_path)}" 

252 ) 

253 if alias_ref in aliases: 

254 _seen.add(alias_ref) 

255 _path.append(alias_ref) 

256 # Recursively expand the alias values 

257 nested_expanded = _expand_aliases( 

258 aliases[alias_ref], 

259 aliases=aliases, 

260 teams=teams, 

261 expand_teams=expand_teams, 

262 _seen=_seen, 

263 _path=_path, 

264 ) 

265 if prefix: 

266 expanded.extend(prefix + v for v in nested_expanded) 

267 else: 

268 expanded.extend(nested_expanded) 

269 _path.pop() 

270 _seen.remove(alias_ref) 

271 else: 

272 # Unknown alias — surface it loudly instead of silently dropping the 

273 # reference (a typo'd alias would otherwise vanish reviewers/paths). 

274 raise ValueError(f"Unknown alias: {prefix}${alias_ref}") 

275 

276 # Remove duplicates while preserving order 

277 return list(dict.fromkeys(expanded)) 

278 

279 

280def _apply_negations(values: list[str]) -> list[str]: 

281 """Compile-time subtraction for roster fields (`ROSTER_FIELDS`): a "!name" 

282 entry removes "name" from the resolved list instead of surviving as a 

283 match-time rule (contrast `authors`, handled by `matches_author`). 

284 

285 A list still holding an unexpanded `@team` ref (offline compile, i.e. 

286 teams=None) is returned untouched — it isn't fully resolved yet, so 

287 subtraction can't run, and the partially-resolved config must round-trip 

288 unchanged. `$aliases` must already be expanded by the caller. 

289 

290 The wildcard+negation error fires BEFORE that early return: it's a check 

291 on the written form (`"*"` alongside any `"!"` entry, team ref or not), 

292 so an offline compile must reject it the same way the server will — a 

293 `pullapprove check` that passes locally can't then error in production. 

294 """ 

295 if "*" in values and any(v.startswith("!") for v in values): 

296 raise ValueError( 

297 'Negation cannot be combined with "*" in reviewers/alternates/cc ' 

298 "(wildcard exclusion is not supported)" 

299 ) 

300 

301 if any(_split_team_ref(v) is not None for v in values): 

302 return values 

303 

304 negations = {v[1:].lower() for v in values if v.startswith("!")} 

305 return [v for v in values if not v.startswith("!") and v.lower() not in negations] 

306 

307 

308def _validate_review_counts(label: str, data: dict[str, Any]) -> None: 

309 """Compile-time rejection of negative require/request/author_value. 

310 Shared by scopes and large_scale_change (which has no 

311 request/author_value — the `.get` defaults pass trivially there). 

312 

313 These are compile-time checks (not field validators) because negative 

314 values were previously accepted, so compiled configs stored inside old 

315 processing results must keep parsing (where they keep their old 

316 behavior: a negative require always passed, a negative request 

317 requested nobody). 

318 """ 

319 for field in ("require", "request", "author_value"): 

320 if data.get(field, 0) < 0: 

321 raise ValueError(f"{label}: {field} cannot be negative") 

322 

323 

324def _resolve_extends_path(extending_path: str, extends_ref: str) -> str: 

325 """Resolve an `extends` reference to a canonical repo-relative config key. 

326 

327 - `/x` is repo-root-relative. 

328 - everything else (`../x`, `dir/x`, bare `x`) is relative to the extending 

329 file's directory. 

330 

331 Raises if the reference escapes above the repo root. 

332 """ 

333 if extends_ref.startswith("/"): 

334 resolved = posixpath.normpath(extends_ref.lstrip("/")) 

335 else: 

336 base_dir = posixpath.dirname(extending_path) 

337 resolved = posixpath.normpath(posixpath.join(base_dir, extends_ref)) 

338 

339 if resolved == ".." or resolved.startswith("../"): 

340 raise ValueError( 

341 f"Invalid extends path: '{extends_ref}' points above the repo root" 

342 ) 

343 

344 return resolved 

345 

346 

347def _anchor_path(base_dir: str, pattern: str) -> str: 

348 """Anchor a scope path glob at `base_dir` (the owning config's directory). 

349 

350 Scope paths are written relative to the config they live in. A leading `/` 

351 makes a pattern repo-root-absolute (escape hatch); a leading `!` negation is 

352 preserved. With an empty `base_dir` (root config) the pattern is unchanged. 

353 """ 

354 negate = pattern.startswith("!") 

355 if negate: 

356 pattern = pattern[1:] 

357 

358 if pattern.startswith("/"): 

359 anchored = pattern.lstrip("/") 

360 elif base_dir: 

361 anchored = f"{base_dir}/{pattern}" 

362 else: 

363 anchored = pattern 

364 

365 return f"!{anchored}" if negate else anchored 

366 

367 

368class OwnershipChoices(StrEnum): 

369 EMPTY = "" 

370 APPEND = "append" 

371 GLOBAL = "global" 

372 

373 

374def _validate_agent_username(username: str) -> None: 

375 """A declared agent is always one concrete account name -- not a ref, 

376 wildcard, or negation. There is no meaningful "any bot" and nothing to 

377 subtract from, so the roster syntax doesn't apply here. 

378 """ 

379 if username == "*" or username.startswith("!") or is_unexpanded_ref(username): 

380 raise ValueError( 

381 f"Agent username '{username}' must be a plain account username" 

382 ) 

383 # Every `@` value left is one _validate_team_refs already rejects (a plain 

384 # username can't be a team ref), so reuse it rather than restate it. 

385 _validate_team_refs([username]) 

386 

387 

388def _reject_declared_agents( 

389 values: list[str], declared_agents: set[str], *, where: str 

390) -> None: 

391 """Reject any declared agent listed in a roster surface (a scope's 

392 reviewers/alternates/cc, or large_scale_change.reviewers). 

393 

394 A declared agent is never a person, so it can never sit in a roster. 

395 Matched case-insensitively and run after alias expansion, so `$alias` 

396 indirection can't smuggle one in. `where` names the surface; the message 

397 becomes the git-host commit status, so it stays under ~130 chars. 

398 """ 

399 for entry in values: 

400 if entry.lower() in declared_agents: 

401 raise ValueError( 

402 f"{where}: '{entry}' is declared as an agent and cannot be " 

403 "listed as a reviewer" 

404 ) 

405 

406 

407def _first_case_insensitive_duplicate(values: Iterable[str]) -> str | None: 

408 """The first value whose lowercased form was already seen, else None. 

409 Shared by the case-insensitive uniqueness checks (agents, scope names).""" 

410 seen: set[str] = set() 

411 for value in values: 

412 if value.lower() in seen: 

413 return value 

414 seen.add(value.lower()) 

415 return None 

416 

417 

418class ScopeModel(BaseModel): 

419 model_config = ConfigDict(extra="forbid") 

420 

421 # Required fields 

422 name: str = Field(min_length=1) 

423 paths: list[str] = Field(min_length=1) 

424 

425 # Optional fields 

426 

427 # Expanded version of lines could be dict 

428 # with fnmatch, regex, exclude patterns, etc? 

429 code: list[str] = [] 

430 

431 # This only filtering field that can't be used with raw diff/files... 

432 # If we get into that, the others are: 

433 # - labels 

434 # - ref 

435 # - statuses 

436 # - dates 

437 # - body 

438 # - title 

439 # - other scopes 

440 # (this is how I ended up with expressions... 

441 # I'm not trying to build a general purpose workflow tool, 

442 # but I do need to support the legit use cases and AI/bot review is one, so is team hierarchy) 

443 authors: list[str] = [] 

444 

445 # (defaults should be the "empty" values) 

446 description: str = "" 

447 reviewers: list[str] = [] 

448 alternates: list[str] = [] 

449 cc: list[str] = [] 

450 

451 # Review scoring 

452 # Negative values are rejected at compile time (_validate_review_counts) 

453 require: int = 0 

454 author_value: int = 0 

455 

456 # How scopes are combined 

457 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

458 

459 # Actionable items 

460 request: int = 0 

461 labels: list[str] = [] 

462 instructions: str = "" 

463 

464 # Approval checklist 

465 checklist: Checklist | None = None 

466 

467 @field_validator("name", mode="after") 

468 @classmethod 

469 def validate_name(cls, name: str) -> str: 

470 if "," in name: 

471 raise ValueError("Scope name cannot contain commas") 

472 return name 

473 

474 @field_validator(*USER_LIST_FIELDS, mode="after") 

475 @classmethod 

476 def validate_team_ref_shape(cls, values: list[str]) -> list[str]: 

477 return _validate_team_refs(values) 

478 

479 @field_validator("code", mode="after") 

480 @classmethod 

481 def validate_code_patterns(cls, code: list[str]) -> list[str]: 

482 for pattern in code: 

483 try: 

484 parsed = sre_parse.parse(pattern) 

485 except re.error as e: 

486 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from None 

487 if _has_nested_quantifiers(parsed): 

488 raise ValueError( 

489 f"Regex pattern '{pattern}' contains nested quantifiers, " 

490 "which can cause catastrophic backtracking." 

491 ) 

492 return code 

493 

494 @model_validator(mode="after") 

495 def validate_reviewers_for_require(self) -> ScopeModel: 

496 all_reviewers = self.reviewers + self.alternates 

497 

498 # Skip if wildcard - anyone can review 

499 if "*" in all_reviewers: 

500 return self 

501 

502 # Skip if aliases or team refs (possibly negated with "!") are not yet 

503 # expanded. Re-validation after compilation only re-checks refs that 

504 # actually resolve — an offline compile (teams=None) leaves refs 

505 # unexpanded, so this same skip fires again there too. 

506 if any(is_unexpanded_ref(r) for r in all_reviewers): 

507 return self 

508 

509 if len(all_reviewers) < self.require: 

510 raise ValueError( 

511 f"has require={self.require} but only {len(all_reviewers)} reviewers/alternates specified" 

512 ) 

513 return self 

514 

515 def author_points(self, author_username: str) -> int: 

516 """Author points only count if the author is explicitly listed as a 

517 reviewer (a wildcard is not converted to usernames).""" 

518 if author_username.lower() in {r.lower() for r in self.reviewers}: 

519 return self.author_value 

520 return 0 

521 

522 def unsolvable_reason(self, author_username: str) -> str | None: 

523 """ 

524 Explain why this scope can never pass for a PR authored by this user, 

525 or None if it can. 

526 

527 Messages must stay under ~130 chars: they flow into the git-host 

528 commit status description, which the adapters slice to 140. 

529 """ 

530 if "*" in self.reviewers: 

531 # Anyone can review, so any require is satisfiable 

532 return None 

533 

534 # Count eligible reviewers (excluding author who can't self-approve) 

535 eligible_reviewers = {r.lower() for r in self.reviewers + self.alternates} - { 

536 author_username.lower() 

537 } 

538 max_possible_points = len(eligible_reviewers) + self.author_points( 

539 author_username 

540 ) 

541 

542 if self.require > 0 and max_possible_points < self.require: 

543 if not eligible_reviewers: 

544 return ( 

545 "PR author is the only reviewer/alternate and cannot self-approve" 

546 ) 

547 return f"require={self.require} but only {max_possible_points} possible approvals (excluding author)" 

548 

549 return None 

550 

551 def printed_name(self) -> str: 

552 match self.ownership: 

553 case OwnershipChoices.APPEND: 

554 return "+" + self.name 

555 case OwnershipChoices.GLOBAL: 

556 return "*" + self.name 

557 

558 return self.name 

559 

560 def __eq__(self, other: Any) -> bool: 

561 return self.name == other.name 

562 

563 def matches_path(self, path: Path) -> bool: 

564 # TODO paths shouldn't start with / 

565 return glob.globmatch( 

566 path, 

567 self.paths, 

568 flags=glob.GLOBSTAR 

569 | glob.BRACE 

570 | glob.NEGATE 

571 | glob.IGNORECASE 

572 | glob.DOTGLOB, 

573 ) 

574 

575 def matches_code(self, code: str) -> Generator[dict[str, int]]: 

576 patterns = getattr(self, "_code_regex_patterns", []) 

577 if not patterns: 

578 patterns = [re.compile(pattern, re.MULTILINE) for pattern in self.code] 

579 self._code_regex_patterns = patterns 

580 

581 for pattern in patterns: 

582 for match in pattern.finditer(code): 

583 start_index = match.start() 

584 end_index = match.end() 

585 

586 start_line = code.count("\n", 0, start_index) + 1 

587 start_col = start_index - code.rfind("\n", 0, start_index) 

588 

589 end_line = code.count("\n", 0, end_index) + 1 

590 end_col = end_index - code.rfind("\n", 0, end_index) 

591 

592 yield { 

593 "start_line": start_line, 

594 "start_col": start_col, 

595 "end_line": end_line, 

596 "end_col": end_col, 

597 } 

598 

599 def matches_author(self, author_username: str) -> bool: 

600 if not self.authors: 

601 # No authors specified, so assume it matches 

602 return True 

603 

604 author_username_lower = author_username.lower() 

605 

606 negated_authors = [a[1:].lower() for a in self.authors if a.startswith("!")] 

607 authors = [a.lower() for a in self.authors if not a.startswith("!")] 

608 

609 if author_username_lower in negated_authors: 

610 # If the author is in the negated list, return False 

611 return False 

612 

613 if not authors: 

614 # Negation-only: everyone not negated matches 

615 return True 

616 

617 if author_username_lower in authors: 

618 # If the author is in the authors list, return True 

619 return True 

620 

621 return False 

622 

623 

624class LargeScaleChangeModel(BaseModel): 

625 model_config = ConfigDict(extra="forbid") 

626 

627 # Note, an LSC only applies to diffs, not raw files, 

628 # because we have to know what *changed*. 

629 

630 # Pretty similar to a scope, but more manual. 

631 # There has to be at least one reviewer. So if a LSC config is not defined, an LSC PR error until you add one. 

632 # Negative values are rejected at compile time (_validate_review_counts) 

633 require: int = 1 

634 reviewers: list[str] = [] # Field(min_length=1) 

635 # min_paths: int = 300 

636 # min_lines: int = 3000 

637 labels: list[str] = [] 

638 # really need author value too...? 

639 

640 @field_validator("reviewers", mode="after") 

641 @classmethod 

642 def validate_team_ref_shape(cls, values: list[str]) -> list[str]: 

643 return _validate_team_refs(values) 

644 

645 def unsolvable_reason(self, author_username: str) -> str | None: 

646 """ 

647 Explain why this LSC can never pass for a PR authored by this user, 

648 or None if it can. 

649 

650 Messages must stay under ~130 chars: they flow into the git-host 

651 commit status description, which the adapters slice to 140. 

652 """ 

653 if "*" in self.reviewers: 

654 # Anyone can review, so any require is satisfiable 

655 return None 

656 

657 if not self.reviewers: 

658 # An empty roster is "configuration required", which 

659 # process_large_scale_change reports on its own 

660 return None 

661 

662 # Count eligible reviewers (excluding author who can't self-approve) 

663 eligible_reviewers = {r.lower() for r in self.reviewers} - { 

664 author_username.lower() 

665 } 

666 if self.require > 0 and len(eligible_reviewers) < self.require: 

667 if not eligible_reviewers: 

668 return "the PR author is the only reviewer and cannot self-approve" 

669 return f"require={self.require} but only {len(eligible_reviewers)} possible approvals (excluding author)" 

670 

671 return None 

672 

673 

674class ConfigModel(BaseModel): 

675 model_config = ConfigDict(extra="forbid") 

676 

677 # Nothing is technically required 

678 extends: list[str] = [] 

679 template: bool = False 

680 aliases: dict[str, list[str]] = {} 

681 large_scale_change: LargeScaleChangeModel | None = None 

682 scopes: list[ScopeModel] = [] 

683 

684 # Accounts whose review output PullApprove reads -- review bots (CodeRabbit, 

685 # Copilot, CI reviewers). A declared agent is never treated as a person: it 

686 # is never counted or requested, and listing one in reviewers/alternates/cc 

687 # is a compile-time error (enforced in compiled_config, where the union 

688 # across the whole config set is known). Concrete usernames only. 

689 agents: list[str] = [] 

690 

691 @field_validator("agents", mode="after") 

692 @classmethod 

693 def validate_agents(cls, agents: list[str]) -> list[str]: 

694 for username in agents: 

695 _validate_agent_username(username) 

696 if dup := _first_case_insensitive_duplicate(agents): 

697 raise ValueError(f"Agent '{dup}' is listed more than once") 

698 return agents 

699 

700 @field_validator("scopes", mode="after") 

701 @classmethod 

702 def validate_unique_scope_names(cls, scopes: list[ScopeModel]) -> list[ScopeModel]: 

703 if dup := _first_case_insensitive_duplicate(scope.name for scope in scopes): 

704 raise ValueError(f"Duplicate scope name: {dup}") 

705 return scopes 

706 

707 @field_validator("extends", mode="before") 

708 @classmethod 

709 def validate_extends(cls, extends: list[str]) -> list[str]: 

710 for i, path in enumerate(extends): 

711 basename = Path(path).name 

712 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

713 raise ValueError( 

714 f"Invalid extends path: {path}. It should start with '{CONFIG_FILENAME_PREFIX}'." 

715 ) 

716 return extends 

717 

718 def compiled_config( 

719 self, 

720 config_path: Path, 

721 other_configs: ConfigModels, 

722 teams: dict[str, list[str]] | None = None, 

723 ) -> ConfigModel: 

724 """ 

725 Resolve `extends` and replace aliases, returning the effective config. 

726 

727 Two phases: flatten the whole extends chain into one merged (raw, 

728 unexpanded) config, then expand aliases once. Expanding after the full 

729 merge is what makes transitive inheritance and cross-chain alias 

730 scoping work — an alias defined anywhere in the chain resolves anywhere. 

731 

732 `teams` maps team refs (any case, without the leading `@`) to member 

733 usernames, and is only consulted for user-list fields (reviewers, 

734 alternates, authors, cc, large_scale_change.reviewers). Keys are 

735 lowercased here, so callers don't need to normalize case themselves. 

736 With `teams=None` (the default), `@org/team` references in those 

737 fields pass through unexpanded — the offline/CLI mode, since this 

738 library never calls out to GitHub/GitLab itself. That 

739 partially-resolved config is only sanctioned for offline use; 

740 anything that needs real reviewer usernames must pass a `teams` 

741 mapping. 

742 

743 Pure function of the raw `self` and `other_configs` (it never reads its 

744 own anchored output), so it is safe to call uncached. 

745 """ 

746 

747 if teams is not None: 

748 teams = {key.lower(): members for key, members in teams.items()} 

749 

750 compiled_data = self._merged_data(config_path, other_configs) 

751 

752 # Expand aliases for any aliasable list fields. Team refs only expand 

753 # in user-list fields — paths/code/labels keep a leading "@" literal. 

754 for scope in compiled_data["scopes"]: 

755 for field in [ 

756 "paths", 

757 "code", 

758 "authors", 

759 "reviewers", 

760 "alternates", 

761 "cc", 

762 "labels", 

763 ]: 

764 if field in scope: 

765 scope[field] = _expand_aliases( 

766 scope[field], 

767 compiled_data["aliases"], 

768 teams=teams, 

769 expand_teams=field in USER_LIST_FIELDS, 

770 ) 

771 

772 # A declared agent is never a person, so it can't sit in a roster 

773 # field. The set is the union across the whole config set (agents 

774 # declared in any file, including a shared template), matched 

775 # case-insensitively and checked after alias expansion so `$alias` 

776 # indirection can't smuggle one in. 

777 declared_agents = other_configs.agent_usernames() 

778 

779 # Apply compile-time "!" subtraction to roster fields. 

780 # `_apply_negations` leaves a field with an unexpanded team ref 

781 # (offline compile, i.e. teams=None) untouched. 

782 for scope in compiled_data["scopes"]: 

783 for field in ROSTER_FIELDS: 

784 if field in scope: 

785 scope[field] = _apply_negations(scope[field]) 

786 _reject_declared_agents( 

787 scope[field], 

788 declared_agents, 

789 where=f"Scope '{scope['name']}' {field}", 

790 ) 

791 

792 # The "*" wildcard is only meaningful in `reviewers` — everywhere 

793 # else it's matched as a literal username and silently does 

794 # nothing: in `alternates` the scope stays pending forever, in 

795 # `authors` the scope never applies to any PR, in `cc` nobody is 

796 # notified. Reject it at compile time (after alias expansion, so 

797 # `$alias` indirection can't smuggle it in) rather than as a 

798 # ScopeModel validator, because compiled configs stored inside 

799 # old processing results must keep parsing. 

800 # Messages must stay under ~130 chars: they become the git-host 

801 # commit status description, which the adapters slice to 140. 

802 for field, hint in ( 

803 ( 

804 "authors", 

805 "remove it (a scope with no authors applies to any author)", 

806 ), 

807 ( 

808 "alternates", 

809 "add it to reviewers instead (wildcard reviewers are never auto-requested)", 

810 ), 

811 ("cc", "remove it"), 

812 ): 

813 if "*" in scope.get(field, []): 

814 raise ValueError( 

815 f"Scope '{scope['name']}': \"*\" is not supported in " 

816 f"{field}{hint}" 

817 ) 

818 

819 _validate_review_counts(f"Scope '{scope['name']}'", scope) 

820 

821 if large_scale_change := compiled_data.get("large_scale_change"): 

822 large_scale_change["reviewers"] = _expand_aliases( 

823 large_scale_change["reviewers"], 

824 compiled_data["aliases"], 

825 teams=teams, 

826 expand_teams=True, 

827 ) 

828 large_scale_change["labels"] = _expand_aliases( 

829 large_scale_change["labels"], 

830 compiled_data["aliases"], 

831 ) 

832 large_scale_change["reviewers"] = _apply_negations( 

833 large_scale_change["reviewers"] 

834 ) 

835 _reject_declared_agents( 

836 large_scale_change["reviewers"], 

837 declared_agents, 

838 where="large_scale_change reviewers", 

839 ) 

840 _validate_review_counts("large_scale_change", large_scale_change) 

841 

842 # Anchor each scope's paths at the directory tagged during flattening 

843 # (after alias expansion, so any `$path-alias` is resolved first). The 

844 # transient tag is popped so it never reaches the model. 

845 for scope in compiled_data["scopes"]: 

846 anchor_dir = scope.pop("_anchor_dir", "") 

847 scope["paths"] = [_anchor_path(anchor_dir, p) for p in scope["paths"]] 

848 

849 # The compiled config is the self-contained effective config: extends 

850 # are already merged in and aliases already expanded, so drop both. This 

851 # keeps stored results lean and makes the compiled form standalone (it 

852 # can never dangle on a missing extends target or re-expand differently). 

853 compiled_data["extends"] = [] 

854 compiled_data["aliases"] = {} 

855 

856 return ConfigModel.from_data( 

857 data=compiled_data, 

858 path=config_path, 

859 ) 

860 

861 def _merged_data( 

862 self, 

863 config_path: Path, 

864 other_configs: ConfigModels, 

865 _in_progress: list[str] | None = None, 

866 _seen: set[str] | None = None, 

867 ) -> dict[str, Any]: 

868 """ 

869 Flatten the `extends` chain into one merged, *unexpanded* config dict. 

870 

871 Parents are merged before this config (so a child can specialize), with 

872 aliases unioned child-wins and the large-scale-change config taken from 

873 the child if set else the first parent that defines one. 

874 

875 `_in_progress` is the current ancestor path, used to detect circular 

876 extends. `_seen` is every config already merged into this flatten, used 

877 to merge a shared ancestor only once (diamond dedup). 

878 """ 

879 if _in_progress is None: 

880 _in_progress = [] 

881 if _seen is None: 

882 _seen = set() 

883 

884 config_path_str = str(config_path) 

885 if config_path_str in _in_progress: 

886 cycle = _in_progress[_in_progress.index(config_path_str) :] + [ 

887 config_path_str 

888 ] 

889 raise ValueError( 

890 f"Circular reference detected in extends: {' -> '.join(cycle)}" 

891 ) 

892 _in_progress.append(config_path_str) 

893 

894 inherited_scopes: list[dict[str, Any]] = [] 

895 inherited_aliases: dict[str, list[str]] = {} 

896 inherited_agents: list[str] = [] 

897 inherited_lsc: dict[str, Any] | None = None 

898 

899 for extend_path in self.extends: 

900 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

901 if resolved_path not in other_configs: 

902 raise ValueError( 

903 f"Config not found: '{extend_path}' (resolved to '{resolved_path}')" 

904 ) 

905 if resolved_path in _seen: 

906 # Already merged via another branch (diamond) — skip the dup. 

907 continue 

908 

909 parent_data = other_configs[resolved_path]._merged_data( 

910 Path(resolved_path), other_configs, _in_progress, _seen 

911 ) 

912 inherited_scopes = inherited_scopes + parent_data["scopes"] 

913 inherited_aliases = inherited_aliases | parent_data["aliases"] 

914 inherited_agents = inherited_agents + parent_data["agents"] 

915 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

916 

917 merged = self.model_dump() 

918 merged["scopes"] = inherited_scopes + merged["scopes"] 

919 merged["aliases"] = inherited_aliases | merged["aliases"] 

920 merged["large_scale_change"] = merged["large_scale_change"] or inherited_lsc 

921 

922 # Union the declared agents across the chain, deduped case-insensitively 

923 # (first casing wins) so a parent and child naming the same agent don't 

924 # re-trip the duplicate validator when the merged config is rebuilt. 

925 merged_agents: list[str] = [] 

926 seen_agents: set[str] = set() 

927 for username in inherited_agents + merged["agents"]: 

928 if username.lower() not in seen_agents: 

929 seen_agents.add(username.lower()) 

930 merged_agents.append(username) 

931 merged["agents"] = merged_agents 

932 

933 # Tag each scope with the directory its paths should anchor at. A scope's 

934 # paths are relative to the config that owns it, so the first 

935 # non-template config to consume a scope claims it: a non-template's own 

936 # scopes (and any it inherits from a template) anchor at its directory, 

937 # while a template defers to its consumer. `setdefault` means an 

938 # already-tagged scope (from a non-template ancestor) keeps its anchor. 

939 if not self.template: 

940 base_dir = posixpath.dirname(config_path_str) 

941 for scope in merged["scopes"]: 

942 scope.setdefault("_anchor_dir", base_dir) 

943 

944 _seen.add(config_path_str) 

945 _in_progress.pop() 

946 

947 return merged 

948 

949 @classmethod 

950 def from_filesystem(cls, path: Path | str) -> ConfigModel: 

951 with open(path, "rb") as f: 

952 return cls.from_data(tomllib.load(f), path) 

953 

954 @classmethod 

955 def from_content(cls, content: str, path: Path | str) -> ConfigModel: 

956 return cls.from_data(tomllib.loads(content), path) 

957 

958 @classmethod 

959 def from_data(cls, data: dict[str, Any], path: Path | str) -> ConfigModel: 

960 return cls(**data) 

961 

962 

963class _ConfigModelsBase(RootModel): 

964 """Shared storage and accessors for a set of configs keyed by repo path.""" 

965 

966 root: dict[str, ConfigModel] 

967 

968 @classmethod 

969 def from_config_models(cls, models: dict[str, ConfigModel]) -> Self: 

970 """Build from a dict of already-constructed configs keyed by path.""" 

971 configs = cls(root={}) 

972 for path, config_model in models.items(): 

973 configs.root[str(Path(path))] = config_model 

974 return configs 

975 

976 def get_config_models(self) -> dict[str, ConfigModel]: 

977 return dict(self.root.items()) 

978 

979 def __bool__(self) -> bool: 

980 return bool(self.root) 

981 

982 def __getitem__(self, key: str) -> ConfigModel: 

983 return self.root[key] 

984 

985 def __contains__(self, key: str) -> bool: 

986 return key in self.root 

987 

988 def __len__(self) -> int: 

989 return len(self.root) 

990 

991 

992class ConfigModels(_ConfigModelsBase): 

993 """Configs exactly as loaded from the repo — extends unresolved, aliases 

994 unexpanded, paths unanchored. Build the set up, then call `compiled()`.""" 

995 

996 def agent_usernames(self) -> set[str]: 

997 """Every account declared as an agent in any config, lowercased -- 

998 the union across the whole set. 

999 

1000 Read off the uncompiled configs on purpose: `agents` entries are 

1001 literal account names -- no aliases, teams, or negation to resolve -- 

1002 so the set is known before compilation, which is what lets a caller 

1003 fetch an agent's review output before evaluating scopes against it. 

1004 """ 

1005 return { 

1006 username.lower() 

1007 for config in self.root.values() 

1008 for username in config.agents 

1009 } 

1010 

1011 @classmethod 

1012 def from_configs_data(cls, data: dict[str, Any]) -> ConfigModels: 

1013 """Load configs from a dict of parsed config data keyed by path.""" 

1014 configs = cls(root={}) 

1015 

1016 for path, config_data in data.items(): 

1017 config = ConfigModel.from_data(config_data, Path(path)) 

1018 configs.add_config(config, Path(path)) 

1019 

1020 return configs 

1021 

1022 @classmethod 

1023 def from_contents(cls, contents: dict[str, str]) -> ConfigModels: 

1024 """Load configs from a dict of raw TOML content keyed by path.""" 

1025 configs = cls(root={}) 

1026 

1027 for path, content in contents.items(): 

1028 configs.add_config(ConfigModel.from_content(content, path), Path(path)) 

1029 

1030 return configs 

1031 

1032 def add_config(self, config: ConfigModel, path: Path) -> None: 

1033 self.root[str(path)] = config 

1034 

1035 def team_refs(self) -> set[str]: 

1036 """Collect every team ref (lowercase, no leading `@`/`!`) that 

1037 `compiled(teams=...)` would actually try to expand: refs written 

1038 directly in a user-list field (scopes' `USER_LIST_FIELDS` and 

1039 `large_scale_change.reviewers`), plus any refs reachable from those 

1040 fields through `$alias`/`!$alias` chains. 

1041 

1042 Meant for callers that need to know which teams to fetch/sync before 

1043 calling `compiled(teams=...)`. 

1044 

1045 Implemented as an offline compile (`teams=None`): aliases expand but 

1046 team refs pass through unexpanded, so whatever refs remain in the 

1047 compiled user-list fields are — by construction — exactly the refs a 

1048 real compile will try to expand. A ref that only appears in a 

1049 non-user-list field (e.g. an npm-style `@vendor/pkg/**` in `paths`) 

1050 or inside an alias nothing references never survives into a compiled 

1051 user-list field, so it is never collected. Raises the same config 

1052 errors `compiled()` would (unknown alias, circular refs, ...), just 

1053 earlier. 

1054 """ 

1055 # Cheap pre-check: a team ref can only enter a compile as a literal 

1056 # `@`/`!@` value in a user-list field or an alias value. Most repos 

1057 # have none, and skipping the compile keeps this near-free for them. 

1058 candidate_lists: list[list[str]] = [] 

1059 for config in self.root.values(): 

1060 candidate_lists.extend(config.aliases.values()) 

1061 for scope in config.scopes: 

1062 for field in USER_LIST_FIELDS: 

1063 candidate_lists.append(getattr(scope, field)) 

1064 if config.large_scale_change: 

1065 candidate_lists.append(config.large_scale_change.reviewers) 

1066 if not any( 

1067 _split_team_ref(value) is not None 

1068 for values in candidate_lists 

1069 for value in values 

1070 ): 

1071 return set() 

1072 

1073 refs: set[str] = set() 

1074 

1075 def collect_refs(values: list[str]) -> None: 

1076 for value in values: 

1077 if (split := _split_team_ref(value)) is not None: 

1078 refs.add(split[1].lower()) 

1079 

1080 for config in self.compiled(teams=None).get_config_models().values(): 

1081 if config.template: 

1082 continue 

1083 for scope in config.scopes: 

1084 for field in USER_LIST_FIELDS: 

1085 collect_refs(getattr(scope, field)) 

1086 if config.large_scale_change: 

1087 collect_refs(config.large_scale_change.reviewers) 

1088 

1089 return refs 

1090 

1091 def compiled( 

1092 self, teams: dict[str, list[str]] | None = None 

1093 ) -> CompiledConfigModels: 

1094 """Resolve the whole set into its effective, PR-independent form. 

1095 

1096 Each non-template config is compiled once — extends merged, aliases 

1097 expanded, paths anchored. Templates are NOT compiled standalone: a 

1098 template scope may reference an alias the consuming config provides, and 

1099 its paths anchor at the consumer. They are carried through untouched 

1100 (folded into each consumer during that consumer's compile, and kept in 

1101 the set for display). 

1102 

1103 `teams` maps team refs (any case, without the leading `@`) to member 

1104 usernames; passed straight through to each config's `compiled_config` 

1105 (see there for case normalization and the `teams=None` vs provided 

1106 semantics). 

1107 

1108 The result is an immutable `CompiledConfigModels` — there is no way to 

1109 compile it again, so the non-idempotent path anchoring can never 

1110 double-apply. 

1111 """ 

1112 effective: dict[str, ConfigModel] = {} 

1113 for path, config in self.root.items(): 

1114 if config.template: 

1115 effective[path] = config 

1116 else: 

1117 effective[path] = config.compiled_config( 

1118 config_path=Path(path), other_configs=self, teams=teams 

1119 ) 

1120 

1121 return CompiledConfigModels.from_config_models(effective) 

1122 

1123 

1124class CompiledConfigModels(_ConfigModelsBase): 

1125 """The effective configs used for matching: every non-template config is 

1126 fully resolved. Produced by `ConfigModels.compiled()`; never recompiled.""" 

1127 

1128 def closest_config(self, file_path: Path) -> ConfigModel: 

1129 """Return the closest non-template config governing this file.""" 

1130 for parent in file_path.parents: 

1131 parent_config_path = str(parent / CONFIG_FILENAME) 

1132 

1133 if parent_config_path in self.root: 

1134 config = self.root[parent_config_path] 

1135 

1136 if config.template: 

1137 # Skip templates 

1138 continue 

1139 

1140 return config 

1141 

1142 raise ValueError(f"No config found for {file_path}") 

1143 

1144 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

1145 """The primary (repo-root) config's large-scale-change section, if any. 

1146 

1147 The primary was compiled by `compiled()`, so its reviewers/labels are 

1148 already alias-expanded (e.g. ["$backend"] -> usernames). A `template = 

1149 true` repo root is a misconfiguration (templates are meant to be 

1150 extended, not be the primary); it is passed through uncompiled, so its 

1151 LSC would read with aliases unexpanded. 

1152 """ 

1153 if CONFIG_FILENAME in self.root: 

1154 if lsc := self.root[CONFIG_FILENAME].large_scale_change: 

1155 return lsc 

1156 

1157 return LargeScaleChangeModel() 

1158 

1159 def filter_for_pullrequest(self, author_username: str) -> CompiledConfigModels: 

1160 """ 

1161 Overlay PR-dependent scope gating: drop scopes that author rules disable 

1162 for this pull request. 

1163 

1164 This is the only PR-dependent step. The configs are already compiled, so 

1165 each config's scopes are self-contained and dropping one is a plain list 

1166 filter — no re-inheritance. Templates are passed through (they are never 

1167 matched directly; their scopes already live in each consumer). 

1168 """ 

1169 effective: dict[str, ConfigModel] = {} 

1170 for config_path, config in self.root.items(): 

1171 if config.template: 

1172 # Templates are never matched directly; pass them through. 

1173 effective[config_path] = config 

1174 continue 

1175 

1176 kept_scopes = [ 

1177 scope 

1178 for scope in config.scopes 

1179 if scope.matches_author(author_username) 

1180 ] 

1181 effective[config_path] = config.model_copy(update={"scopes": kept_scopes}) 

1182 

1183 return CompiledConfigModels.from_config_models(effective)