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

349 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 16:00 -0500

1from __future__ import annotations 

2 

3import posixpath 

4import re 

5import tomllib 

6import warnings 

7 

8with warnings.catch_warnings(): 

9 warnings.simplefilter("ignore", DeprecationWarning) 

10 import sre_parse 

11from collections.abc import Generator 

12from enum import StrEnum 

13from pathlib import Path 

14from typing import Any, Self 

15 

16from pydantic import ( 

17 BaseModel, 

18 ConfigDict, 

19 Field, 

20 RootModel, 

21 field_validator, 

22 model_validator, 

23) 

24from wcmatch import glob 

25 

26from .checklists import Checklist 

27 

28CONFIG_FILENAME_PREFIX = "CODEREVIEW" 

29 

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

31 

32 

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

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

35 for op, av in data: 

36 if op in _REPEAT_OPS: 

37 if _contains_quantifier(av[2]): 

38 return True 

39 elif op == sre_parse.SUBPATTERN: 

40 if _has_nested_quantifiers(av[-1]): 

41 return True 

42 elif op == sre_parse.BRANCH: 

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

44 return True 

45 return False 

46 

47 

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

49 for op, av in data: 

50 if op in _REPEAT_OPS: 

51 return True 

52 elif op == sre_parse.SUBPATTERN: 

53 if _contains_quantifier(av[-1]): 

54 return True 

55 elif op == sre_parse.BRANCH: 

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

57 return True 

58 return False 

59 

60 

61CONFIG_FILENAME = "CODEREVIEW.toml" 

62 

63 

64def _matches_branches(branches: list[str], base_branch: str, head_branch: str) -> bool: 

65 """Check if the given base/head branches match any of the branch patterns.""" 

66 if not branches: 

67 return True 

68 

69 for pattern in branches: 

70 splitter = "..." if "..." in pattern else ".." 

71 parts = pattern.split(splitter, 1) 

72 

73 base_pattern = parts[0] 

74 head_pattern = parts[1] if len(parts) > 1 else None 

75 

76 base_match = glob.globmatch(base_branch, base_pattern) if base_pattern else True 

77 head_match = glob.globmatch(head_branch, head_pattern) if head_pattern else True 

78 

79 if base_match and head_match: 

80 return True 

81 

82 return False 

83 

84 

85def _expand_aliases( 

86 values: list[str], 

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

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

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

90) -> list[str]: 

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

92 if _seen is None: 

93 _seen = set() 

94 if _path is None: 

95 _path = [] 

96 

97 expanded: list[str] = [] 

98 for value in values: 

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

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

101 prefix = "!" 

102 alias_ref = value[2:] 

103 elif value.startswith("$"): 

104 prefix = "" 

105 alias_ref = value[1:] 

106 else: 

107 expanded.append(value) 

108 continue 

109 

110 if alias_ref in _seen: 

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

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

113 raise ValueError( 

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

115 ) 

116 if alias_ref in aliases: 

117 _seen.add(alias_ref) 

118 _path.append(alias_ref) 

119 # Recursively expand the alias values 

120 nested_expanded = _expand_aliases(aliases[alias_ref], aliases, _seen, _path) 

121 if prefix: 

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

123 else: 

124 expanded.extend(nested_expanded) 

125 _path.pop() 

126 _seen.remove(alias_ref) 

127 else: 

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

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

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

131 

132 # Remove duplicates while preserving order 

133 return list(dict.fromkeys(expanded)) 

134 

135 

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

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

138 

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

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

141 file's directory. 

142 

143 Raises if the reference escapes above the repo root. 

144 """ 

145 if extends_ref.startswith("/"): 

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

147 else: 

148 base_dir = posixpath.dirname(extending_path) 

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

150 

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

152 raise ValueError( 

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

154 ) 

155 

156 return resolved 

157 

158 

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

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

161 

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

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

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

165 """ 

166 negate = pattern.startswith("!") 

