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

595 statements  

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

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

330# wins is decided in `_resolve_reviewer_alternate_overlap`. 

331_MENTIONED_BY_USERNAME = 3 

332_MENTIONED_BY_ALIAS = 2 

333_MENTIONED_BY_TEAM = 1 

334 

335 

336def _mentions_by_username_or_alias( 

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

338) -> dict[str, int]: 

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

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

341 

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

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

344 entries are subtractions, not mentions. 

345 """ 

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

347 for value in values: 

348 if value.startswith("$"): 

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

350 # negations written inside it. 

351 flattened.extend( 

352 (alias_value, _MENTIONED_BY_ALIAS) 

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

354 ) 

355 else: 

356 flattened.append((value, _MENTIONED_BY_USERNAME)) 

357 

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

359 for value, level in flattened: 

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

361 continue 

362 key = value.lower() 

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

364 return mentions 

365 

366 

367def _resolve_reviewer_alternate_overlap( 

368 *, 

369 entry: dict[str, Any], 

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

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

372) -> None: 

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

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

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

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

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

378 are never requested. 

379 

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

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

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

383 never dropped. 

384 """ 

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

386 return 

387 

388 reviewer_mentions = _mentions_by_username_or_alias( 

389 written["reviewers"], aliases=aliases 

390 ) 

391 alternate_mentions = _mentions_by_username_or_alias( 

392 written["alternates"], aliases=aliases 

393 ) 

394 

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

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

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

398 outranked = { 

399 login 

400 for login, level in alternate_mentions.items() 

401 if login in compiled_alternates 

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

403 } 

404 if not outranked: 

405 return 

406 

407 entry["reviewers"] = [ 

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

409 ] 

410 

411 

412def _compile_review_fields( 

413 *, 

414 entry: dict[str, Any], 

415 label: str, 

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

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

418) -> None: 

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

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

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

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

423 "large_scale_change"). 

424 """ 

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

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

427 written = { 

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

429 } 

430 

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

432 # leading "@" literal. 

433 for field in ( 

434 "paths", 

435 "code", 

436 "authors", 

437 "reviewers", 

438 "alternates", 

439 "cc", 

440 "labels", 

441 ): 

442 if field in entry: 

443 entry[field] = _expand_aliases( 

444 entry[field], 

445 aliases, 

446 teams=teams, 

447 expand_teams=field in USER_LIST_FIELDS, 

448 ) 

449 

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

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

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

453 for field in ROSTER_FIELDS: 

454 if field in entry: 

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

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

457 

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

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

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

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

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

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

464 # ScopeModel validator, because compiled configs stored inside 

465 # old processing results must keep parsing. 

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

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

468 for field, hint in ( 

469 ( 

470 "authors", 

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

472 ), 

473 ( 

474 "alternates", 

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

476 ), 

477 ("cc", "remove it"), 

478 ): 

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

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

481 

482 _validate_review_counts(label, entry) 

483 

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

485 

486 

487def _roster_shortfall( 

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

489) -> str | None: 

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

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

492 all_reviewers = reviewers + alternates 

493 

494 # Skip if wildcard - anyone can review 

495 if "*" in all_reviewers: 

496 return None 

497 

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

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

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

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

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

503 return None 

504 

505 if len(all_reviewers) < require: 

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

507 

508 return None 

509 

510 

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

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

513 

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

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

516 file's directory. 

517 

518 Raises if the reference escapes above the repo root. 

519 """ 

520 if extends_ref.startswith("/"): 

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

522 else: 

523 base_dir = posixpath.dirname(extending_path) 

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

525 

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

527 raise ValueError( 

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

529 ) 

530 

531 return resolved 

532 

533 

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

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

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

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

538 

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

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

541 """ 

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

543 # on characters like U+2028). 

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

545 

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

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

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

549 # with. 

550 block_starts = [ 

551 index 

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

553 if starts_outside 

554 and _table_header(lines[index]) 

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

556 ] 

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

558 

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

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

561 continue 

562 try: 

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

564 except tomllib.TOMLDecodeError: 

565 continue 

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

567 continue 

568 

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

570 while end > start + 1 and ( 

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

572 ): 

573 end -= 1 

574 return start + 1, end 

575 

576 return None 

577 

578 

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

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

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

582 stripped = line.strip() 

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

584 return "" 

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

586 

587 

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

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

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

591 doesn't open anything.""" 

