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

567 statements  

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

93def is_config_path(path: str) -> bool: 

94 """Whether a repo-relative path names a config file.""" 

95 return is_config_filename(posixpath.basename(path)) 

96 

97 

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

99 

100 

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

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

103 for op, av in data: 

104 if op in _REPEAT_OPS: 

105 if _contains_quantifier(av[2]): 

106 return True 

107 elif op == sre_parse.SUBPATTERN: 

108 if _has_nested_quantifiers(av[-1]): 

109 return True 

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

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

112 return True 

113 return False 

114 

115 

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

117 for op, av in data: 

118 if op in _REPEAT_OPS: 

119 return True 

120 elif op == sre_parse.SUBPATTERN: 

121 if _contains_quantifier(av[-1]): 

122 return True 

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

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

125 return True 

126 return False 

127 

128 

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

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

131 

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

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

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

135 

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

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

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

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

140 

141 

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

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

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

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

146 plain values. 

147 """ 

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

149 return "!", value[2:] 

150 if value.startswith("@"): 

151 return "", value[1:] 

152 return None 

153 

154 

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

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

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

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

159 in user-list fields. 

160 """ 

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

162 

163 

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

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

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

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

168 

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

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

171 can't be confused with a plain username. 

172 """ 

173 for value in values: 

174 split = _split_team_ref(value) 

175 if split is None: 

176 if "@" in value: 

177 raise ValueError( 

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

179 "supported here — use the platform username" 

180 ) 

181 continue 

182 

183 _prefix, ref = split 

184 if not _TEAM_REF_RE.match(ref): 

185 raise ValueError( 

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

187 "'org/team' form" 

188 ) 

189 return values 

190 

191 

192def _expand_team_ref( 

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

194) -> list[str]: 

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

196 

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

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

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

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

201 unknown `$aliases` are handled. 

202 """ 

203 if teams is None: 

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

205 

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

207 if members is None: 

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

209 

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

211 

212 

213def _expand_aliases( 

214 values: list[str], 

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

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

217 expand_teams: bool = False, 

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

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

220) -> list[str]: 

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

222 

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

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

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

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

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

228 never recurses further. 

229 """ 

230 if _seen is None: 

231 _seen = set() 

232 if _path is None: 

233 _path = [] 

234 

235 expanded: list[str] = [] 

236 for value in values: 

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

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

239 prefix = "!" 

240 alias_ref = value[2:] 

241 elif value.startswith("$"): 

242 prefix = "" 

243 alias_ref = value[1:] 

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

245 team_prefix, ref = team_ref 

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

247 continue 

248 else: 

249 expanded.append(value) 

250 continue 

251 

252 if alias_ref in _seen: 

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

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

255 raise ValueError( 

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

257 ) 

258 if alias_ref in aliases: 

259 _seen.add(alias_ref) 

260 _path.append(alias_ref) 

261 # Recursively expand the alias values 

262 nested_expanded = _expand_aliases( 

263 aliases[alias_ref], 

264 aliases=aliases, 

265 teams=teams, 

266 expand_teams=expand_teams, 

267 _seen=_seen, 

268 _path=_path, 

269 ) 

270 if prefix: 

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

272 else: 

273 expanded.extend(nested_expanded) 

274 _path.pop() 

275 _seen.remove(alias_ref) 

276 else: 

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

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

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

280 

281 # Remove duplicates while preserving order 

282 return list(dict.fromkeys(expanded)) 

283 

284 

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

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

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

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

289 

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

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

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

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

294 

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

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

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

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

299 """ 

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

301 raise ValueError( 

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

303 "(wildcard exclusion is not supported)" 

304 ) 

305 

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

307 return values 

308 

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

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

311 

312 

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

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

315 Shared by scopes and large_scale_change (which has no author_value — 

316 the `.get` default passes trivially there). 

317 

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

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

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

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

322 requested nobody). 