167 if negate: 

168 pattern = pattern[1:] 

169 

170 if pattern.startswith("/"): 

171 anchored = pattern.lstrip("/") 

172 elif base_dir: 

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

174 else: 

175 anchored = pattern 

176 

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

178 

179 

180class ReviewedForChoices(StrEnum): 

181 EMPTY = "" 

182 REQUIRED = "required" 

183 IGNORED = "ignored" 

184 

185 

186class OwnershipChoices(StrEnum): 

187 EMPTY = "" 

188 APPEND = "append" 

189 GLOBAL = "global" 

190 

191 

192class ScopeModel(BaseModel): 

193 model_config = ConfigDict(extra="forbid") 

194 

195 # Required fields 

196 name: str = Field(min_length=1) 

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

198 

199 # Optional fields 

200 

201 # Expanded version of lines could be dict 

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

203 code: list[str] = [] 

204 

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

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

207 # - labels 

208 # - ref (have branches at the root level...) 

209 # - statuses 

210 # - dates 

211 # - body 

212 # - title 

213 # - other scopes 

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

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

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

217 authors: list[str] = [] 

218 branches: list[str] = [] 

219 

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

221 description: str = "" 

222 reviewers: list[str] = [] 

223 alternates: list[str] = [] 

224 cc: list[str] = [] 

225 

226 # Review scoring 

227 require: int = 0 

228 reviewed_for: ReviewedForChoices = ReviewedForChoices.EMPTY 

229 author_value: int = 0 

230 

231 # How scopes are combined 

232 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

233 

234 # Actionable items 

235 request: int = 0 

236 labels: list[str] = [] 

237 instructions: str = "" 

238 

239 # Approval checklist 

240 checklist: Checklist | None = None 

241 

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

243 @classmethod 

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

245 if "," in name: 

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

247 return name 

248 

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

250 @classmethod 

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

252 for pattern in code: 

253 try: 

254 parsed = sre_parse.parse(pattern) 

255 except re.error as e: 

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

257 if _has_nested_quantifiers(parsed): 

258 raise ValueError( 

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

260 "which can cause catastrophic backtracking." 

261 ) 

262 return code 

263 

264 @model_validator(mode="after") 

265 def validate_reviewers_for_require(self) -> ScopeModel: 

266 all_reviewers = self.reviewers + self.alternates 

267 

268 # Skip if wildcard - anyone can review 

269 if "*" in all_reviewers: 

270 return self 

271 

272 # Skip if aliases not yet expanded (will validate again after compilation) 

273 if any(r.startswith("$") for r in all_reviewers): 

274 return self 

275 

276 if len(all_reviewers) < self.require: 

277 raise ValueError( 

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

279 ) 

280 return self 

281 

282 @model_validator(mode="after") 

283 def validate_checklist_reviewed_for(self) -> ScopeModel: 

284 if self.checklist and self.reviewed_for == ReviewedForChoices.REQUIRED: 

285 raise ValueError( 

286 "checklist and reviewed_for='required' cannot be used together. " 

287 "The checklist already requires explicit scope acknowledgment." 

288 ) 

289 return self 

290 

291 def printed_name(self) -> str: 

292 match self.ownership: 

293 case OwnershipChoices.APPEND: 

294 return "+" + self.name 

295 case OwnershipChoices.GLOBAL: 

296 return "*" + self.name 

297 

298 return self.name 

299 

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

301 return self.name == other.name 

302 

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

304 # TODO paths shouldn't start with / 

305 return glob.globmatch( 

306 path, 

307 self.paths, 

308 flags=glob.GLOBSTAR 

309 | glob.BRACE 

310 | glob.NEGATE 

311 | glob.IGNORECASE 

312 | glob.DOTGLOB, 

313 ) 

314 

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

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

317 if not patterns: 

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

319 self._code_regex_patterns = patterns 

320 

321 for pattern in patterns: 