592 starts_outside = [] 

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

594 for line in lines: 

595 starts_outside.append(not open_multiline) 

596 index = 0 

597 while index < len(line): 

598 if open_multiline == '"""': 

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

600 index += 2 

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

602 open_multiline = "" 

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

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

605 index += 1 

606 index += 3 

607 else: 

608 index += 1 

609 elif open_multiline == "'''": 

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

611 if close == -1: 

612 break 

613 open_multiline = "" 

614 index = close + 3 

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

616 index += 1 

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

618 open_multiline = line[index : index + 3] 

619 index += 3 

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

621 break 

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

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

624 index += 1 

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

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

627 index += 1 

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

629 # A literal string has no escapes. 

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

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

632 else: 

633 index += 1 

634 return starts_outside 

635 

636 

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

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

639 

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

641 """ 

642 # TODO paths shouldn't start with / 

643 return glob.globmatch( 

644 path, 

645 patterns, 

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

647 ) 

648 

649 

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

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

652 

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

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

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

656 """ 

657 negate = pattern.startswith("!") 

658 if negate: 

659 pattern = pattern[1:] 

660 

661 if pattern.startswith("/"): 

662 anchored = pattern.lstrip("/") 

663 elif base_dir: 

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

665 else: 

666 anchored = pattern 

667 

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

669 

670 

671class OwnershipChoices(StrEnum): 

672 EMPTY = "" 

673 APPEND = "append" 

674 GLOBAL = "global" 

675 

676 

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

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

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

680# a roster, no declaration needed. 

681BOT_LOGIN_SUFFIX = "[bot]" 

682 

683 

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

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

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

687 

688 

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

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

691 

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

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

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

695 """ 

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

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

698 

699 

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

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

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

703 

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

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

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

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

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

709 """ 

710 for entry in values: 

711 if is_bot_login(entry): 

712 raise ValueError( 

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

714 ) 

715 

716 

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

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

719 seen: set[str] = set() 

720 for value in values: 

721 if value.lower() in seen: 

722 return value 

723 seen.add(value.lower()) 

724 return None 

725 

726 

727class ScopeModel(BaseModel): 

728 model_config = ConfigDict(extra="forbid") 

729 

730 # Required fields 

731 name: str = Field(min_length=1) 

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

733 

734 # Optional fields 

735 

736 # Expanded version of lines could be dict 

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

738 code: list[str] = [] 

739 

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

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

742 # - labels 

743 # - ref 

744 # - statuses 

745 # - dates 

746 # - body 

747 # - title 

748 # - other scopes 

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

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

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

752 authors: list[str] = [] 

753 

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

755 description: str = "" 

756 reviewers: list[str] = [] 

757 alternates: list[str] = [] 

758 cc: list[str] = [] 

759 

760 # Review scoring 

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

762 require: int = 0 

763 author_value: int = 0 

764 

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

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

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

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

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

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

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

772 # 

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

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

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

776 # producer, which can post one combined check. 

777 unless: list[str] = [] 

778 

779 # How scopes are combined 

780 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

781 

782 # Actionable items 

783 request: int = 0 

784 labels: list[str] = [] 

785 instructions: str = "" 

786 

787 # Approval checklist 

788 checklist: Checklist | None = None 

789 

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

791 @classmethod 

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

793 if "," in name: 

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

795 return name 

796 

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

798 @classmethod 

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

800 return _validate_team_refs(values) 

801 

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

803 @classmethod 

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

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

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

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