323 """ 

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

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

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

327 

328 

329def _compile_review_fields( 

330 *, 

331 entry: dict[str, Any], 

332 label: str, 

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

334 teams: dict[str, list[str]] | None, 

335) -> None: 

336 """Compile a raw scope, or the large-scale change, in place: expand 

337 aliases, apply "!" subtraction, and reject what can never work. Only the 

338 fields the entry has are touched. `label` names it in error messages 

339 ("Scope 'app'", "large_scale_change"). 

340 """ 

341 # Team refs only expand in user-list fields — paths/code/labels keep a 

342 # leading "@" literal. 

343 for field in ( 

344 "paths", 

345 "code", 

346 "authors", 

347 "reviewers", 

348 "alternates", 

349 "cc", 

350 "labels", 

351 ): 

352 if field in entry: 

353 entry[field] = _expand_aliases( 

354 entry[field], 

355 aliases, 

356 teams=teams, 

357 expand_teams=field in USER_LIST_FIELDS, 

358 ) 

359 

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

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

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

363 for field in ROSTER_FIELDS: 

364 if field in entry: 

365 entry[field] = _apply_negations(entry[field]) 

366 _reject_bot_reviewers(entry[field], where=f"{label} {field}") 

367 

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

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

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

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

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

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

374 # ScopeModel validator, because compiled configs stored inside 

375 # old processing results must keep parsing. 

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

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

378 for field, hint in ( 

379 ( 

380 "authors", 

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

382 ), 

383 ( 

384 "alternates", 

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

386 ), 

387 ("cc", "remove it"), 

388 ): 

389 if "*" in entry.get(field, []): 

390 raise ValueError(f'{label}: "*" is not supported in {field} — {hint}') 

391 

392 _validate_review_counts(label, entry) 

393 

394 

395def _roster_shortfall( 

396 *, reviewers: list[str], alternates: list[str], require: int 

397) -> str | None: 

398 """Why `require` approvals can never come from these reviewers and 

399 alternates, or None if they can.""" 

400 all_reviewers = reviewers + alternates 

401 

402 # Skip if wildcard - anyone can review 

403 if "*" in all_reviewers: 

404 return None 

405 

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

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

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

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

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

411 return None 

412 

413 if len(all_reviewers) < require: 

414 return f"has require={require} but only {len(all_reviewers)} reviewers/alternates specified" 

415 

416 return None 

417 

418 

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

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

421 

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

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

424 file's directory. 

425 

426 Raises if the reference escapes above the repo root. 

427 """ 

428 if extends_ref.startswith("/"): 

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

430 else: 

431 base_dir = posixpath.dirname(extending_path) 

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

433 

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

435 raise ValueError( 

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

437 ) 

438 

439 return resolved 

440 

441 

442def scope_line_range(*, content: str, scope_name: str) -> tuple[int, int] | None: 

443 """The first and last line (1-indexed) of the `[[scopes]]` table that 

444 declares a scope, or None when it isn't written as its own table (an 

445 inline `scopes = [...]`, say). 

446 

447 TOML parsers don't report positions, so this splits the text at its table 

448 headers and parses each `[[scopes]]` block on its own. 

449 """ 

450 # Only `\n` ends a line, as in git and editors (`splitlines` also splits 

451 # on characters like U+2028). 

452 lines = content.split("\n") 

453 

454 # Every table header starts a block, except a scope's own sub-tables 

455 # (`[[scopes.checklist.items]]`), which belong to the scope above them. A 

456 # line inside a multi-line string is never a header, whatever it starts 

457 # with. 

458 block_starts = [ 

459 index 

460 for index, starts_outside in enumerate(_lines_outside_strings(lines)) 

461 if starts_outside 

462 and _table_header(lines[index]) 

463 and not _table_header(lines[index]).startswith(("[scopes.", "[[scopes.")) 

464 ] 

465 block_ends = block_starts[1:] + [len(lines)] 

466 

467 for start, end in zip(block_starts, block_ends): 

468 if not _table_header(lines[start]).startswith("[[scopes]]"): 