322 for match in pattern.finditer(code): 

323 start_index = match.start() 

324 end_index = match.end() 

325 

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

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

328 

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

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

331 

332 yield { 

333 "start_line": start_line, 

334 "start_col": start_col, 

335 "end_line": end_line, 

336 "end_col": end_col, 

337 } 

338 

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

340 if not self.authors: 

341 # No authors specified, so assume it matches 

342 return True 

343 

344 author_username_lower = author_username.lower() 

345 

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

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

348 

349 if author_username_lower in negated_authors: 

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

351 return False 

352 

353 if not authors: 

354 # Negation-only: everyone not negated matches 

355 return True 

356 

357 if author_username_lower in authors: 

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

359 return True 

360 

361 return False 

362 

363 def matches_branches(self, base_branch: str, head_branch: str) -> bool: 

364 return _matches_branches(self.branches, base_branch, head_branch) 

365 

366 def enabled_for_pullrequest( 

367 self, author_username: str, base_branch: str, head_branch: str 

368 ) -> bool: 

369 # Paths/code are matched during diff parsing, 

370 # but we also consider authors and branches in the context of a pull request. 

371 return self.matches_author(author_username) and self.matches_branches( 

372 base_branch, head_branch 

373 ) 

374 

375 

376class LargeScaleChangeModel(BaseModel): 

377 model_config = ConfigDict(extra="forbid") 

378 

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

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

381 

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

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

384 require: int = 1 

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

386 # min_paths: int = 300 

387 # min_lines: int = 3000 

388 labels: list[str] = [] 

389 # really need author value too...? 

390 

391 

392class ConfigModel(BaseModel): 

393 model_config = ConfigDict(extra="forbid") 

394 

395 # Nothing is technically required 

396 extends: list[str] = [] 

397 template: bool = False 

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

399 large_scale_change: LargeScaleChangeModel | None = None 

400 scopes: list[ScopeModel] = [] 

401 

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

403 @classmethod 

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

405 seen: set[str] = set() 

406 for scope in scopes: 

407 if scope.name.lower() in seen: 

408 raise ValueError(f"Duplicate scope name: {scope.name}") 

409 seen.add(scope.name.lower()) 

410 

411 return scopes 

412 

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

414 @classmethod 

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

416 for i, path in enumerate(extends): 

417 basename = Path(path).name 

418 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

419 raise ValueError( 

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

421 ) 

422 return extends 

423 

424 def compiled_config( 

425 self, config_path: Path, other_configs: ConfigModels 

426 ) -> ConfigModel: 

427 """ 

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

429 

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

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

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

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

434 

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

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

437 """ 

438 

439 compiled_data = self._merged_data(config_path, other_configs) 

440 

441 # Expand aliases for any aliasable list fields 

442 for scope in compiled_data["scopes"]: 

443 for field in [ 

444 "paths", 

445 "code", 

446 "authors", 

447 "branches", 

448 "reviewers", 

449 "alternates", 

450 "cc", 

451 "labels", 

452 ]: 

453 if field in scope: 

454 scope[field] = _expand_aliases( 

455 scope[field], compiled_data["aliases"] 

456 ) 

457 

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

459 for field in ["reviewers", "labels"]: 

460 large_scale_change[field] = _expand_aliases( 

461 large_scale_change[field], 

462 compiled_data["aliases"], 

463 ) 

464 

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

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

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

468 for scope in compiled_data["scopes"]: 

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

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

471 

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

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

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

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

476 compiled_data["extends"] = [] 

477 compiled_data["aliases"] = {} 

478 

479 return ConfigModel.from_data( 

480 data=compiled_data, 

481 path=config_path, 

482 ) 

483 

484 def _merged_data( 

485 self, 

486 config_path: Path, 

487 other_configs: ConfigModels, 

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

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

490 ) -> dict[str, Any]: 

491 """ 

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

493 

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

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

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

497 

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

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

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

501 """ 

