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

526 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-27 20:25 -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 

388# A GitHub App has two names: the login it posts as (`name[bot]`) and the slug 

389# that owns its check runs (`name`). Declaring the login is enough -- the slug is 

390# the login without this suffix, so pinning a check to its producing App needs no 

391# extra config. 

392BOT_LOGIN_SUFFIX = "[bot]" 

393 

394 

395def _reject_declared_agents( 

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

397) -> None: 

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

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

400 

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

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

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

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

405 """ 

406 for entry in values: 

407 if entry.lower() in declared_agents: 

408 raise ValueError( 

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

410 "listed as a reviewer" 

411 ) 

412 

413 

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

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

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

417 seen: set[str] = set() 

418 for value in values: 

419 if value.lower() in seen: 

420 return value 

421 seen.add(value.lower()) 

422 return None 

423 

424 

425class AgentModel(BaseModel): 

426 """One declared agent: the account whose review output PullApprove reads, 

427 and optionally the check run that counts as its answer. 

428 

429 Declared as an array of tables so identity can grow into more fields 

430 without reshaping the key: 

431 

432 [[agents]] 

433 account = "review-bot[bot]" 

434 check = "Approvability Check" 

435 """ 

436 

437 model_config = ConfigDict(extra="forbid") 

438 

439 account: str 

440 

441 # The name of a check run that reports for this agent, for agents that 

442 # report through checks instead of reviews -- some never submit a review at 

443 # all. Empty means reviews are the only channel read. 

444 # 

445 # Not optional decoration: without it, `github-actions[bot]` declared as an 

446 # agent would be answered by any CI check in the repo. The name alone is 

447 # forgeable too -- any workflow with `checks: write` can create a check of 

448 # any name -- so matching also requires the producing App to be this 

449 # account's App (see `app_slug`). 

450 check: str = "" 

451 

452 @field_validator("account", mode="after") 

453 @classmethod 

454 def validate_account(cls, account: str) -> str: 

455 _validate_agent_username(account) 

456 return account 

457 

458 @model_validator(mode="after") 

459 def validate_check(self) -> AgentModel: 

460 if not self.check: 

461 return self 

462 

463 if not self.check.strip(): 

464 raise ValueError(f"Agent '{self.account}' has an empty check name") 

465 

466 # A check run is always created by a GitHub App, and the App is derived 

467 # from the account's `[bot]` login. An account without one names no App, 

468 # so there would be nothing to match the check's producer against -- 

469 # which is the whole reason `check` is safe to trust. 

470 if not self.account.lower().endswith(BOT_LOGIN_SUFFIX): 

471 raise ValueError( 

472 f"Agent '{self.account}' can't use `check`: only a " 

473 f'"name{BOT_LOGIN_SUFFIX}" app account produces check runs' 

474 ) 

475 

476 return self 

477 

478 @property 

479 def app_slug(self) -> str: 

480 """The App slug that must have produced a check run for it to count as 

481 this agent's report, lowercased. 

482 

483 Only meaningful alongside `check`, and `validate_check` already refuses 

484 that on an account without the suffix -- so the rule lives in one place 

485 and this is a plain derivation. 

