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

600 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-21 11:55 -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 _validate_global_require(*, label: str, scope: dict[str, Any]) -> None: 

330 """Global scopes need require >= 1: a satisfied global scope passes every 

331 file it matches, and require = 0 (the default) is satisfied before anyone 

332 reviews. Compile-time, like _validate_review_counts, so compiled configs 

333 stored inside old processing results keep parsing. 

334 """ 

335 if ( 

336 scope.get("ownership") == OwnershipChoices.GLOBAL 

337 and scope.get("require", 0) < 1 

338 ): 

339 raise ValueError( 

340 f"{label}: global scopes need require >= 1 " 

341 "(0 would pass every file it matches)" 

342 ) 

343 

344 

345# How directly a config points at a person, most specific first. Which one 

346# wins is decided in `_resolve_reviewer_alternate_overlap`. 

347_MENTIONED_BY_USERNAME = 3 

348_MENTIONED_BY_ALIAS = 2 

349_MENTIONED_BY_TEAM = 1 

350 

351 

352def _mentions_by_username_or_alias( 

353 values: list[str], *, aliases: dict[str, list[str]] 

354) -> dict[str, int]: 

355 """Each login a user list, as written, mentions by username or through an 

356 alias, keyed by lowercased login, with its most specific mention. 

357 

358 Team refs are skipped, whether written in the field or inside an alias: 

359 a team mention is the lowest level, so it never needs looking up. Negated 

360 entries are subtractions, not mentions. 

361 """ 

362 flattened: list[tuple[str, int]] = [] 

363 for value in values: 

364 if value.startswith("$"): 

365 # An alias flattens to usernames, plus any team refs and 

366 # negations written inside it. 

367 flattened.extend( 

368 (alias_value, _MENTIONED_BY_ALIAS) 

369 for alias_value in _expand_aliases([value], aliases) 

370 ) 

371 else: 

372 flattened.append((value, _MENTIONED_BY_USERNAME)) 

373 

374 mentions: dict[str, int] = {} 

375 for value, level in flattened: 

376 if value.startswith("!") or _split_team_ref(value) is not None: 

377 continue 

378 key = value.lower() 

379 mentions[key] = max(mentions.get(key, 0), level) 

380 return mentions 

381 

382 

383def _resolve_reviewer_alternate_overlap( 

384 *, 

385 entry: dict[str, Any], 

386 written: dict[str, list[str]], 

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

388) -> None: 

389 """Someone in both `reviewers` and `alternates` is requested only if 

390 `reviewers` mentions them at least as specifically as `alternates` does: 

391 a username beats an alias, an alias beats a team, and a tie leaves them 

392 a reviewer. Whoever `alternates` outranks is dropped from the compiled 

393 `reviewers`, so for this scope they are an alternate: they count, but 

394 are never requested. 

395 

396 The compiled lists decide who is in both. `written`, the two fields 

397 before expansion, decides how specifically each one mentions them. An 

398 unexpanded team ref left by an offline compile is not a login, so it is 

399 never dropped. 

400 """ 

401 if not entry.get("reviewers") or not entry.get("alternates"): 

402 return 

403 

404 reviewer_mentions = _mentions_by_username_or_alias( 

405 written["reviewers"], aliases=aliases 

406 ) 

407 alternate_mentions = _mentions_by_username_or_alias( 

408 written["alternates"], aliases=aliases 

409 ) 

410 

411 # A reviewer with no username or alias mention got there through a team. 

412 # Only a username or alias mention in `alternates` can outrank that. 

413 compiled_alternates = {login.lower() for login in entry["alternates"]} 

414 outranked = { 

415 login 

416 for login, level in alternate_mentions.items() 

417 if login in compiled_alternates 

418 and level > reviewer_mentions.get(login, _MENTIONED_BY_TEAM) 

419 } 

420 if not outranked: 

421 return 

422 

