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

321 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 17:28 -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 _expand_aliases( 

65 values: list[str], 

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

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

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

69) -> list[str]: 

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

71 if _seen is None: 

72 _seen = set() 

73 if _path is None: 

74 _path = [] 

75 

76 expanded: list[str] = [] 

77 for value in values: 

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

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

80 prefix = "!" 

81 alias_ref = value[2:] 

82 elif value.startswith("$"): 

83 prefix = "" 

84 alias_ref = value[1:] 

85 else: 

86 expanded.append(value) 

87 continue 

88 

89 if alias_ref in _seen: 

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

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

92 raise ValueError( 

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

94 ) 

95 if alias_ref in aliases: 

96 _seen.add(alias_ref) 

97 _path.append(alias_ref) 

98 # Recursively expand the alias values 

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

100 if prefix: 

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

102 else: 

103 expanded.extend(nested_expanded) 

104 _path.pop() 

105 _seen.remove(alias_ref) 

106 else: 

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

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

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

110 

111 # Remove duplicates while preserving order 

112 return list(dict.fromkeys(expanded)) 

113 

114 

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

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

117 

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

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

120 file's directory. 

121 

122 Raises if the reference escapes above the repo root. 

123 """ 

124 if extends_ref.startswith("/"): 

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

126 else: 

127 base_dir = posixpath.dirname(extending_path) 

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

129 

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

131 raise ValueError( 

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

133 ) 

134 

135 return resolved 

136 

137 

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

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

140 

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

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

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

144 """ 

145 negate = pattern.startswith("!") 

146 if negate: 

147 pattern = pattern[1:] 

148 

149 if pattern.startswith("/"): 

150 anchored = pattern.lstrip("/") 

151 elif base_dir: 

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

153 else: 

154 anchored = pattern 

155 

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

157 

158 

159class OwnershipChoices(StrEnum): 

160 EMPTY = "" 

161 APPEND = "append" 

162 GLOBAL = "global" 

163 

164 

165class ScopeModel(BaseModel): 

166 model_config = ConfigDict(extra="forbid") 

167 

168 # Required fields 

169 name: str = Field(min_length=1) 

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

171 

172 # Optional fields 

173 

174 # Expanded version of lines could be dict 

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

176 code: list[str] = [] 

177 

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

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

180 # - labels 

181 # - ref 

182 # - statuses 

183 # - dates 

184 # - body 

185 # - title 

186 # - other scopes 

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

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

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

190 authors: list[str] = [] 

191 

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

193 description: str = "" 

194 reviewers: list[str] = [] 

195 alternates: list[str] = [] 

196 cc: list[str] = [] 

197 

198 # Review scoring 

199 require: int = 0 

200 author_value: int = 0 

201 

202 # How scopes are combined 

203 ownership: OwnershipChoices = OwnershipChoices.EMPTY 

204 

205 # Actionable items 

206 request: int = 0 

207 labels: list[str] = [] 

208 instructions: str = "" 

209 

210 # Approval checklist 

211 checklist: Checklist | None = None 

212 

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

214 @classmethod 

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

216 if "," in name: 

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

218 return name 

219 

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

221 @classmethod 

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

223 for pattern in code: 

224 try: 

225 parsed = sre_parse.parse(pattern) 

226 except re.error as e: 

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

228 if _has_nested_quantifiers(parsed): 

229 raise ValueError( 

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

231 "which can cause catastrophic backtracking." 

232 ) 

233 return code 

234 

235 @model_validator(mode="after") 

236 def validate_reviewers_for_require(self) -> ScopeModel: 

237 all_reviewers = self.reviewers + self.alternates 

238 

239 # Skip if wildcard - anyone can review 

240 if "*" in all_reviewers: 

241 return self 

242 

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

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

245 return self 

246 

247 if len(all_reviewers) < self.require: 

248 raise ValueError( 

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

250 ) 

251 return self 

252 

253 def printed_name(self) -> str: 

254 match self.ownership: 

255 case OwnershipChoices.APPEND: 

256 return "+" + self.name 

257 case OwnershipChoices.GLOBAL: 

258 return "*" + self.name 

259 

260 return self.name 

261 

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

263 return self.name == other.name 

264 

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

266 # TODO paths shouldn't start with / 

267 return glob.globmatch( 

268 path, 

269 self.paths, 

270 flags=glob.GLOBSTAR 

271 | glob.BRACE 

272 | glob.NEGATE 

273 | glob.IGNORECASE 

274 | glob.DOTGLOB, 

275 ) 

276 

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

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

279 if not patterns: 

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

281 self._code_regex_patterns = patterns 

282 

283 for pattern in patterns: 

284 for match in pattern.finditer(code): 

285 start_index = match.start() 

286 end_index = match.end() 

287 

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

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

290 

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

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

293 

294 yield { 

295 "start_line": start_line, 

296 "start_col": start_col, 

297 "end_line": end_line, 

298 "end_col": end_col, 

299 } 

300 

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

302 if not self.authors: 

303 # No authors specified, so assume it matches 

304 return True 

305 

306 author_username_lower = author_username.lower() 

307 

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

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

310 

311 if author_username_lower in negated_authors: 

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

313 return False 

314 

315 if not authors: 

316 # Negation-only: everyone not negated matches 

317 return True 

318 

319 if author_username_lower in authors: 

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

321 return True 

322 

323 return False 

324 

325 

326class LargeScaleChangeModel(BaseModel): 

327 model_config = ConfigDict(extra="forbid") 

328 

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

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

331 

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

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

334 require: int = 1 

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

336 # min_paths: int = 300 

337 # min_lines: int = 3000 

338 labels: list[str] = [] 

339 # really need author value too...? 

340 

341 

342class ConfigModel(BaseModel): 