469 continue 

470 try: 

471 block = tomllib.loads("\n".join(lines[start:end])) 

472 except tomllib.TOMLDecodeError: 

473 continue 

474 if block["scopes"][0].get("name") != scope_name: 

475 continue 

476 

477 # Blank lines and comments at the end lead into the next table. 

478 while end > start + 1 and ( 

479 not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#") 

480 ): 

481 end -= 1 

482 return start + 1, end 

483 

484 return None 

485 

486 

487def _table_header(line: str) -> str: 

488 """A table header line without the whitespace and quotes TOML allows in 

489 it (`[[ "scopes" ]]` is `[[scopes]]`), or "" for any other line.""" 

490 stripped = line.strip() 

491 if not stripped.startswith("["): 

492 return "" 

493 return re.sub(r"[\s\"']", "", stripped) 

494 

495 

496def _lines_outside_strings(lines: list[str]) -> list[bool]: 

497 """For each line, whether it starts outside a multi-line string. Comments 

498 and single-line strings are skipped, so a `\"\"\"` inside one of them 

499 doesn't open anything.""" 

500 starts_outside = [] 

501 open_multiline = "" # `"""` or `'''` while inside one 

502 for line in lines: 

503 starts_outside.append(not open_multiline) 

504 index = 0 

505 while index < len(line): 

506 if open_multiline == '"""': 

507 if line[index] == "\\": 

508 index += 2 

509 elif line.startswith('"""', index): 

510 open_multiline = "" 

511 # Up to two quotes may sit right before the delimiter. 

512 while line.startswith('"', index + 3): 

513 index += 1 

514 index += 3 

515 else: 

516 index += 1 

517 elif open_multiline == "'''": 

518 close = line.find("'''", index) 

519 if close == -1: 

520 break 

521 open_multiline = "" 

522 index = close + 3 

523 while line.startswith("'", index): 

524 index += 1 

525 elif line.startswith(('"""', "'''"), index): 

526 open_multiline = line[index : index + 3] 

527 index += 3 

528 elif line[index] == "#": 

529 break 

530 elif line[index] == '"': 

531 # A basic string ends at the next unescaped quote. 

532 index += 1 

533 while index < len(line) and line[index] != '"': 

534 index += 2 if line[index] == "\\" else 1 

535 index += 1 

536 elif line[index] == "'": 

537 # A literal string has no escapes. 

538 close = line.find("'", index + 1) 

539 index = len(line) if close == -1 else close + 1 

540 else: 

541 index += 1 

542 return starts_outside 

543 

544 

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

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

547 

548 The one definition of the config's glob semantics. 

549 """ 

550 # TODO paths shouldn't start with / 

551 return glob.globmatch( 

552 path, 

553 patterns, 

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

555 ) 

556 

557 

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

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

560 

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

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

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

564 """ 

565 negate = pattern.startswith("!") 

566 if negate: 

567 pattern = pattern[1:] 

568 

569 if pattern.startswith("/"): 

570 anchored = pattern.lstrip("/") 

571 elif base_dir: 

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

573 else: 

574 anchored = pattern 

575 

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

577 

578 

579class OwnershipChoices(StrEnum): 

580 EMPTY = "" 

581 APPEND = "append" 

582 GLOBAL = "global" 

583 

584 

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

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

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

588# a roster, no declaration needed. 

589BOT_LOGIN_SUFFIX = "[bot]" 

590 

591 

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

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

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

595 

596 

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

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

599 

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

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

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

603 """ 

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

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

606 

607 

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

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

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

611 

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

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

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

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

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

617 """ 

618 for entry in values: 

619 if is_bot_login(entry): 

620 raise ValueError( 

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

622 ) 

623 

624 

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

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

627 seen: set[str] = set() 

628 for value in values: 

629 if value.lower() in seen: 

630 return value 

631 seen.add(value.lower()) 

632 return None 

633 

634 

635class ScopeModel(BaseModel): 