423 entry["reviewers"] = [ 

424 login for login in entry["reviewers"] if login.lower() not in outranked 

425 ] 

426 

427 

428def _compile_review_fields( 

429 *, 

430 entry: dict[str, Any], 

431 label: str, 

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

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

434) -> None: 

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

436 aliases, apply "!" subtraction, reject what can never work, and settle 

437 who is both a reviewer and an alternate. Only the fields the entry has 

438 are touched. `label` names it in error messages ("Scope 'app'", 

439 "large_scale_change"). 

440 """ 

441 # Reviewers and alternates as written: once expanded, a list no longer 

442 # says whether a login was typed in or came from an alias or a team. 

443 written = { 

444 field: entry[field] for field in ("reviewers", "alternates") if field in entry 

445 } 

446 

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

448 # leading "@" literal. 

449 for field in ( 

450 "paths", 

451 "code", 

452 "authors", 

453 "reviewers", 

454 "alternates", 

455 "cc", 

456 "labels", 

457 ): 

458 if field in entry: 

459 entry[field] = _expand_aliases( 

460 entry[field], 

461 aliases, 

462 teams=teams, 

463 expand_teams=field in USER_LIST_FIELDS, 

464 ) 

465 

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

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

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

469 for field in ROSTER_FIELDS: 

470 if field in entry: 

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

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

473 

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

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

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

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

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

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

480 # ScopeModel validator, because compiled configs stored inside 

481 # old processing results must keep parsing. 

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

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

484 for field, hint in ( 

485 ( 

486 "authors", 

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

488 ), 

489 ( 

490 "alternates", 

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

492 ), 

493 ("cc", "remove it"), 

494 ): 

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

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

497 

498 _validate_review_counts(label, entry) 

499 

500 _resolve_reviewer_alternate_overlap(entry=entry, written=written, aliases=aliases) 

501 

502 

503def _roster_shortfall( 

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

505) -> str | None: 

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

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

508 all_reviewers = reviewers + alternates 

509 

510 # Skip if wildcard - anyone can review 

511 if "*" in all_reviewers: 

512 return None 

513 

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

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

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

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

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

519 return None 

520 

521 if len(all_reviewers) < require: 

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

523 

524 return None 

525 

526 

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

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

529 

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

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

532 file's directory. 

533 

534 Raises if the reference escapes above the repo root. 

535 """ 

536 if extends_ref.startswith("/"): 

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

538 else: 

539 base_dir = posixpath.dirname(extending_path) 

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

541 

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

543 raise ValueError( 

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

545 ) 

546 

547 return resolved 

548 

549 

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

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

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

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

554 

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

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

557 """ 

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

559 # on characters like U+2028). 

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

561 

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

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

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

565 # with. 

566 block_starts = [ 

567 index 

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

569 if starts_outside 

570 and _table_header(lines[index]) 

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

572 ] 

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

574 

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

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

577 continue 

578 try: 

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

580 except tomllib.TOMLDecodeError: 

581 continue 

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

583 continue 

584 

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

586 while end > start + 1 and ( 

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

588 ): 

589 end -= 1 

590 return start + 1, end 

591 

592 return None 

593 

594 

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

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

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

598 stripped = line.strip() 

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

600 return "" 

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

602 

603 

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

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

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

607 doesn't open anything.""" 

608 starts_outside = [] 

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

610 for line in lines: 

611 starts_outside.append(not open_multiline) 

612 index = 0 

613 while index < len(line): 

614 if open_multiline == '"""': 

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

616 index += 2 

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

618 open_multiline = "" 

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

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

621 index += 1 

622 index += 3 

623 else: 

624 index += 1 

625 elif open_multiline == "'''": 

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

627 if close == -1: 

628 break 

629 open_multiline = "" 

630 index = close + 3 

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

632 index += 1 

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

634 open_multiline = line[index : index + 3] 

635 index += 3 

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

637 break 

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

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

