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

531 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-09-02 15:09 -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 

28from .presets import PRESET_NAMES, Preset, resolve_preset 

29 

30 

31def _resolve_config_filename_prefix() -> str: 

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

33 

34 Defaults to CODEREVIEW. An instance can rename it with 

35 PULLAPPROVE_CONFIG_PREFIX, which renames the config file itself 

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

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

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

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

40 that production also watches. 

41 

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

43 

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

45 

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

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

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

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

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

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

52 

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

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

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

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

57 """ 

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

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

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

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

62 

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

64 # unconfigured), so refuse to start instead. 

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

66 raise ValueError( 

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

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

69 ) 

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

71 raise ValueError( 

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

73 ) 

74 

75 return prefix 

76 

77 

78CONFIG_FILENAME_PREFIX = _resolve_config_filename_prefix() 

79CONFIG_FILENAME = f"{CONFIG_FILENAME_PREFIX}.toml" 

80 

81 

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

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

84 CODEREVIEW.template.toml). 

85 

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

87 prefix (see PULLAPPROVE_CONFIG_PREFIX) never even discovers another 

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

89 from being treated as one. 

90 """ 

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

92 

93 

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

95 

96 

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

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

99 for op, av in data: 

100 if op in _REPEAT_OPS: 

101 if _contains_quantifier(av[2]): 

102 return True 

103 elif op == sre_parse.SUBPATTERN: 

104 if _has_nested_quantifiers(av[-1]): 

105 return True 

106 elif op == sre_parse.BRANCH: # noqa: SIM102 

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

108 return True 

109 return False 

110 

111 

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

113 for op, av in data: 

114 if op in _REPEAT_OPS: 

115 return True 

116 elif op == sre_parse.SUBPATTERN: 

117 if _contains_quantifier(av[-1]): 

118 return True 

119 elif op == sre_parse.BRANCH: # noqa: SIM102 

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

121 return True 

122 return False 

123 

124 

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

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

127 

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

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

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

131 

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

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

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

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

136 

137 

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

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

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

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

142 plain values. 

143 """ 

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

145 return "!", value[2:] 

146 if value.startswith("@"): 

147 return "", value[1:] 

148 return None 

149 

150 

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

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

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

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

155 in user-list fields. 

156 """ 

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

158 

159 

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

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

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

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

164 

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

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

167 can't be confused with a plain username. 

168 """ 

169 for value in values: 

170 split = _split_team_ref(value) 

171 if split is None: 

172 if "@" in value: 

173 raise ValueError( 

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

175 "supported here — use the platform username" 

176 ) 

177 continue 

178 

179 _prefix, ref = split 

180 if not _TEAM_REF_RE.match(ref): 

181 raise ValueError( 

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

183 "'org/team' form" 

184 ) 

185 return values 

186 

187 

188def _expand_team_ref( 

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

190) -> list[str]: 

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

192 

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

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

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

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

197 unknown `$aliases` are handled. 

198 """ 

199 if teams is None: 

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

201 

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

203 if members is None: 

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

205 

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

207 

208 

209def _expand_aliases( 

210 values: list[str], 

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

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

213 expand_teams: bool = False, 

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

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

216) -> list[str]: 

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

218 

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

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

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

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

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

224 never recurses further. 

225 """ 

226 if _seen is None: 

227 _seen = set() 

228 if _path is None: 

229 _path = [] 

230 

231 expanded: list[str] = [] 

232 for value in values: 

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

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

235 prefix = "!" 

236 alias_ref = value[2:] 

237 elif value.startswith("$"): 

238 prefix = "" 

239 alias_ref = value[1:] 

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

241 team_prefix, ref = team_ref 

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

243 continue 

244 else: 

245 expanded.append(value) 

246 continue 

247 

248 if alias_ref in _seen: 

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

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

251 raise ValueError( 

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

253 ) 

254 if alias_ref in aliases: 

255 _seen.add(alias_ref) 

256 _path.append(alias_ref) 

257 # Recursively expand the alias values 

258 nested_expanded = _expand_aliases( 

259 aliases[alias_ref], 

260 aliases=aliases, 

261 teams=teams, 

262 expand_teams=expand_teams, 

263 _seen=_seen, 

264 _path=_path, 

265 ) 

266 if prefix: 

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

268 else: 

269 expanded.extend(nested_expanded) 

270 _path.pop() 

271 _seen.remove(alias_ref) 

272 else: 

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

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

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

276 

277 # Remove duplicates while preserving order 

278 return list(dict.fromkeys(expanded)) 

279 

280 

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

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

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

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

285 

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

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

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

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

290 

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

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

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

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

295 """ 

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