808 """ 

809 for ref in values: 

810 producer, name = _split_check_ref(ref) 

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

812 raise ValueError( 

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

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

815 ) 

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

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

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

819 if producer == "pullapprove": 

820 raise ValueError( 

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

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

823 ) 

824 return values 

825 

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

827 @classmethod 

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

829 for pattern in code: 

830 try: 

831 parsed = sre_parse.parse(pattern) 

832 except re.error as e: 

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

834 if _has_nested_quantifiers(parsed): 

835 raise ValueError( 

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

837 "which can cause catastrophic backtracking." 

838 ) 

839 return code 

840 

841 @model_validator(mode="after") 

842 def validate_reviewers_for_require(self) -> ScopeModel: 

843 if reason := _roster_shortfall( 

844 reviewers=self.reviewers, 

845 alternates=self.alternates, 

846 require=self.require, 

847 ): 

848 raise ValueError(reason) 

849 return self 

850 

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

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

853 

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

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

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

857 its backups. 

858 

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

860 """ 

861 if "*" in self.reviewers: 

862 return True 

863 username_lower = username.lower() 

864 return any( 

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

866 ) 

867 

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

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

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

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

872 return self.author_value 

873 return 0 

874 

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

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

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

878 

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

880 """ 

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

882 or None if it can. 

883 

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

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

886 """ 

887 if "*" in self.reviewers: 

888 # Anyone can review, so any require is satisfiable 

889 return None 

890 

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

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

893 author_username.lower() 

894 } 

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

896 author_username 

897 ) 

898 

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

900 if not eligible_reviewers: 

901 return ( 

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

903 ) 

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

905 

906 return None 

907 

908 def ownership_marker(self) -> str: 

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

910 

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

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

913 """ 

914 match self.ownership: 

915 case OwnershipChoices.APPEND: 

916 return "+" 

917 case OwnershipChoices.GLOBAL: 

918 return "*" 

919 

920 return "" 

921 

922 def printed_name(self) -> str: 

923 return self.ownership_marker() + self.name 

924 

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

926 if not isinstance(other, ScopeModel): 

927 return NotImplemented 

928 return self.name == other.name 

929 

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

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

932 

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

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

935 if not patterns: 

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

937 self._code_regex_patterns = patterns 

938 

939 for pattern in patterns: 

940 for match in pattern.finditer(code): 

941 start_index = match.start() 

942 end_index = match.end() 

943 

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

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

946 

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

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

949 

950 yield { 

951 "start_line": start_line, 

952 "start_col": start_col, 

953 "end_line": end_line, 

954 "end_col": end_col, 

955 } 

956 

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

958 if not self.authors: 

959 # No authors specified, so assume it matches 

960 return True 

961 

962 author_username_lower = author_username.lower() 

963 

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

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

966 

967 if author_username_lower in negated_authors: 

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

969 return False 

970 

971 if not authors: 

972 # Negation-only: everyone not negated matches 

973 return True 

974 

975 return author_username_lower in authors 

976 

977 

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

979LARGE_SCALE_CHANGE_SCOPE_NAME = "Large-scale change" 

980 

981 

982class LargeScaleChangeModel(BaseModel): 

983 model_config = ConfigDict(extra="forbid") 

984 

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

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

987 

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

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

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

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

992 require: int = 1 

993 request: int = 0 

994 reviewers: list[str] = [] 

995 alternates: list[str] = [] 

996 # min_paths: int = 300 

997 # min_lines: int = 3000 

998 labels: list[str] = [] 

999 

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

1001 @classmethod 

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

1003 return _validate_team_refs(values) 

1004 

1005 def as_scope(self) -> ScopeModel: 

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

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

1008 scored, requested and shown like any other scope. 

1009 

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

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

1012 status description. 

1013 """ 

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

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

1016 raise ValueError( 

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

1018 ) 

1019 

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

1021 # problem is. 

1022 if reason := _roster_shortfall( 

1023 reviewers=self.reviewers, 

1024 alternates=self.alternates, 

1025 require=self.require, 

1026 ): 

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

1028 

1029 return ScopeModel( 

1030 name=LARGE_SCALE_CHANGE_SCOPE_NAME, 

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

1032 reviewers=self.reviewers, 

1033 alternates=self.alternates, 

1034 require=self.require, 

1035 request=self.request, 

1036 labels=self.labels, 

1037 ) 

1038 

1039 