486 """ 

487 return self.account.lower().removesuffix(BOT_LOGIN_SUFFIX) 

488 

489 

490class ScopeModel(BaseModel): 

491 model_config = ConfigDict(extra="forbid") 

492 

493 # Required fields 

494 name: str = Field(min_length=1) 

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

496 

497 # Optional fields 

498 

499 # Expanded version of lines could be dict 

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

501 code: list[str] = [] 

502 

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

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

505 # - labels 

506 # - ref 

507 # - statuses 

508 # - dates 

509 # - body 

510 # - title 

511 # - other scopes 

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

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

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

515 authors: list[str] = [] 

516 

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

518 description: str = "" 

519 reviewers: list[str] = [] 

520 alternates: list[str] = [] 

521 cc: list[str] = [] 

522 

523 # Review scoring 

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

525 require: int = 0 

526 author_value: int = 0 

527 

528 # How scopes are combined 

529 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

530 

531 # Actionable items 

532 request: int = 0 

533 labels: list[str] = [] 

534 instructions: str = "" 

535 

536 # Approval checklist 

537 checklist: Checklist | None = None 

538 

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

540 @classmethod 

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

542 if "," in name: 

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

544 return name 

545 

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

547 @classmethod 

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

549 return _validate_team_refs(values) 

550 

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

552 @classmethod 

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

554 for pattern in code: 

555 try: 

556 parsed = sre_parse.parse(pattern) 

557 except re.error as e: 

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

559 if _has_nested_quantifiers(parsed): 

560 raise ValueError( 

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

562 "which can cause catastrophic backtracking." 

563 ) 

564 return code 

565 

566 @model_validator(mode="after") 

567 def validate_reviewers_for_require(self) -> ScopeModel: 

568 all_reviewers = self.reviewers + self.alternates 

569 

570 # Skip if wildcard - anyone can review 

571 if "*" in all_reviewers: 

572 return self 

573 

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

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

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

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

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

579 return self 

580 

581 if len(all_reviewers) < self.require: 

582 raise ValueError( 

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

584 ) 

585 return self 

586 

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

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

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

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

591 return self.author_value 

592 return 0 

593 

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

595 """ 

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

597 or None if it can. 

598 

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

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

601 """ 

602 if "*" in self.reviewers: 

603 # Anyone can review, so any require is satisfiable 

604 return None 

605 

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

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

608 author_username.lower() 

609 } 

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

611 author_username 

612 ) 

613 

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

615 if not eligible_reviewers: 

616 return ( 

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

618 ) 

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

620 

621 return None 

622 

623 def printed_name(self) -> str: 

624 match self.ownership: 

625 case OwnershipChoices.APPEND: 

626 return "+" + self.name 

627 case OwnershipChoices.GLOBAL: 

628 return "*" + self.name 

629 

630 return self.name 

631 

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

633 return self.name == other.name 

634 

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

636 # TODO paths shouldn't start with / 

637 return glob.globmatch( 

638 path, 

639 self.paths, 

640 flags=glob.GLOBSTAR 

641 | glob.BRACE 

642 | glob.NEGATE 

643 | glob.IGNORECASE 

644 | glob.DOTGLOB, 

645 ) 

646 

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

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

649 if not patterns: 

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

651 self._code_regex_patterns = patterns 

652 

653 for pattern in patterns: 

654 for match in pattern.finditer(code): 

655 start_index = match.start() 

656 end_index = match.end() 

657 

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

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

660 

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

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

663 

664 yield { 

665 "start_line": start_line, 

666 "start_col": start_col, 

667 "end_line": end_line, 

668 "end_col": end_col, 

669 } 

670 

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

672 if not self.authors: 

673 # No authors specified, so assume it matches 

674 return True 

675 

676 author_username_lower = author_username.lower() 

677 

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

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

680 

681 if author_username_lower in negated_authors: 

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

683 return False 

684 

685 if not authors: 

686 # Negation-only: everyone not negated matches 

687 return True 

688 

689 if author_username_lower in authors: 

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

691 return True 

692 

693 return False 

694 

695 

696class LargeScaleChangeModel(BaseModel): 

697 model_config = ConfigDict(extra="forbid") 

698 

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

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

701 

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

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

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

705 require: int = 1 

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

707 # min_paths: int = 300 

708 # min_lines: int = 3000 

709 labels: list[str] = [] 

710 # really need author value too...? 

711 

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

713 @classmethod 

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

715 return _validate_team_refs(values) 

716 

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

718 """ 

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

720 or None if it can. 

721 

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

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