297 raise ValueError( 

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

299 "(wildcard exclusion is not supported)" 

300 ) 

301 

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

303 return values 

304 

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

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

307 

308 

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

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

311 Shared by scopes and large_scale_change (which has no 

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

313 

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

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

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

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

318 requested nobody). 

319 """ 

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

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

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

323 

324 

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

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

327 

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

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

330 file's directory. 

331 

332 Raises if the reference escapes above the repo root. 

333 """ 

334 if extends_ref.startswith("/"): 

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

336 else: 

337 base_dir = posixpath.dirname(extending_path) 

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

339 

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

341 raise ValueError( 

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

343 ) 

344 

345 return resolved 

346 

347 

348def matches_path_patterns(*, path: Path, patterns: list[str]) -> bool: 

349 """Whether `path` matches any of the config's path globs. 

350 

351 The one definition of the config's glob semantics — scopes and agents 

352 both match through here, so their paths can never mean different things. 

353 """ 

354 # TODO paths shouldn't start with / 

355 return glob.globmatch( 

356 path, 

357 patterns, 

358 flags=glob.GLOBSTAR | glob.BRACE | glob.NEGATE | glob.IGNORECASE | glob.DOTGLOB, 

359 ) 

360 

361 

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

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

364 

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

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

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

368 """ 

369 negate = pattern.startswith("!") 

370 if negate: 

371 pattern = pattern[1:] 

372 

373 if pattern.startswith("/"): 

374 anchored = pattern.lstrip("/") 

375 elif base_dir: 

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

377 else: 

378 anchored = pattern 

379 

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

381 

382 

383class OwnershipChoices(StrEnum): 

384 EMPTY = "" 

385 APPEND = "append" 

386 GLOBAL = "global" 

387 

388 

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

390# that owns its check runs (`name`). The suffix is how the engine tells a bot 

391# from a person -- a `[bot]` account never counts as a human and never sits in 

392# a roster, no declaration needed. 

393BOT_LOGIN_SUFFIX = "[bot]" 

394 

395 

396def is_bot_login(username: str) -> bool: 

397 """The engine rule, in one place: a `[bot]` account is never a person.""" 

398 return username.lower().endswith(BOT_LOGIN_SUFFIX) 

399 

400 

401def _split_check_ref(ref: str) -> tuple[str, str]: 

402 """An `unless` ref as (producer slug lowercased, check name). 

403 

404 Split on the FIRST `/`: App slugs cannot contain one, check names can. 

405 Both parts are stripped -- the validator checks THIS function's output, so 

406 a ref that validates is exactly a ref that matches at runtime. 