640 index += 1 

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

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

643 index += 1 

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

645 # A literal string has no escapes. 

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

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

648 else: 

649 index += 1 

650 return starts_outside 

651 

652 

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

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

655 

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

657 """ 

658 # TODO paths shouldn't start with / 

659 return glob.globmatch( 

660 path, 

661 patterns, 

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

663 ) 

664 

665 

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

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

668 

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

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

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

672 """ 

673 negate = pattern.startswith("!") 

674 if negate: 

675 pattern = pattern[1:] 

676 

677 if pattern.startswith("/"): 

678 anchored = pattern.lstrip("/") 

679 elif base_dir: 

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

681 else: 

682 anchored = pattern 

683 

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

685 

686 

687class OwnershipChoices(StrEnum): 

688 EMPTY = "" 

689 APPEND = "append" 

690 GLOBAL = "global" 

691 

692 

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

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

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

696# a roster, no declaration needed. 

697BOT_LOGIN_SUFFIX = "[bot]" 

698 

699 

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

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

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

703 

704 

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

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

707 

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

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

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

711 """ 

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

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

714 

715 

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

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

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

719 

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

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

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

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

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

725 """ 

726 for entry in values: 

727 if is_bot_login(entry): 

728 raise ValueError( 

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

730 ) 

731 

732 

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

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

735 seen: set[str] = set() 

736 for value in values: 

737 if value.lower() in seen: 

738 return value 

739 seen.add(value.lower()) 

740 return None 

741 

742 

743class ScopeModel(BaseModel): 

744 model_config = ConfigDict(extra="forbid") 

745 

746 # Required fields 

747 name: str = Field(min_length=1) 

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

749 

750 # Optional fields 

751 

752 # Expanded version of lines could be dict 

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

754 code: list[str] = [] 

755 

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

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

758 # - labels 

759 # - ref 

760 # - statuses 

761 # - dates 

762 # - body 

763 # - title 

764 # - other scopes 

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

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

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

768 authors: list[str] = [] 

769 

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

771 description: str = "" 

772 reviewers: list[str] = [] 

773 alternates: list[str] = [] 

774 cc: list[str] = [] 

775 

776 # Review scoring 

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

778 require: int = 0 

779 author_value: int = 0 

780 

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

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

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

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

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

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

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

788 # 

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

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

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

792 # producer, which can post one combined check. 

793 unless: list[str] = [] 

794 

795 # How scopes are combined 

796 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

797 

798 # Actionable items 

799 request: int = 0 

800 labels: list[str] = [] 

801 instructions: str = "" 

802 

803 # Approval checklist 

804 checklist: Checklist | None = None 

805 

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

807 @classmethod 

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

809 if "," in name: 

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

811 return name 

812 

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

814 @classmethod 

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

816 return _validate_team_refs(values) 

817 

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

819 @classmethod 

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

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

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

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

824 """ 

825 for ref in values: 

826 producer, name = _split_check_ref(ref) 

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

828 raise ValueError( 

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

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

831 ) 

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

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

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

835 if producer == "pullapprove": 

836 raise ValueError( 

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

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

839 ) 

840 return values 

841 

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

843 @classmethod 

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

845 for pattern in code: 

846 try: 

847 parsed = sre_parse.parse(pattern) 

848 except re.error as e: 

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

850 if _has_nested_quantifiers(parsed): 

851 raise ValueError( 

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

853 "which can cause catastrophic backtracking." 

854 ) 

855 return code 

856 

857 @model_validator(mode="after") 

858 def validate_reviewers_for_require(self) -> ScopeModel: 

859 if reason := _roster_shortfall( 

860 reviewers=self.reviewers, 

861 alternates=self.alternates, 

862 require=self.require, 

863 ): 

864 raise ValueError(reason) 

865 return self 

866 

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

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

869 

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

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

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

873 its backups. 

874 

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

876 """ 