724 """ 

725 if "*" in self.reviewers: 

726 # Anyone can review, so any require is satisfiable 

727 return None 

728 

729 if not self.reviewers: 

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

731 # process_large_scale_change reports on its own 

732 return None 

733 

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

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

736 author_username.lower() 

737 } 

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

739 if not eligible_reviewers: 

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

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

742 

743 return None 

744 

745 

746class ConfigModel(BaseModel): 

747 model_config = ConfigDict(extra="forbid") 

748 

749 # Nothing is technically required 

750 extends: list[str] = [] 

751 template: bool = False 

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

753 large_scale_change: LargeScaleChangeModel | None = None 

754 scopes: list[ScopeModel] = [] 

755 

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

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

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

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

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

761 agents: list[AgentModel] = [] 

762 

763 @field_validator("agents", mode="before") 

764 @classmethod 

765 def reject_flat_agents(cls, agents: Any) -> Any: 

766 """`agents` shipped as a flat list of usernames in the first beta. 

767 

768 Pydantic's own error for a string where a table belongs reads as a 

769 type mismatch, which doesn't tell anyone what to write instead -- and 

770 this message becomes the git-host commit status, so it's the only 

771 instruction most people will see. 

772 """ 

773 if isinstance(agents, list) and any(isinstance(a, str) for a in agents): 

774 raise ValueError( 

775 "`agents` is now an array of tables: replace " 

776 '`agents = ["name[bot]"]` with `[[agents]]` / `account = "name[bot]"`' 

777 ) 

778 return agents 

779 

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

781 @classmethod 

782 def validate_agents(cls, agents: list[AgentModel]) -> list[AgentModel]: 

783 if dup := _first_case_insensitive_duplicate(a.account for a in agents): 

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

785 return agents 

786 

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

788 @classmethod 

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

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

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

792 return scopes 

793 

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

795 @classmethod 

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

797 for i, path in enumerate(extends): 

798 basename = Path(path).name 

799 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

800 raise ValueError( 

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

802 ) 

803 return extends 

804 

805 def compiled_config( 

806 self, 

807 config_path: Path, 

808 other_configs: ConfigModels, 

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

810 ) -> ConfigModel: 

811 """ 

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

813 

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

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

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

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

818 

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

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

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

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

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

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

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

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

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

828 mapping. 

829 

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

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

832 """ 

833 

834 if teams is not None: 

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

836 

837 compiled_data = self._merged_data(config_path, other_configs) 

838 

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

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

841 for scope in compiled_data["scopes"]: 

842 for field in [ 

843 "paths", 

844 "code", 

845 "authors", 

846 "reviewers", 

847 "alternates", 

848 "cc", 

849 "labels", 

850 ]: 

851 if field in scope: 

852 scope[field] = _expand_aliases( 

853 scope[field], 

854 compiled_data["aliases"], 

855 teams=teams, 

856 expand_teams=field in USER_LIST_FIELDS, 

857 ) 

858 

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

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

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

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

863 # indirection can't smuggle one in. 

864 declared_agents = other_configs.agent_usernames() 

865 

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

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

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

869 for scope in compiled_data["scopes"]: 

870 for field in ROSTER_FIELDS: 

871 if field in scope: 

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

873 _reject_declared_agents( 

874 scope[field], 

875 declared_agents, 

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

877 ) 

878 

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

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

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

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

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

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

885 # ScopeModel validator, because compiled configs stored inside 

886 # old processing results must keep parsing. 

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

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

889 for field, hint in ( 

890 ( 

891 "authors", 

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

893 ), 

894 ( 

895 "alternates", 

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

897 ), 

898 ("cc", "remove it"), 

899 ): 

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

901 raise ValueError( 

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

903 f"{field}{hint}" 

904 ) 

905 

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

907 

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

909 large_scale_change["reviewers"] = _expand_aliases( 

910 large_scale_change["reviewers"], 

911 compiled_data["aliases"], 

912 teams=teams, 

913 expand_teams=True, 

914 ) 

915 large_scale_change["labels"] = _expand_aliases( 

916 large_scale_change["labels"], 

917 compiled_data["aliases"], 

918 ) 

919 large_scale_change["reviewers"] = _apply_negations( 

920 large_scale_change["reviewers"] 

921 ) 

922 _reject_declared_agents( 

923 large_scale_change["reviewers"], 

924 declared_agents, 

925 where="large_scale_change reviewers", 

926 ) 

927 _validate_review_counts("large_scale_change", large_scale_change) 

928 

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

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

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

932 for scope in compiled_data["scopes"]: 

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

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

935 

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

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

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

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

940 compiled_data["extends"] = [] 

941 compiled_data["aliases"] = {} 

942 

943 return ConfigModel.from_data( 

944 data=compiled_data, 

945 path=config_path, 

946 ) 

947 

948 def _merged_data( 

949 self, 

950 config_path: Path, 

951 other_configs: ConfigModels, 

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

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

954 ) -> dict[str, Any]: 

955 """ 

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