636 model_config = ConfigDict(extra="forbid") 

637 

638 # Required fields 

639 name: str = Field(min_length=1) 

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

641 

642 # Optional fields 

643 

644 # Expanded version of lines could be dict 

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

646 code: list[str] = [] 

647 

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

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

650 # - labels 

651 # - ref 

652 # - statuses 

653 # - dates 

654 # - body 

655 # - title 

656 # - other scopes 

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

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

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

660 authors: list[str] = [] 

661 

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

663 description: str = "" 

664 reviewers: list[str] = [] 

665 alternates: list[str] = [] 

666 cc: list[str] = [] 

667 

668 # Review scoring 

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

670 require: int = 0 

671 author_value: int = 0 

672 

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

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

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

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

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

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

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

680 # 

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

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

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

684 # producer, which can post one combined check. 

685 unless: list[str] = [] 

686 

687 # How scopes are combined 

688 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

689 

690 # Actionable items 

691 request: int = 0 

692 labels: list[str] = [] 

693 instructions: str = "" 

694 

695 # Approval checklist 

696 checklist: Checklist | None = None 

697 

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

699 @classmethod 

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

701 if "," in name: 

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

703 return name 

704 

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

706 @classmethod 

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

708 return _validate_team_refs(values) 

709 

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

711 @classmethod 

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

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

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

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

716 """ 

717 for ref in values: 

718 producer, name = _split_check_ref(ref) 

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

720 raise ValueError( 

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

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

723 ) 

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

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

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

727 if producer == "pullapprove": 

728 raise ValueError( 

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

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

731 ) 

732 return values 

733 

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

735 @classmethod 

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

737 for pattern in code: 

738 try: 

739 parsed = sre_parse.parse(pattern) 

740 except re.error as e: 

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

742 if _has_nested_quantifiers(parsed): 

743 raise ValueError( 

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

745 "which can cause catastrophic backtracking." 

746 ) 

747 return code 

748 

749 @model_validator(mode="after") 

750 def validate_reviewers_for_require(self) -> ScopeModel: 

751 if reason := _roster_shortfall( 

752 reviewers=self.reviewers, 

753 alternates=self.alternates, 

754 require=self.require, 

755 ): 

756 raise ValueError(reason) 

757 return self 

758 

759 def counts_toward(self, username: str) -> bool: 

760 """Whether this person's review counts for this scope. 

761 

762 Its named `reviewers` and its `alternates`, plus everybody when 

763 `reviewers` holds the wildcard. A wildcard in `alternates` does not 

764 count everybody: the wildcard describes the scope's `reviewers`, not 

765 its backups. 

766 

767 Case-insensitive, like every other username comparison here. 

768 """ 

769 if "*" in self.reviewers: 

770 return True 

771 username_lower = username.lower() 

772 return any( 

773 username_lower == r.lower() for r in self.reviewers + self.alternates 

774 ) 

775 

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

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

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

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

780 return self.author_value 

781 return 0 

782 

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

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

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

786 

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

788 """ 

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

790 or None if it can. 

791 

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

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

794 """ 

795 if "*" in self.reviewers: 

796 # Anyone can review, so any require is satisfiable 

797 return None 

798 

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

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

801 author_username.lower() 

802 } 

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

804 author_username 

805 ) 

806 

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

808 if not eligible_reviewers: 

809 return ( 

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

811 ) 

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

813 

814 return None 

815 

816 def ownership_marker(self) -> str: 

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

818 

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

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

821 """ 

822 match self.ownership: 

823 case OwnershipChoices.APPEND: 

824 return "+" 

825 case OwnershipChoices.GLOBAL: 

826 return "*" 

827 

828 return "" 

829 

830 def printed_name(self) -> str: 

831 return self.ownership_marker() + self.name 

832 

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

834 if not isinstance(other, ScopeModel): 

835 return NotImplemented 

836 return self.name == other.name 

837 

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

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

840 

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

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