877 if "*" in self.reviewers: 

878 return True 

879 username_lower = username.lower() 

880 return any( 

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

882 ) 

883 

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

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

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

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

888 return self.author_value 

889 return 0 

890 

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

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

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

894 

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

896 """ 

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

898 or None if it can. 

899 

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

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

902 """ 

903 if "*" in self.reviewers: 

904 # Anyone can review, so any require is satisfiable 

905 return None 

906 

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

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

909 author_username.lower() 

910 } 

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

912 author_username 

913 ) 

914 

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

916 if not eligible_reviewers: 

917 return ( 

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

919 ) 

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

921 

922 return None 

923 

924 def ownership_marker(self) -> str: 

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

926 

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

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

929 """ 

930 match self.ownership: 

931 case OwnershipChoices.APPEND: 

932 return "+" 

933 case OwnershipChoices.GLOBAL: 

934 return "*" 

935 

936 return "" 

937 

938 def printed_name(self) -> str: 

939 return self.ownership_marker() + self.name 

940 

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

942 if not isinstance(other, ScopeModel): 

943 return NotImplemented 

944 return self.name == other.name 

945 

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

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

948 

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

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

951 if not patterns: 

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

953 self._code_regex_patterns = patterns 

954 

955 for pattern in patterns: 

956 for match in pattern.finditer(code): 

957 start_index = match.start() 

958 end_index = match.end() 

959 

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

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

962 

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

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

965 

966 yield { 

967 "start_line": start_line, 

968 "start_col": start_col, 

969 "end_line": end_line, 

970 "end_col": end_col, 

971 } 

972 

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

974 if not self.authors: 

975 # No authors specified, so assume it matches 

976 return True 

977 

978 author_username_lower = author_username.lower() 

979 

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

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

982 

983 if author_username_lower in negated_authors: 

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

985 return False 

986 

987 if not authors: 

988 # Negation-only: everyone not negated matches 

989 return True 

990 

991 return author_username_lower in authors 

992 

993 

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

995LARGE_SCALE_CHANGE_SCOPE_NAME = "Large-scale change" 

996 

997 

998class LargeScaleChangeModel(BaseModel): 

999 model_config = ConfigDict(extra="forbid") 

1000 

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

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

1003 

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

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

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

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

1008 require: int = 1 

1009 request: int = 0 

1010 reviewers: list[str] = [] 

1011 alternates: list[str] = [] 

1012 # min_paths: int = 300 

1013 # min_lines: int = 3000 

1014 labels: list[str] = [] 

1015 

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

1017 @classmethod 

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

1019 return _validate_team_refs(values) 

1020 

1021 def as_scope(self) -> ScopeModel: 

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

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

1024 scored, requested and shown like any other scope. 

1025 

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

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

1028 status description. 

1029 """ 

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

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

1032 raise ValueError( 

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

1034 ) 

1035 

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

1037 # problem is. 

1038 if reason := _roster_shortfall( 

1039 reviewers=self.reviewers, 

1040 alternates=self.alternates, 

1041 require=self.require, 

1042 ): 

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

1044 

1045 return ScopeModel( 

1046 name=LARGE_SCALE_CHANGE_SCOPE_NAME, 

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

1048 reviewers=self.reviewers, 

1049 alternates=self.alternates, 

1050 require=self.require, 

1051 request=self.request, 

1052 labels=self.labels, 

1053 ) 

1054 

1055 

1056class ConfigModel(BaseModel): 

1057 model_config = ConfigDict(extra="forbid") 

1058 

1059 # Nothing is technically required 

1060 extends: list[str] = [] 

1061 template: bool = False 

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

1063 large_scale_change: LargeScaleChangeModel | None = None 

1064 scopes: list[ScopeModel] = [] 

1065 

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

1067 @classmethod 

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

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

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

1071 return scopes 

1072 

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

1074 @classmethod 

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

1076 for i, path in enumerate(extends): 