407 """ 

408 producer, _, name = ref.partition("/") 

409 return producer.strip().lower(), name.strip() 

410 

411 

412def _reject_bot_reviewers(values: list[str], *, where: str) -> None: 

413 """Reject any `[bot]` account listed in a roster surface (a scope's 

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

415 

416 A bot is never a person, so it can never sit in a roster: bots that open 

417 pull requests are routed with `authors`, and bots that attest are 

418 referenced from `unless`. Checked after alias expansion so `$alias` 

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

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

421 """ 

422 for entry in values: 

423 if is_bot_login(entry): 

424 raise ValueError( 

425 f"{where}: '{entry}' is a bot and cannot be listed as a reviewer" 

426 ) 

427 

428 

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

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

431 seen: set[str] = set() 

432 for value in values: 

433 if value.lower() in seen: 

434 return value 

435 seen.add(value.lower()) 

436 return None 

437 

438 

439_KEBAB_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") 

440 

441 

442class AgentModel(BaseModel): 

443 """One agent PullApprove runs against the change. 

444 

445 [[agents]] 

446 name = "react" 

447 preset = "codex-review" 

448 paths = ["frontend/**"] 

449 

450 An agent names exactly one reviewer: a `preset` — a command PullApprove 

451 maintains, see presets.py. Nothing else is configurable — the preset IS 

452 the reviewer, and varying it per repo is what presets exist to prevent. 

453 

454 `paths` is the agent's own jurisdiction, matched the same way a scope's 

455 paths are: it runs when the change touches them, it reviews the touched 

456 files that match, and findings it reports elsewhere are recorded but never 

457 counted. Required, so "everything" is a line someone wrote (`["**"]`) 

458 rather than a blank someone has to interpret. 

459 """ 

460 

461 model_config = ConfigDict(extra="forbid") 

462 

463 name: str 

464 

465 preset: str = "" 

466 

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

468 

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

470 @classmethod 

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

472 if not _KEBAB_NAME_RE.match(name): 

473 raise ValueError( 

474 f"Invalid agent name '{name}'. Use lowercase letters, numbers, " 

475 "and single hyphens (e.g. 'security-review')." 

476 ) 

477 return name 

478 

479 @model_validator(mode="after") 

480 def validate_reviewer(self) -> AgentModel: 

481 """Every agent names a real preset. 

482 

483 Model-level rather than compiled-level: `extends` concatenates whole 

484 agents rather than merging their fields, and the compiled data goes 

485 back through this model, so an agent is the same object here and there. 

486 """ 

487 if not self.preset: 

488 raise ValueError( 

489 f"Agent '{self.name}' names no reviewer. Set 'preset' (one of: " 

490 f"{', '.join(PRESET_NAMES)})." 

491 ) 

492 

493 try: 

494 resolve_preset(self.preset) 

495 except ValueError as exc: 

496 raise ValueError(f"Agent '{self.name}': {exc}") from exc 

497 

498 return self 

499 

500 def reviewer_preset(self) -> Preset: 

501 """The preset this agent names, resolved through the catalog. 

502 

503 `validate_reviewer` guarantees a preset is set, so there is no default 

504 to fall back to. For a caller that wants more than the command — what 

505 the command pinned, say — so the catalog stays behind this model. 

506 """ 

507 return resolve_preset(self.preset) 

508 

509 def command_for(self, *, base: str) -> str: 

510 """The reviewer command to run, with the base spelled into it.""" 

511 return self.reviewer_preset().command(base=base) 

512 

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

514 return matches_path_patterns(path=path, patterns=self.paths) 

515 

516 

517class ScopeModel(BaseModel): 

518 model_config = ConfigDict(extra="forbid") 

519 

520 # Required fields 

521 name: str = Field(min_length=1) 

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

523 

524 # Optional fields 

525 

526 # Expanded version of lines could be dict 

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

528 code: list[str] = [] 

529 

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

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

532 # - labels 

533 # - ref 

534 # - statuses 

535 # - dates 

536 # - body 

537 # - title 

538 # - other scopes 

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

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

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

542 authors: list[str] = [] 

543 

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

545 description: str = "" 

546 reviewers: list[str] = [] 

547 alternates: list[str] = [] 

548 cc: list[str] = [] 

549 

550 # Review scoring 

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

552 require: int = 0 

553 author_value: int = 0 

554 

555 # The checks that can attest this scope's review requirement away. Each ref 

556 # is "producer/check-name" -- the slug of the App that produces the check, 

557 # then the check run's name, split on the first "/". When every listed 

558 # check has completed successfully on the current head commit, the scope is 

559 # waived: its requirement drops to zero and the evidence is stored on the 

560 # result. Failed, skipped, pending, missing -- the requirement stands 

561 # unchanged; passing is the only state that subtracts. 

562 # 

563 # Deliberately placed next to the `require` it undermines, so reading a 

564 # scope always reveals whether its human review is removable. A list is 

565 # implicitly "all must pass" -- OR, thresholds, and 2-of-3 belong in the 

566 # producer, which can post one combined check. 

567 unless: list[str] = [] 

568 

569 # How scopes are combined 

570 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

571 

572 # Actionable items 

573 request: int = 0 

574 labels: list[str] = [] 

575 instructions: str = "" 

576 

577 # Approval checklist 

578 checklist: Checklist | None = None 

579 

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

581 @classmethod 

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

583 if "," in name: 

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

585 return name 

586 

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

588 @classmethod 

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

590 return _validate_team_refs(values) 

591 

592 @field_validator("unless", mode="after") 

593 @classmethod 

594 def validate_unless_refs(cls, values: list[str]) -> list[str]: 

595 """A check name alone is worthless -- any workflow with `checks: write` 

596 can post any name -- so a bare name is a parse error, not a default. 

597 Messages stay under ~130 chars: they become the git-host commit status. 

598 """ 

599 for ref in values: 

600 producer, name = _split_check_ref(ref) 

601 if "/" not in ref or not producer or not name: 

602 raise ValueError( 

603 f"unless: '{ref}' must be 'producer/check-name' -- the App " 

604 "slug that produces the check, then the check's name" 

605 ) 

606 # To most users PullApprove *is* "the check" on their PRs. 

607 # PullApprove never creates these checks, it only reads them -- and 

608 # a config waiting on our own status would deadlock politely. 

609 if producer == "pullapprove": 

610 raise ValueError( 

611 f"unless: '{ref}' references PullApprove itself -- " 

612 "PullApprove never creates checks, it only reads them" 

613 ) 

614 return values 

615 

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

617 @classmethod 

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

619 for pattern in code: 

620 try: 

621 parsed = sre_parse.parse(pattern) 

622 except re.error as e: 

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

624 if _has_nested_quantifiers(parsed): 

625 raise ValueError( 

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

627 "which can cause catastrophic backtracking." 

628 ) 

629 return code 

630 

631 @model_validator(mode="after") 

632 def validate_reviewers_for_require(self) -> ScopeModel: 

633 all_reviewers = self.reviewers + self.alternates 

634 

635 # Skip if wildcard - anyone can review 

636 if "*" in all_reviewers: 

637 return self 

638 

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

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

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

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

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

644 return self 

645 

646 if len(all_reviewers) < self.require: 

647 raise ValueError( 

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

649 ) 

650 return self 

651 

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

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

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

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

656 return self.author_value 

657 return 0 

658 

659 def unless_refs(self) -> list[tuple[str, str]]: 

660 """Each `unless` entry as (producer slug lowercased, check name).""" 

661 return [_split_check_ref(ref) for ref in self.unless] 

662 

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

664 """ 

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

666 or None if it can. 

667 

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

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

670 """ 

671 if "*" in self.reviewers: 

672 # Anyone can review, so any require is satisfiable 

673 return None 

674 

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

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

677 author_username.lower() 

678 } 

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

680 author_username 

681 ) 

682 

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

684 if not eligible_reviewers: 

685 return ( 

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

687 ) 

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

689 

690 return None 

691 

692 def ownership_marker(self) -> str: 

693 """The glyph a non-default ownership puts in front of the scope name. 

694 

695 Split out from printed_name so a renderer that styles the marker apart 

696 from the name doesn't have to know which glyph goes with which mode. 

697 """ 

698 match self.ownership: 

699 case OwnershipChoices.APPEND: 

700 return "+" 

701 case OwnershipChoices.GLOBAL: 

702 return "*" 

703 

704 return "" 

705 

706 def printed_name(self) -> str: 

707 return self.ownership_marker() + self.name 

708 

709 def __eq__(self, other: object) -> bool: 

710 if not isinstance(other, ScopeModel): 

711 return NotImplemented 

712 return self.name == other.name 

713 

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

715 return matches_path_patterns(path=path, patterns=self.paths) 

716 

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

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

719 if not patterns: 

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

721 self._code_regex_patterns = patterns 

722 

723 for pattern in patterns: 

724 for match in pattern.finditer(code): 

725 start_index = match.start() 

726 end_index = match.end() 

727 

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

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

730 

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

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

733 

734 yield { 

735 "start_line": start_line, 

736 "start_col": start_col, 

737 "end_line": end_line, 

738 "end_col": end_col, 

739 } 

740 

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

742 if not self.authors: 

743 # No authors specified, so assume it matches 

744 return True 

745 

746 author_username_lower = author_username.lower() 

747 

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

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

750 

751 if author_username_lower in negated_authors: 

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

753 return False 

754 

755 if not authors: 

756 # Negation-only: everyone not negated matches 

757 return True 

758 

759 return author_username_lower in authors 

760 

761 

762class LargeScaleChangeModel(BaseModel): 

763 model_config = ConfigDict(extra="forbid") 

764 

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

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

767 

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

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

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

771 require: int = 1 

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

773 # min_paths: int = 300 

774 # min_lines: int = 3000 

775 labels: list[str] = [] 

776 # really need author value too...? 

777 

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

779 @classmethod 

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

781 return _validate_team_refs(values) 

782 

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

784 """ 

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

786 or None if it can. 

787 

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

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

790 """ 

791 if "*" in self.reviewers: 

792 # Anyone can review, so any require is satisfiable 

793 return None 

794 

795 if not self.reviewers: 

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

797 # process_large_scale_change reports on its own 

798 return None 

799 

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

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

802 author_username.lower() 

803 } 

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

805 if not eligible_reviewers: 

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

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

808 

809 return None 

810 

811 

812class ConfigModel(BaseModel): 

813 model_config = ConfigDict(extra="forbid") 

814 

815 # Nothing is technically required 

816 extends: list[str] = [] 

817 template: bool = False 

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

819 large_scale_change: LargeScaleChangeModel | None = None 

820 scopes: list[ScopeModel] = [] 

821 agents: list[AgentModel] = [] 

822 

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

824 @classmethod 

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

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

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

828 return scopes 

829 

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

831 @classmethod 

832 def validate_unique_agent_names(cls, agents: list[AgentModel]) -> list[AgentModel]: 

833 if dup := _first_case_insensitive_duplicate(agent.name for agent in agents): 

834 raise ValueError(f"Duplicate agent name: {dup}") 

835 return agents 

836 

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

838 @classmethod 

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

840 for i, path in enumerate(extends): 

841 basename = Path(path).name 

842 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

843 raise ValueError( 

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

845 ) 

846 return extends 

847 

848 def compiled_config( 

849 self, 

850 config_path: Path, 

851 other_configs: ConfigModels, 

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

853 ) -> ConfigModel: 

854 """ 

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

856 

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

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

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

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

861 

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

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

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

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

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

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

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

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

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

871 mapping. 

872 

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

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

875 """ 

876 

877 if teams is not None: 

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

879 

880 compiled_data = self._merged_data(config_path, other_configs) 

881 

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

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

884 for scope in compiled_data["scopes"]: 

885 for field in [ 

886 "paths", 

887 "code", 

888 "authors", 

889 "reviewers", 

890 "alternates", 

891 "cc", 

892 "labels", 

893 ]: 

894 if field in scope: 

895 scope[field] = _expand_aliases( 

896 scope[field], 

897 compiled_data["aliases"], 

898 teams=teams, 

899 expand_teams=field in USER_LIST_FIELDS, 

900 ) 

901 

902 # An agent's paths are paths like a scope's: `$path-alias` references 

903 # resolve, team refs stay literal. 

904 for agent in compiled_data["agents"]: 

905 agent["paths"] = _expand_aliases( 

906 agent["paths"], 

907 compiled_data["aliases"], 

908 teams=teams, 

909 ) 

910 

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

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

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

914 for scope in compiled_data["scopes"]: 

915 for field in ROSTER_FIELDS: 

916 if field in scope: 

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

918 _reject_bot_reviewers( 

919 scope[field], 

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

921 ) 

922 

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

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

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

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

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

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

929 # ScopeModel validator, because compiled configs stored inside 

930 # old processing results must keep parsing. 

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

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

933 for field, hint in ( 

934 ( 

935 "authors", 

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

937 ), 

938 ( 

939 "alternates", 

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

941 ), 

942 ("cc", "remove it"), 

943 ): 

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

945 raise ValueError( 

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

947 f"{field}{hint}" 

948 ) 

949 

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

951 

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

953 large_scale_change["reviewers"] = _expand_aliases( 

954 large_scale_change["reviewers"], 

955 compiled_data["aliases"], 

956 teams=teams, 

957 expand_teams=True, 

958 ) 

959 large_scale_change["labels"] = _expand_aliases( 

960 large_scale_change["labels"], 

961 compiled_data["aliases"], 

962 ) 

963 large_scale_change["reviewers"] = _apply_negations( 

964 large_scale_change["reviewers"] 

965 ) 

966 _reject_bot_reviewers( 

967 large_scale_change["reviewers"], 

968 where="large_scale_change reviewers", 

969 ) 

970 _validate_review_counts("large_scale_change", large_scale_change) 

971 

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

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

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

975 # Scopes and agents alike: paths are relative to the config that owns 

976 # the entry, `/` for repo-root-absolute. 

977 for entry in [*compiled_data["scopes"], *compiled_data["agents"]]: 

978 anchor_dir = entry.pop("_anchor_dir", "") 

979 entry["paths"] = [_anchor_path(anchor_dir, p) for p in entry["paths"]] 

980 

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

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

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

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

985 compiled_data["extends"] = [] 

986 compiled_data["aliases"] = {} 

987 

988 return ConfigModel.from_data( 

989 data=compiled_data, 

990 path=config_path, 

991 ) 

992 

993 def _merged_data( 

994 self, 

995 config_path: Path, 

996 other_configs: ConfigModels, 

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

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

999 ) -> dict[str, Any]: 

1000 """ 

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

1002 

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

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

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

1006 

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

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

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

1010 """ 

1011 if _in_progress is None: 

1012 _in_progress = [] 

1013 if _seen is None: 

1014 _seen = set() 

1015 

1016 config_path_str = str(config_path) 

1017 if config_path_str in _in_progress: 

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

1019 config_path_str 

1020 ] 

1021 raise ValueError( 

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

1023 ) 

1024 _in_progress.append(config_path_str) 

1025 

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

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

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

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

1030 

1031 for extend_path in self.extends: 

1032 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

1033 if resolved_path not in other_configs: 

1034 raise ValueError( 

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

1036 ) 

1037 if resolved_path in _seen: 

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

1039 continue 

1040 

1041 parent_data = other_configs[resolved_path]._merged_data( 

1042 Path(resolved_path), other_configs, _in_progress, _seen 

1043 ) 

1044 inherited_scopes = inherited_scopes + parent_data["scopes"] 

1045 inherited_agents = inherited_agents + parent_data["agents"] 

1046 inherited_aliases = inherited_aliases | parent_data["aliases"] 

1047 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

1048 

1049 merged = self.model_dump() 

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

1051 merged["agents"] = inherited_agents + merged["agents"] 

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

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

1054 

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

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

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

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

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

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

1061 if not self.template: 

1062 base_dir = posixpath.dirname(config_path_str) 

1063 for entry in [*merged["scopes"], *merged["agents"]]: 

1064 entry.setdefault("_anchor_dir", base_dir) 

1065 

1066 _seen.add(config_path_str) 

1067 _in_progress.pop() 

1068 

1069 return merged 

1070 

1071 @classmethod 

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

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

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

1075 

1076 @classmethod 

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

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

1079 

1080 @classmethod 

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

1082 return cls(**data) 

1083 

1084 

1085class _ConfigModelsBase(RootModel): 

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

1087 

1088 root: dict[str, ConfigModel] 

1089 

1090 @classmethod 

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

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

1093 configs = cls(root={}) 

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

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

1096 return configs 

1097 

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

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

1100 

1101 def __bool__(self) -> bool: 

1102 return bool(self.root) 

1103 

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

1105 return self.root[key] 

1106 

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

1108 return key in self.root 

1109 

1110 def __len__(self) -> int: 

1111 return len(self.root) 

1112 

1113 

1114class ConfigModels(_ConfigModelsBase): 

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

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

1117 

1118 def declared_check_names(self) -> set[str]: 

1119 """Every check-run name an effective (non-template) scope's `unless` 

1120 references, across the whole config set. 

1121 

1122 Collected from an offline compile (`teams=None`, the `team_refs` 

1123 precedent) so a template's refs count only where a consumer actually 

1124 inherits them -- an unconsumed template must not make every pull 

1125 request fetch (or error on) checks that nothing evaluates. `unless` 

1126 refs are literal strings (aliases and teams never expand inside 

1127 them), so the offline compile is exact. Empty for the common case (no 

1128 `unless` anywhere), which is what lets the processor skip fetching 

1129 check runs entirely. 

1130 """ 

1131 return { 

1132 name 

1133 for config in self.compiled().root.values() 

1134 if not config.template 

1135 for scope in config.scopes 

1136 for _, name in scope.unless_refs() 

1137 } 

1138 

1139 @classmethod 

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

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

1142 configs = cls(root={}) 

1143 

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

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

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

1147 

1148 return configs 

1149 

1150 @classmethod 

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

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

1153 configs = cls(root={}) 

1154 

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

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

1157 

1158 return configs 

1159 

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

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

1162 

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

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

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

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

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

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

1169 

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

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

1172 

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

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

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

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

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

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

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

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

1181 earlier. 

1182 """ 

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

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

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

1186 candidate_lists: list[list[str]] = [] 

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

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

1189 for scope in config.scopes: 

1190 for field in USER_LIST_FIELDS: 

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

1192 if config.large_scale_change: 

1193 candidate_lists.append(config.large_scale_change.reviewers) 

1194 if not any( 

1195 _split_team_ref(value) is not None 

1196 for values in candidate_lists 

1197 for value in values 

1198 ): 

1199 return set() 

1200 

1201 refs: set[str] = set() 

1202 

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

1204 for value in values: 

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

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

1207 

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

1209 if config.template: 

1210 continue 

1211 for scope in config.scopes: 

1212 for field in USER_LIST_FIELDS: 

1213 collect_refs(getattr(scope, field)) 

1214 if config.large_scale_change: 

1215 collect_refs(config.large_scale_change.reviewers) 

1216 

1217 return refs 

1218 

1219 def compiled( 

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

1221 ) -> CompiledConfigModels: 

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

1223 

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

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

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

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

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

1229 the set for display). 