502 if _in_progress is None: 

503 _in_progress = [] 

504 if _seen is None: 

505 _seen = set() 

506 

507 config_path_str = str(config_path) 

508 if config_path_str in _in_progress: 

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

510 config_path_str 

511 ] 

512 raise ValueError( 

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

514 ) 

515 _in_progress.append(config_path_str) 

516 

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

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

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

520 

521 for extend_path in self.extends: 

522 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

523 if resolved_path not in other_configs: 

524 raise ValueError( 

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

526 ) 

527 if resolved_path in _seen: 

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

529 continue 

530 

531 parent_data = other_configs[resolved_path]._merged_data( 

532 Path(resolved_path), other_configs, _in_progress, _seen 

533 ) 

534 inherited_scopes = inherited_scopes + parent_data["scopes"] 

535 inherited_aliases = inherited_aliases | parent_data["aliases"] 

536 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

537 

538 merged = self.model_dump() 

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

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

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

542 

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

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

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

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

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

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

549 if not self.template: 

550 base_dir = posixpath.dirname(config_path_str) 

551 for scope in merged["scopes"]: 

552 scope.setdefault("_anchor_dir", base_dir) 

553 

554 _seen.add(config_path_str) 

555 _in_progress.pop() 

556 

557 return merged 

558 

559 @classmethod 

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

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

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

563 

564 @classmethod 

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

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

567 

568 @classmethod 

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

570 return cls(**data) 

571 

572 

573class _ConfigModelsBase(RootModel): 

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

575 

576 root: dict[str, ConfigModel] 

577 

578 @classmethod 

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

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

581 configs = cls(root={}) 

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

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

584 return configs 

585 

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

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

588 

589 def __bool__(self) -> bool: 

590 return bool(self.root) 

591 

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

593 return self.root[key] 

594 

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

596 return key in self.root 

597 

598 def __len__(self) -> int: 

599 return len(self.root) 

600 

601 

602class ConfigModels(_ConfigModelsBase): 

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

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

605 

606 @classmethod 

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

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

609 configs = cls(root={}) 

610 

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

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

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

614 

615 return configs 

616 

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

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

619 

620 def compiled(self) -> CompiledConfigModels: 

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

622 

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

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

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

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

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

628 the set for display). 

629 

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

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

632 double-apply. 

633 """ 

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

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

636 if config.template: 

637 effective[path] = config 

638 else: 

639 effective[path] = config.compiled_config(Path(path), self) 

640 

641 return CompiledConfigModels.from_config_models(effective) 

642 

643 

644class CompiledConfigModels(_ConfigModelsBase): 

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

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

647 

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

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

650 for parent in file_path.parents: 

651 parent_config_path = str(parent / CONFIG_FILENAME) 

652 

653 if parent_config_path in self.root: 

654 config = self.root[parent_config_path] 

655 

656 if config.template: 

657 # Skip templates 

658 continue 

659 

660 return config 

661 

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

663 

664 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

666 

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

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

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

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

671 LSC would read with aliases unexpanded. 

672 """ 

673 if CONFIG_FILENAME in self.root: 

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

675 return lsc 

676 

677 return LargeScaleChangeModel() 

678 

679 def filter_for_pullrequest( 

680 self, 

681 base_branch: str, 

682 head_branch: str, 

683 author_username: str, 

684 ) -> CompiledConfigModels: 

685 """ 

686 Overlay PR-dependent scope gating: drop scopes that branch/author rules 

687 disable for this pull request. 

688 

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

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

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

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

693 """ 

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

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

696 if config.template: 

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

698 effective[config_path] = config 

699 continue 

700 

701 kept_scopes = [ 

702 scope 

703 for scope in config.scopes 

704 if scope.enabled_for_pullrequest( 

705 author_username, base_branch, head_branch 

706 ) 

707 ] 

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

709 

710 return CompiledConfigModels.from_config_models(effective)