343 model_config = ConfigDict(extra="forbid") 

344 

345 # Nothing is technically required 

346 extends: list[str] = [] 

347 template: bool = False 

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

349 large_scale_change: LargeScaleChangeModel | None = None 

350 scopes: list[ScopeModel] = [] 

351 

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

353 @classmethod 

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

355 seen: set[str] = set() 

356 for scope in scopes: 

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

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

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

360 

361 return scopes 

362 

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

364 @classmethod 

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

366 for i, path in enumerate(extends): 

367 basename = Path(path).name 

368 if not basename.startswith(CONFIG_FILENAME_PREFIX): 

369 raise ValueError( 

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

371 ) 

372 return extends 

373 

374 def compiled_config( 

375 self, config_path: Path, other_configs: ConfigModels 

376 ) -> ConfigModel: 

377 """ 

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

379 

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

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

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

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

384 

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

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

387 """ 

388 

389 compiled_data = self._merged_data(config_path, other_configs) 

390 

391 # Expand aliases for any aliasable list fields 

392 for scope in compiled_data["scopes"]: 

393 for field in [ 

394 "paths", 

395 "code", 

396 "authors", 

397 "reviewers", 

398 "alternates", 

399 "cc", 

400 "labels", 

401 ]: 

402 if field in scope: 

403 scope[field] = _expand_aliases( 

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

405 ) 

406 

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

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

409 large_scale_change[field] = _expand_aliases( 

410 large_scale_change[field], 

411 compiled_data["aliases"], 

412 ) 

413 

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

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

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

417 for scope in compiled_data["scopes"]: 

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

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

420 

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

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

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

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

425 compiled_data["extends"] = [] 

426 compiled_data["aliases"] = {} 

427 

428 return ConfigModel.from_data( 

429 data=compiled_data, 

430 path=config_path, 

431 ) 

432 

433 def _merged_data( 

434 self, 

435 config_path: Path, 

436 other_configs: ConfigModels, 

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

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

439 ) -> dict[str, Any]: 

440 """ 

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

442 

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

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

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

446 

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

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

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

450 """ 

451 if _in_progress is None: 

452 _in_progress = [] 

453 if _seen is None: 

454 _seen = set() 

455 

456 config_path_str = str(config_path) 

457 if config_path_str in _in_progress: 

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

459 config_path_str 

460 ] 

461 raise ValueError( 

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

463 ) 

464 _in_progress.append(config_path_str) 

465 

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

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

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

469 

470 for extend_path in self.extends: 

471 resolved_path = _resolve_extends_path(config_path_str, extend_path) 

472 if resolved_path not in other_configs: 

473 raise ValueError( 

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

475 ) 

476 if resolved_path in _seen: 

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

478 continue 

479 

480 parent_data = other_configs[resolved_path]._merged_data( 

481 Path(resolved_path), other_configs, _in_progress, _seen 

482 ) 

483 inherited_scopes = inherited_scopes + parent_data["scopes"] 

484 inherited_aliases = inherited_aliases | parent_data["aliases"] 

485 inherited_lsc = inherited_lsc or parent_data["large_scale_change"] 

486 

487 merged = self.model_dump() 

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

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

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

491 

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

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

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

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

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

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

498 if not self.template: 

499 base_dir = posixpath.dirname(config_path_str) 

500 for scope in merged["scopes"]: 

501 scope.setdefault("_anchor_dir", base_dir) 

502 

503 _seen.add(config_path_str) 

504 _in_progress.pop() 

505 

506 return merged 

507 

508 @classmethod 

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

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

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

512 

513 @classmethod 

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

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

516 

517 @classmethod 

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

519 return cls(**data) 

520 

521 

522class _ConfigModelsBase(RootModel): 

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

524 

525 root: dict[str, ConfigModel] 

526 

527 @classmethod 

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

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

530 configs = cls(root={}) 

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

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

533 return configs 

534 

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

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

537 

538 def __bool__(self) -> bool: 

539 return bool(self.root) 

540 

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

542 return self.root[key] 

543 

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

545 return key in self.root 

546 

547 def __len__(self) -> int: 

548 return len(self.root) 

549 

550 

551class ConfigModels(_ConfigModelsBase): 

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

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

554 

555 @classmethod 

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

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

558 configs = cls(root={}) 

559 

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

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

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

563 

564 return configs 

565 

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

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

568 

569 def compiled(self) -> CompiledConfigModels: 

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

571 

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

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

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

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

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

577 the set for display). 

578 

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

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

581 double-apply. 

582 """ 

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

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

585 if config.template: 

586 effective[path] = config 

587 else: 

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

589 

590 return CompiledConfigModels.from_config_models(effective) 

591 

592 

593class CompiledConfigModels(_ConfigModelsBase): 

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

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

596 

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

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

599 for parent in file_path.parents: 

600 parent_config_path = str(parent / CONFIG_FILENAME) 

601 

602 if parent_config_path in self.root: 

603 config = self.root[parent_config_path] 

604 

605 if config.template: 

606 # Skip templates 

607 continue 

608 

609 return config 

610 

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

612 

613 def get_default_large_scale_change(self) -> LargeScaleChangeModel: 

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

615 

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

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

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

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

620 LSC would read with aliases unexpanded. 

621 """ 

622 if CONFIG_FILENAME in self.root: 

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

624 return lsc 

625 

626 return LargeScaleChangeModel() 

627 

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

629 """ 

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

631 for this pull request. 

632 

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

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

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

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

637 """ 

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

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

640 if config.template: 

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

642 effective[config_path] = config 

643 continue 

644 

645 kept_scopes = [ 

646 scope 

647 for scope in config.scopes 

648 if scope.matches_author(author_username) 

649 ] 

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

651 

652 return CompiledConfigModels.from_config_models(effective)