957 

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

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

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

961 

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

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

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

965 """ 

966 if _in_progress is None: 

967 _in_progress = [] 

968 if _seen is None: 

969 _seen = set() 

970 

971 config_path_str = str(config_path) 

972 if config_path_str in _in_progress: 

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

974 config_path_str 

975 ] 

976 raise ValueError( 

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

978 ) 

979 _in_progress.append(config_path_str) 

980 

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

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

983 inherited_agents: list[dict[str, Any]] = [] 

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

985 

986 for extend_path in self.extends: 

987 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

988 if resolved_path not in other_configs: 

989 raise ValueError( 

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

991 ) 

992 if resolved_path in _seen: 

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

994 continue 

995 

996 parent_data = other_configs[resolved_path]._merged_data( 

997 Path(resolved_path), other_configs, _in_progress, _seen 

998 ) 

999 inherited_scopes = inherited_scopes + parent_data["scopes"] 

1000 inherited_aliases = inherited_aliases | parent_data["aliases"] 

1001 inherited_agents = inherited_agents + parent_data["agents"] 

1002 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

1003 

1004 merged = self.model_dump() 

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

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

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

1008 

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

1010 # by account (first declaration wins) so a parent and child naming the 

1011 # same agent don't re-trip the duplicate validator when the merged 

1012 # config is rebuilt. 

1013 merged_agents: list[dict[str, Any]] = [] 

1014 seen_agents: set[str] = set() 

1015 for agent in inherited_agents + merged["agents"]: 

1016 if agent["account"].lower() not in seen_agents: 

1017 seen_agents.add(agent["account"].lower()) 

1018 merged_agents.append(agent) 

1019 merged["agents"] = merged_agents 

1020 

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

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

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

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

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

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

1027 if not self.template: 

1028 base_dir = posixpath.dirname(config_path_str) 

1029 for scope in merged["scopes"]: 

1030 scope.setdefault("_anchor_dir", base_dir) 

1031 

1032 _seen.add(config_path_str) 

1033 _in_progress.pop() 

1034 

1035 return merged 

1036 

1037 @classmethod 

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

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

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

1041 

1042 @classmethod 

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

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

1045 

1046 @classmethod 

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

1048 return cls(**data) 

1049 

1050 

1051class _ConfigModelsBase(RootModel): 

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

1053 

1054 root: dict[str, ConfigModel] 

1055 

1056 @classmethod 

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

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

1059 configs = cls(root={}) 

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

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

1062 return configs 

1063 

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

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

1066 

1067 def __bool__(self) -> bool: 

1068 return bool(self.root) 

1069 

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

1071 return self.root[key] 

1072 

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

1074 return key in self.root 

1075 

1076 def __len__(self) -> int: 

1077 return len(self.root) 

1078 

1079 

1080class ConfigModels(_ConfigModelsBase): 

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

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

1083 

1084 def declared_agents(self) -> dict[str, AgentModel]: 

1085 """Every declared agent in any config, keyed by lowercased account -- 

1086 the union across the whole set. 

1087 

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

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

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

1091 fetch an agent's review output (and its check runs) before evaluating 

1092 scopes against it. 

1093 

1094 Declaring one account in several files is fine while the declarations 

1095 agree, and an error when they disagree. Keeping the first and dropping 

1096 the rest would make the outcome depend on which file was enumerated 

1097 first, and the failure would be silent in the worst direction: an 

1098 agent declared with a `check` in one file and without one in another 