1077 basename = Path(path).name 

1078 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

1079 raise ValueError( 

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

1081 ) 

1082 return extends 

1083 

1084 def compiled_config( 

1085 self, 

1086 config_path: Path, 

1087 other_configs: ConfigModels, 

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

1089 ) -> ConfigModel: 

1090 """ 

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

1092 

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

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

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

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

1097 

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

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

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

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

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

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

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

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

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

1107 mapping. 

1108 

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

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

1111 """ 

1112 

1113 if teams is not None: 

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

1115 

1116 compiled_data = self._merged_data(config_path, other_configs) 

1117 

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

1119 # scope's rules. 

1120 for scope in compiled_data["scopes"]: 

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

1122 _compile_review_fields( 

1123 entry=scope, 

1124 label=label, 

1125 aliases=compiled_data["aliases"], 

1126 teams=teams, 

1127 ) 

1128 _validate_global_require(label=label, scope=scope) 

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

1130 _compile_review_fields( 

1131 entry=large_scale_change, 

1132 label="large_scale_change", 

1133 aliases=compiled_data["aliases"], 

1134 teams=teams, 

1135 ) 

1136 

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

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

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

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

1141 # repo-root-absolute. 

1142 for entry in compiled_data["scopes"]: 

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

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

1145 entry.pop("_declared_in", None) 

1146 

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

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

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

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

1151 compiled_data["extends"] = [] 

1152 compiled_data["aliases"] = {} 

1153 

1154 return ConfigModel.from_data( 

1155 data=compiled_data, 

1156 path=config_path, 

1157 ) 

1158 

1159 def _merged_data( 

1160 self, 

1161 config_path: Path, 

1162 other_configs: ConfigModels, 

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

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

1165 ) -> dict[str, Any]: 

1166 """ 

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

1168 

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

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

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

1172 

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

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

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

1176 """ 

1177 if _in_progress is None: 

1178 _in_progress = [] 

1179 if _seen is None: 

1180 _seen = set() 

1181 

1182 config_path_str = str(config_path) 

1183 if config_path_str in _in_progress: 

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

1185 config_path_str 

1186 ] 

1187 raise ValueError( 

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

1189 ) 

1190 _in_progress.append(config_path_str) 

1191 

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

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

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

1195 

1196 for extend_path in self.extends: 

1197 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

1198 if resolved_path not in other_configs: 

1199 raise ValueError( 

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

1201 ) 

1202 if resolved_path in _seen: 

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

1204 continue 

1205 

1206 parent_data = other_configs[resolved_path]._merged_data( 

1207 Path(resolved_path), other_configs, _in_progress, _seen 

1208 ) 

1209 inherited_scopes = inherited_scopes + parent_data["scopes"] 

1210 inherited_aliases = inherited_aliases | parent_data["aliases"] 

1211 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

1212 

1213 merged = self.model_dump() 

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

1215 for entry in merged["scopes"]: 

1216 entry["_declared_in"] = config_path_str 

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

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

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

1220 

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

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

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

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

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

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

1227 if not self.template: 

1228 base_dir = posixpath.dirname(config_path_str) 

1229 for entry in merged["scopes"]: 

1230 entry.setdefault("_anchor_dir", base_dir) 

1231 

1232 _seen.add(config_path_str) 

1233 _in_progress.pop() 

1234 

1235 return merged 

1236 

1237 @classmethod 

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

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

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

1241 

1242 @classmethod 

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

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

1245 

1246 @classmethod 

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

1248 return cls(**data) 

1249 

1250 

1251class _ConfigModelsBase(RootModel): 

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

1253 

1254 root: dict[str, ConfigModel] 

1255 

1256 @classmethod 

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

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

1259 configs = cls(root={}) 

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

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

1262 return configs 

1263 

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

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

1266 

1267 def __bool__(self) -> bool: 

1268 return bool(self.root) 

1269 

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