843 if not patterns: 

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

845 self._code_regex_patterns = patterns 

846 

847 for pattern in patterns: 

848 for match in pattern.finditer(code): 

849 start_index = match.start() 

850 end_index = match.end() 

851 

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

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

854 

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

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

857 

858 yield { 

859 "start_line": start_line, 

860 "start_col": start_col, 

861 "end_line": end_line, 

862 "end_col": end_col, 

863 } 

864 

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

866 if not self.authors: 

867 # No authors specified, so assume it matches 

868 return True 

869 

870 author_username_lower = author_username.lower() 

871 

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

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

874 

875 if author_username_lower in negated_authors: 

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

877 return False 

878 

879 if not authors: 

880 # Negation-only: everyone not negated matches 

881 return True 

882 

883 return author_username_lower in authors 

884 

885 

886# The scope a large-scale change is reviewed as (LargeScaleChangeModel.as_scope). 

887LARGE_SCALE_CHANGE_SCOPE_NAME = "Large-scale change" 

888 

889 

890class LargeScaleChangeModel(BaseModel): 

891 model_config = ConfigDict(extra="forbid") 

892 

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

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

895 

896 # Reviewed as a scope (see as_scope), so each field means what it means 

897 # on a scope. There has to be at least one reviewer, so a PR that turns 

898 # out to be an LSC errors until the config names some. 

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

900 require: int = 1 

901 request: int = 0 

902 reviewers: list[str] = [] 

903 alternates: list[str] = [] 

904 # min_paths: int = 300 

905 # min_lines: int = 3000 

906 labels: list[str] = [] 

907 

908 @field_validator("reviewers", "alternates", mode="after") 

909 @classmethod 

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

911 return _validate_team_refs(values) 

912 

913 def as_scope(self) -> ScopeModel: 

914 """The scope a large-scale change is reviewed as, in place of the 

915 scopes its diff was too large to match. It covers every path, and is 

916 scored, requested and shown like any other scope. 

917 

918 Raises ValueError when no set of reviewers could ever approve it. 

919 Messages must stay under ~130 chars: they become the git-host commit 

920 status description. 

921 """ 

922 # A scope can get by on alternates alone, so this can too. 

923 if not self.reviewers and not self.alternates: 

924 raise ValueError( 

925 "Large-scale change: configuration required (no reviewers defined)" 

926 ) 

927 

928 # ScopeModel rejects this too, but its message wouldn't say where the 

929 # problem is. 

930 if reason := _roster_shortfall( 

931 reviewers=self.reviewers, 

932 alternates=self.alternates, 

933 require=self.require, 

934 ): 

935 raise ValueError(f"Large-scale change: {reason}") 

936 

937 return ScopeModel( 

938 name=LARGE_SCALE_CHANGE_SCOPE_NAME, 

939 paths=["**/*"], 

940 reviewers=self.reviewers, 

941 alternates=self.alternates, 

942 require=self.require, 

943 request=self.request, 

944 labels=self.labels, 

945 ) 

946 

947 

948class ConfigModel(BaseModel): 

949 model_config = ConfigDict(extra="forbid") 

950 

951 # Nothing is technically required 

952 extends: list[str] = [] 

953 template: bool = False 

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

955 large_scale_change: LargeScaleChangeModel | None = None 

956 scopes: list[ScopeModel] = [] 

957 

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

959 @classmethod 

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

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

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

963 return scopes 

964 

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

966 @classmethod 

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

968 for i, path in enumerate(extends): 

969 basename = Path(path).name 

970 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

971 raise ValueError( 

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

973 ) 

974 return extends 

975 

976 def compiled_config( 

977 self, 

978 config_path: Path, 

979 other_configs: ConfigModels, 

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

981 ) -> ConfigModel: 

982 """ 

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

984 

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

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

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

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

989 

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

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

992 alternates, authors, cc, large_scale_change.reviewers/alternates). Keys are 

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

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

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

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

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

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

999 mapping. 

1000 

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

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

1003 """ 