1040class ConfigModel(BaseModel): 

1041 model_config = ConfigDict(extra="forbid") 

1042 

1043 # Nothing is technically required 

1044 extends: list[str] = [] 

1045 template: bool = False 

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

1047 large_scale_change: LargeScaleChangeModel | None = None 

1048 scopes: list[ScopeModel] = [] 

1049 

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

1051 @classmethod 

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

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

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

1055 return scopes 

1056 

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

1058 @classmethod 

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

1060 for i, path in enumerate(extends): 

1061 basename = Path(path).name 

1062 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

1063 raise ValueError( 

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

1065 ) 

1066 return extends 

1067 

1068 def compiled_config( 

1069 self, 

1070 config_path: Path, 

1071 other_configs: ConfigModels, 

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

1073 ) -> ConfigModel: 

1074 """ 

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

1076 

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

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

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

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

1081 

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

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

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

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

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

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

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

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

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

1091 mapping. 

1092 

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

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

1095 """ 

1096 

1097 if teams is not None: 

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

1099 

1100 compiled_data = self._merged_data(config_path, other_configs) 

1101 

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

1103 # scope's rules. 

1104 for scope in compiled_data["scopes"]: 

1105 _compile_review_fields( 

1106 entry=scope, 

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

1108 aliases=compiled_data["aliases"], 

1109 teams=teams, 

1110 ) 

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

1112 _compile_review_fields( 

1113 entry=large_scale_change, 

1114 label="large_scale_change", 

1115 aliases=compiled_data["aliases"], 

1116 teams=teams, 

1117 ) 

1118 

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

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

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

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

1123 # repo-root-absolute. 

1124 for entry in compiled_data["scopes"]: 

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

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

1127 entry.pop("_declared_in", None) 

1128 

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

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

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

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

1133 compiled_data["extends"] = [] 

1134 compiled_data["aliases"] = {} 

1135 

1136 return ConfigModel.from_data( 

1137 data=compiled_data, 

1138 path=config_path, 

1139 ) 

1140 

1141 def _merged_data( 

1142 self, 

1143 config_path: Path, 

1144 other_configs: ConfigModels, 

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

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

1147 ) -> dict[str, Any]: 

1148 """ 

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

1150 

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

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

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

1154 

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

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

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

1158 """ 

1159 if _in_progress is None: 

1160 _in_progress = [] 

1161 if _seen is None: 

1162 _seen = set() 

1163 

1164 config_path_str = str(config_path) 

1165 if config_path_str in _in_progress: 

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

1167 config_path_str 

1168 ] 

1169 raise ValueError( 

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

1171 ) 

1172 _in_progress.append(config_path_str) 

1173 

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

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

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

1177 

1178 for extend_path in self.extends: 

1179 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

1180 if resolved_path not in other_configs: 

1181 raise ValueError( 

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

1183 ) 

1184 if resolved_path in _seen: 

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

1186 continue 

1187 

1188 parent_data = other_configs[resolved_path]._merged_data( 

1189 Path(resolved_path), other_configs, _in_progress, _seen 

1190 ) 

1191 inherited_scopes = inherited_scopes + parent_data["scopes"] 

1192 inherited_aliases = inherited_aliases | parent_data["aliases"] 

1193 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

1194 

1195 merged = self.model_dump() 

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

1197 for entry in merged["scopes"]: 

1198 entry["_declared_in"] = config_path_str 

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

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

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

1202 

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

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

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

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

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

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

1209 if not self.template: 

1210 base_dir = posixpath.dirname(config_path_str) 

1211 for entry in merged["scopes"]: 

1212 entry.setdefault("_anchor_dir", base_dir) 

1213 

1214 _seen.add(config_path_str) 

1215 _in_progress.pop() 

1216 

1217 return merged 

1218 

1219 @classmethod 

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

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

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

1223 

1224 @classmethod 

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

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

1227 

1228 @classmethod 

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

1230 return cls(**data) 

1231 

1232 

1233class _ConfigModelsBase(RootModel): 

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

1235 

1236 root: dict[str, ConfigModel] 

1237 

1238 @classmethod 

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

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