1271 return self.root[key] 

1272 

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

1274 return key in self.root 

1275 

1276 def __len__(self) -> int: 

1277 return len(self.root) 

1278 

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

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

1281 or None when nothing governs it.""" 

1282 for parent in file_path.parents: 

1283 parent_config_path = str(parent / CONFIG_FILENAME) 

1284 config = self.root.get(parent_config_path) 

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

1286 return parent_config_path 

1287 return None 

1288 

1289 

1290class ConfigModels(_ConfigModelsBase): 

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

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

1293 

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

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

1296 references, across the whole config set. 

1297 

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

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

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

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

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

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

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

1305 check runs entirely. 

1306 """ 

1307 return { 

1308 name 

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

1310 if not config.template 

1311 for scope in config.scopes 

1312 for _, name in scope.unless_refs() 

1313 } 

1314 

1315 @classmethod 

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

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

1318 configs = cls(root={}) 

1319 

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

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

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

1323 

1324 return configs 

1325 

1326 @classmethod 

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

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

1329 configs = cls(root={}) 

1330 

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

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

1333 

1334 return configs 

1335 

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

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

1338 

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

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

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

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

1343 for entry in merged["scopes"]: 

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

1345 return entry["_declared_in"] 

1346 return None 

1347 

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

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

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

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

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

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

1354 

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

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

1357 

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

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

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

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

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

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

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

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

1366 earlier. 

1367 """ 

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

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

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

1371 candidate_lists: list[list[str]] = [] 

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

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

1374 for scope in config.scopes: 

1375 for field in USER_LIST_FIELDS: 

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

1377 if config.large_scale_change: 

1378 candidate_lists.append(config.large_scale_change.reviewers) 

1379 candidate_lists.append(config.large_scale_change.alternates) 

1380 if not any( 

1381 _split_team_ref(value) is not None 

1382 for values in candidate_lists 

1383 for value in values 

1384 ): 

1385 return set() 

1386 

1387 refs: set[str] = set() 

1388 

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

1390 for value in values: 

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

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

1393 

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

1395 if config.template: 

1396 continue 

1397 for scope in config.scopes: 

1398 for field in USER_LIST_FIELDS: 

1399 collect_refs(getattr(scope, field)) 

1400 if config.large_scale_change: 

1401 collect_refs(config.large_scale_change.reviewers) 

1402 collect_refs(config.large_scale_change.alternates) 

1403 

1404 return refs 

1405 

1406 def compiled( 

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

1408 ) -> CompiledConfigModels: 

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

1410 

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

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

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

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

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

1416 the set for display). 

1417 

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

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

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

1421 semantics). 

1422 

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

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

1425 double-apply. 

1426 """ 

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

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

1429 if config.template: 

1430 effective[path] = config 

1431 else: 

1432 effective[path] = config.compiled_config( 

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

1434 ) 

1435 

1436 return CompiledConfigModels.from_config_models(effective) 

1437 

1438 

1439class CompiledConfigModels(_ConfigModelsBase): 

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

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

1442 

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

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

1445 config_path = self.closest_config_path(file_path) 

1446 if config_path is None: 

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

1448 return self.root[config_path] 

1449 

1450 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

1452 

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

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

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

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

1457 LSC would read with aliases unexpanded. 

1458 """ 

1459 if CONFIG_FILENAME in self.root and ( 

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

1461 ): 

1462 return lsc 

1463 

1464 return LargeScaleChangeModel() 

1465 

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

1467 """ 

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

1469 for this pull request. 

1470 

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

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

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

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

1475 """ 

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

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

1478 if config.template: 

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

1480 effective[config_path] = config 

1481 continue 

1482 

1483 kept_scopes = [ 

1484 scope 

1485 for scope in config.scopes 

1486 if scope.matches_author(author_username) 

1487 ] 

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

1489 

1490 return CompiledConfigModels.from_config_models(effective)