1004 

1005 if teams is not None: 

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

1007 

1008 compiled_data = self._merged_data(config_path, other_configs) 

1009 

1010 # A large-scale change is reviewed as a scope, so it compiles by a 

1011 # scope's rules. 

1012 for scope in compiled_data["scopes"]: 

1013 _compile_review_fields( 

1014 entry=scope, 

1015 label=f"Scope '{scope['name']}'", 

1016 aliases=compiled_data["aliases"], 

1017 teams=teams, 

1018 ) 

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

1020 _compile_review_fields( 

1021 entry=large_scale_change, 

1022 label="large_scale_change", 

1023 aliases=compiled_data["aliases"], 

1024 teams=teams, 

1025 ) 

1026 

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

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

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

1030 # Paths are relative to the config that owns the scope, `/` for 

1031 # repo-root-absolute. 

1032 for entry in compiled_data["scopes"]: 

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

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

1035 entry.pop("_declared_in", None) 

1036 

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

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

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

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

1041 compiled_data["extends"] = [] 

1042 compiled_data["aliases"] = {} 

1043 

1044 return ConfigModel.from_data( 

1045 data=compiled_data, 

1046 path=config_path, 

1047 ) 

1048 

1049 def _merged_data( 

1050 self, 

1051 config_path: Path, 

1052 other_configs: ConfigModels, 

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

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

1055 ) -> dict[str, Any]: 

1056 """ 

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

1058 

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

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

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

1062 

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

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

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

1066 """ 

1067 if _in_progress is None: 

1068 _in_progress = [] 

1069 if _seen is None: 

1070 _seen = set() 

1071 

1072 config_path_str = str(config_path) 

1073 if config_path_str in _in_progress: 

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

1075 config_path_str 

1076 ] 

1077 raise ValueError( 

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

1079 ) 

1080 _in_progress.append(config_path_str) 

1081 

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

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

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

1085 

1086 for extend_path in self.extends: 

1087 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

1088 if resolved_path not in other_configs: 

1089 raise ValueError( 

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

1091 ) 

1092 if resolved_path in _seen: 

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

1094 continue 

1095 

1096 parent_data = other_configs[resolved_path]._merged_data( 

1097 Path(resolved_path), other_configs, _in_progress, _seen 

1098 ) 

1099 inherited_scopes = inherited_scopes + parent_data["scopes"] 

1100 inherited_aliases = inherited_aliases | parent_data["aliases"] 

1101 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

1102 

1103 merged = self.model_dump() 

1104 # Which file each scope is written in (see `declaring_config_path`). 

1105 for entry in merged["scopes"]: 

1106 entry["_declared_in"] = config_path_str 

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

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

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

1110 

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

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

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

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

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

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

1117 if not self.template: 

1118 base_dir = posixpath.dirname(config_path_str) 

1119 for entry in merged["scopes"]: 

1120 entry.setdefault("_anchor_dir", base_dir) 

1121 

1122 _seen.add(config_path_str) 

1123 _in_progress.pop() 

1124 

1125 return merged 

1126 

1127 @classmethod 

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

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

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

1131 

1132 @classmethod 

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

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

1135 

1136 @classmethod 

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

1138 return cls(**data) 

1139 

1140 

1141class _ConfigModelsBase(RootModel): 

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

1143 

1144 root: dict[str, ConfigModel] 

1145 

1146 @classmethod 

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

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

1149 configs = cls(root={}) 

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

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

1152 return configs 

1153 

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

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

1156 

1157 def __bool__(self) -> bool: 

1158 return bool(self.root) 

1159 

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

1161 return self.root[key] 

1162 

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

1164 return key in self.root 

1165 

1166 def __len__(self) -> int: 

1167 return len(self.root) 

1168 

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

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