1241 configs = cls(root={}) 

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

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

1244 return configs 

1245 

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

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

1248 

1249 def __bool__(self) -> bool: 

1250 return bool(self.root) 

1251 

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

1253 return self.root[key] 

1254 

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

1256 return key in self.root 

1257 

1258 def __len__(self) -> int: 

1259 return len(self.root) 

1260 

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

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

1263 or None when nothing governs it.""" 

1264 for parent in file_path.parents: 

1265 parent_config_path = str(parent / CONFIG_FILENAME) 

1266 config = self.root.get(parent_config_path) 

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

1268 return parent_config_path 

1269 return None 

1270 

1271 

1272class ConfigModels(_ConfigModelsBase): 

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

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

1275 

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

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

1278 references, across the whole config set. 

1279 

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

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

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

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

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

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

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

1287 check runs entirely. 

1288 """ 

1289 return { 

1290 name 

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

1292 if not config.template 

1293 for scope in config.scopes 

1294 for _, name in scope.unless_refs() 

1295 } 

1296 

1297 @classmethod 

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

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

1300 configs = cls(root={}) 

1301 

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

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

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

1305 

1306 return configs 

1307 

1308 @classmethod 

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

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

1311 configs = cls(root={}) 

1312 

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

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

1315 

1316 return configs 

1317 

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

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

1320 

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

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

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

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

1325 for entry in merged["scopes"]: 

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

1327 return entry["_declared_in"] 

1328 return None 

1329 

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

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

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

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

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

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

1336 

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

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

1339 

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

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

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

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

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

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

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

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

1348 earlier. 

1349 """ 

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

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

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

1353 candidate_lists: list[list[str]] = [] 

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

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

1356 for scope in config.scopes: 

1357 for field in USER_LIST_FIELDS: 

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

1359 if config.large_scale_change: 

1360 candidate_lists.append(config.large_scale_change.reviewers) 

1361 candidate_lists.append(config.large_scale_change.alternates) 

1362 if not any( 

1363 _split_team_ref(value) is not None 

1364 for values in candidate_lists 

1365 for value in values 

1366 ): 

1367 return set() 

1368 

1369 refs: set[str] = set() 

1370 

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

1372 for value in values: 

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

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

1375 

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

1377 if config.template: 

1378 continue 

1379 for scope in config.scopes: 

1380 for field in USER_LIST_FIELDS: 

1381 collect_refs(getattr(scope, field)) 

1382 if config.large_scale_change: 

1383 collect_refs(config.large_scale_change.reviewers) 

1384 collect_refs(config.large_scale_change.alternates) 

1385 

1386 return refs 

1387 

1388 def compiled( 

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

1390 ) -> CompiledConfigModels: 

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

1392 

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

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

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

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

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

1398 the set for display). 

1399 

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

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

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

1403 semantics). 

1404 

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

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

1407 double-apply. 

1408 """ 

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

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

1411 if config.template: 

1412 effective[path] = config 

1413 else: 

1414 effective[path] = config.compiled_config( 

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

1416 ) 

1417 

1418 return CompiledConfigModels.from_config_models(effective) 

1419 

1420 

1421class CompiledConfigModels(_ConfigModelsBase): 

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

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

1424 

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

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

1427 config_path = self.closest_config_path(file_path) 

1428 if config_path is None: 

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

1430 return self.root[config_path] 

1431 

1432 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

1434 

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

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

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

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

1439 LSC would read with aliases unexpanded. 

1440 """ 

1441 if CONFIG_FILENAME in self.root and ( 

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

1443 ): 

1444 return lsc 

1445 

1446 return LargeScaleChangeModel() 

1447 

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

1449 """ 

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

1451 for this pull request. 

1452 

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

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

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

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

1457 """ 

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

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

1460 if config.template: 

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

1462 effective[config_path] = config 

1463 continue 

1464 

1465 kept_scopes = [ 

1466 scope 

1467 for scope in config.scopes 

1468 if scope.matches_author(author_username) 

1469 ] 

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

1471 

1472 return CompiledConfigModels.from_config_models(effective)