1230 

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

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

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

1234 semantics). 

1235 

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

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

1238 double-apply. 

1239 """ 

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

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

1242 if config.template: 

1243 effective[path] = config 

1244 else: 

1245 effective[path] = config.compiled_config( 

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

1247 ) 

1248 

1249 return CompiledConfigModels.from_config_models(effective) 

1250 

1251 

1252class CompiledConfigModels(_ConfigModelsBase): 

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

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

1255 

1256 def agents_by_config(self) -> dict[str, dict[str, AgentModel]]: 

1257 """Every effective config's agents, keyed by config path then name. 

1258 

1259 Per config, not flattened, because an agent belongs to the config that 

1260 declares it and covers that config's files. Two sibling configs may 

1261 each declare a `security` agent; those are two agents with one name, 

1262 each reviewing its own config's subtree — flattening them into one 

1263 by-name dict would silently make the last one win. 

1264 

1265 Templates are skipped: their agents are already folded into each 

1266 consumer's compiled config, and a template governs no files itself. 

1267 """ 

1268 return { 

1269 config_path: {agent.name: agent for agent in config.agents} 

1270 for config_path, config in self.root.items() 

1271 if not config.template 

1272 } 

1273 

1274 def closest_config_path(self, file_path: Path) -> str | None: 

1275 """The path of the closest non-template config governing this file, 

1276 or None when nothing governs it.""" 

1277 for parent in file_path.parents: 

1278 parent_config_path = str(parent / CONFIG_FILENAME) 

1279 config = self.root.get(parent_config_path) 

1280 if config is not None and not config.template: 

1281 return parent_config_path 

1282 return None 

1283 

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

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

1286 config_path = self.closest_config_path(file_path) 

1287 if config_path is None: 

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

1289 return self.root[config_path] 

1290 

1291 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

1293 

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

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

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

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

1298 LSC would read with aliases unexpanded. 

1299 """ 

1300 if CONFIG_FILENAME in self.root and ( 

1301 lsc := self.root[CONFIG_FILENAME].large_scale_change 

1302 ): 

1303 return lsc 

1304 

1305 return LargeScaleChangeModel() 

1306 

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

1308 """ 

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

1310 for this pull request. 

1311 

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

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

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

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

1316 """ 

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

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

1319 if config.template: 

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

1321 effective[config_path] = config 

1322 continue 

1323 

1324 kept_scopes = [ 

1325 scope 

1326 for scope in config.scopes 

1327 if scope.matches_author(author_username) 

1328 ] 

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

1330 

1331 return CompiledConfigModels.from_config_models(effective)