1171 or None when nothing governs it.""" 

1172 for parent in file_path.parents: 

1173 parent_config_path = str(parent / CONFIG_FILENAME) 

1174 config = self.root.get(parent_config_path) 

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

1176 return parent_config_path 

1177 return None 

1178 

1179 

1180class ConfigModels(_ConfigModelsBase): 

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

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

1183 

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

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

1186 references, across the whole config set. 

1187 

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

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

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

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

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

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

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

1195 check runs entirely. 

1196 """ 

1197 return { 

1198 name 

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

1200 if not config.template 

1201 for scope in config.scopes 

1202 for _, name in scope.unless_refs() 

1203 } 

1204 

1205 @classmethod 

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

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

1208 configs = cls(root={}) 

1209 

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

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

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

1213 

1214 return configs 

1215 

1216 @classmethod 

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

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

1219 configs = cls(root={}) 

1220 

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

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

1223 

1224 return configs 

1225 

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

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

1228 

1229 def declaring_config_path(self, *, config_path: str, scope_name: str) -> str | None: 

1230 """The config in `config_path`'s extends chain that declares a scope, 

1231 or None when nothing in the chain does.""" 

1232 merged = self.root[config_path]._merged_data(Path(config_path), self) 

1233 for entry in merged["scopes"]: 

1234 if entry["name"] == scope_name: 

1235 return entry["_declared_in"] 

1236 return None 

1237 

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

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

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

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

1242 `large_scale_change.reviewers`/`alternates`), plus any refs reachable from those 

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

1244 

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

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

1247 

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

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

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

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

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

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

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

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

1256 earlier. 

1257 """ 

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

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

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

1261 candidate_lists: list[list[str]] = [] 

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

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

1264 for scope in config.scopes: 

1265 for field in USER_LIST_FIELDS: 

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

1267 if config.large_scale_change: 

1268 candidate_lists.append(config.large_scale_change.reviewers) 

1269 candidate_lists.append(config.large_scale_change.alternates) 

1270 if not any( 

1271 _split_team_ref(value) is not None 

1272 for values in candidate_lists 

1273 for value in values 

1274 ): 

1275 return set() 

1276 

1277 refs: set[str] = set() 

1278 

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

1280 for value in values: 

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

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

1283 

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

1285 if config.template: 

1286 continue 

1287 for scope in config.scopes: 

1288 for field in USER_LIST_FIELDS: 

1289 collect_refs(getattr(scope, field)) 

1290 if config.large_scale_change: 

1291 collect_refs(config.large_scale_change.reviewers) 

1292 collect_refs(config.large_scale_change.alternates) 

1293 

1294 return refs 

1295 

1296 def compiled( 

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

1298 ) -> CompiledConfigModels: 

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

1300 

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

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

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

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

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

1306 the set for display). 

1307 

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

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

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

1311 semantics). 

1312 

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

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

1315 double-apply. 

1316 """ 

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

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

1319 if config.template: 

1320 effective[path] = config 

1321 else: 

1322 effective[path] = config.compiled_config( 

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

1324 ) 

1325 

1326 return CompiledConfigModels.from_config_models(effective) 

1327 

1328 

1329class CompiledConfigModels(_ConfigModelsBase): 

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

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

1332 

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

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

1335 config_path = self.closest_config_path(file_path) 

1336 if config_path is None: 

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

1338 return self.root[config_path] 

1339 

1340 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

1342 

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

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

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

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

1347 LSC would read with aliases unexpanded. 

1348 """ 

1349 if CONFIG_FILENAME in self.root and ( 

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

1351 ): 

1352 return lsc 

1353 

1354 return LargeScaleChangeModel() 

1355 

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

1357 """ 

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

1359 for this pull request. 

1360 

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

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

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

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

1365 """ 

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

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

1368 if config.template: 

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

1370 effective[config_path] = config 

1371 continue 

1372 

1373 kept_scopes = [ 

1374 scope 

1375 for scope in config.scopes 

1376 if scope.matches_author(author_username) 

1377 ] 

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

1379 

1380 return CompiledConfigModels.from_config_models(effective)