1099 would lose its only reporting channel and hold every pull request it 

1100 matched. Same-file duplicates are already rejected outright. 

1101 """ 

1102 agents: dict[str, AgentModel] = {} 

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

1104 for agent in config.agents: 

1105 account = agent.account.lower() 

1106 existing = agents.get(account) 

1107 if existing is None: 

1108 agents[account] = agent 

1109 elif existing.check != agent.check: 

1110 raise ValueError( 

1111 f"Agent '{agent.account}' is declared with different " 

1112 "`check` values in more than one config" 

1113 ) 

1114 return agents 

1115 

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

1117 """Every account declared as an agent, lowercased.""" 

1118 return set(self.declared_agents()) 

1119 

1120 def agent_check_names(self) -> set[str]: 

1121 """Every check-run name a declared agent answers through. 

1122 

1123 Empty for the common case (agents that submit reviews), which is what 

1124 lets the processor skip fetching check runs entirely. 

1125 """ 

1126 return {agent.check for agent in self.declared_agents().values() if agent.check} 

1127 

1128 @classmethod 

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

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

1131 configs = cls(root={}) 

1132 

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

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

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

1136 

1137 return configs 

1138 

1139 @classmethod 

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

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

1142 configs = cls(root={}) 

1143 

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

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

1146 

1147 return configs 

1148 

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

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

1151 

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

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

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

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

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

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

1158 

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

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

1161 

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

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

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

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

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

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

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

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

1170 earlier. 

1171 """ 

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

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

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

1175 candidate_lists: list[list[str]] = [] 

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

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

1178 for scope in config.scopes: 

1179 for field in USER_LIST_FIELDS: 

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

1181 if config.large_scale_change: 

1182 candidate_lists.append(config.large_scale_change.reviewers) 

1183 if not any( 

1184 _split_team_ref(value) is not None 

1185 for values in candidate_lists 

1186 for value in values 

1187 ): 

1188 return set() 

1189 

1190 refs: set[str] = set() 

1191 

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

1193 for value in values: 

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

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

1196 

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

1198 if config.template: 

1199 continue 

1200 for scope in config.scopes: 

1201 for field in USER_LIST_FIELDS: 

1202 collect_refs(getattr(scope, field)) 

1203 if config.large_scale_change: 

1204 collect_refs(config.large_scale_change.reviewers) 

1205 

1206 return refs 

1207 

1208 def compiled( 

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

1210 ) -> CompiledConfigModels: 

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

1212 

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

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

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

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

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

1218 the set for display). 

1219 

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

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

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

1223 semantics). 

1224 

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

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

1227 double-apply. 

1228 """ 

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

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

1231 if config.template: 

1232 effective[path] = config 

1233 else: 

1234 effective[path] = config.compiled_config( 

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

1236 ) 

1237 

1238 return CompiledConfigModels.from_config_models(effective) 

1239 

1240 

1241class CompiledConfigModels(_ConfigModelsBase): 

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

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

1244 

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

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

1247 for parent in file_path.parents: 

1248 parent_config_path = str(parent / CONFIG_FILENAME) 

1249 

1250 if parent_config_path in self.root: 

1251 config = self.root[parent_config_path] 

1252 

1253 if config.template: 

1254 # Skip templates 

1255 continue 

1256 

1257 return config 

1258 

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

1260 

1261 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

1263 

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

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

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

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

1268 LSC would read with aliases unexpanded. 

1269 """ 

1270 if CONFIG_FILENAME in self.root: 

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

1272 return lsc 

1273 

1274 return LargeScaleChangeModel() 

1275 

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

1277 """ 

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

1279 for this pull request. 

1280 

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

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

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

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

1285 """ 

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

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

1288 if config.template: 

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

1290 effective[config_path] = config 

1291 continue 

1292 

1293 kept_scopes = [ 

1294 scope 

1295 for scope in config.scopes 

1296 if scope.matches_author(author_username) 

1297 ] 

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

1299 

1300 return CompiledConfigModels.from_config_models(effective)