Coverage for src/pullapprove/config.py: 95%
497 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-15 21:54 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-15 21:54 -0500
1from __future__ import annotations
3import os
4import posixpath
5import re
6import tomllib
7import warnings
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
17from pydantic import (
18 BaseModel,
19 ConfigDict,
20 Field,
21 RootModel,
22 field_validator,
23 model_validator,
24)
25from wcmatch import glob
27from .checklists import Checklist
30def _resolve_config_filename_prefix() -> str:
31 """The name every config file starts with, for this process.
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.
41 Read once at import: this is a per-process constant, not runtime state.
43 Two operational constraints this doesn't (and can't) enforce:
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).
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"
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 )
74 return prefix
77CONFIG_FILENAME_PREFIX = _resolve_config_filename_prefix()
78CONFIG_FILENAME = f"{CONFIG_FILENAME_PREFIX}.toml"
81def is_config_filename(basename: str) -> bool:
82 """Whether a filename is a config file (CODEREVIEW.toml,
83 CODEREVIEW.template.toml).
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")
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))
98_REPEAT_OPS = {sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT}
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
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
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})+$")
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")
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")
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
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(("$", "@"))
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.
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
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
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.
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}"]
206 members = teams.get(ref.lower())
207 if members is None:
208 raise ValueError(f"Unknown team: {prefix}@{ref}")
210 return [f"{prefix}{member}" for member in members]
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.
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 = []
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
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}")
281 # Remove duplicates while preserving order
282 return list(dict.fromkeys(expanded))
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`).
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.
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 )
306 if any(_split_team_ref(v) is not None for v in values):
307 return values
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]
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).
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")
329def _compile_review_fields(
330 *,
331 entry: dict[str, Any],
332 label: str,
333 aliases: dict[str, list[str]],
334 teams: dict[str, list[str]] | None,
335) -> None:
336 """Compile a raw scope, or the large-scale change, in place: expand
337 aliases, apply "!" subtraction, and reject what can never work. Only the
338 fields the entry has are touched. `label` names it in error messages
339 ("Scope 'app'", "large_scale_change").
340 """
341 # Team refs only expand in user-list fields — paths/code/labels keep a
342 # leading "@" literal.
343 for field in (
344 "paths",
345 "code",
346 "authors",
347 "reviewers",
348 "alternates",
349 "cc",
350 "labels",
351 ):
352 if field in entry:
353 entry[field] = _expand_aliases(
354 entry[field],
355 aliases,
356 teams=teams,
357 expand_teams=field in USER_LIST_FIELDS,
358 )
360 # Apply compile-time "!" subtraction to roster fields.
361 # `_apply_negations` leaves a field with an unexpanded team ref
362 # (offline compile, i.e. teams=None) untouched.
363 for field in ROSTER_FIELDS:
364 if field in entry:
365 entry[field] = _apply_negations(entry[field])
366 _reject_bot_reviewers(entry[field], where=f"{label} {field}")
368 # The "*" wildcard is only meaningful in `reviewers` — everywhere
369 # else it's matched as a literal username and silently does
370 # nothing: in `alternates` the scope stays pending forever, in
371 # `authors` the scope never applies to any PR, in `cc` nobody is
372 # notified. Reject it at compile time (after alias expansion, so
373 # `$alias` indirection can't smuggle it in) rather than as a
374 # ScopeModel validator, because compiled configs stored inside
375 # old processing results must keep parsing.
376 # Messages must stay under ~130 chars: they become the git-host
377 # commit status description, which the adapters slice to 140.
378 for field, hint in (
379 (
380 "authors",
381 "remove it (a scope with no authors applies to any author)",
382 ),
383 (
384 "alternates",
385 "add it to reviewers instead (wildcard reviewers are never auto-requested)",
386 ),
387 ("cc", "remove it"),
388 ):
389 if "*" in entry.get(field, []):
390 raise ValueError(f'{label}: "*" is not supported in {field} — {hint}')
392 _validate_review_counts(label, entry)
395def _roster_shortfall(
396 *, reviewers: list[str], alternates: list[str], require: int
397) -> str | None:
398 """Why `require` approvals can never come from these reviewers and
399 alternates, or None if they can."""
400 all_reviewers = reviewers + alternates
402 # Skip if wildcard - anyone can review
403 if "*" in all_reviewers:
404 return None
406 # Skip if aliases or team refs (possibly negated with "!") are not yet
407 # expanded. Re-validation after compilation only re-checks refs that
408 # actually resolve — an offline compile (teams=None) leaves refs
409 # unexpanded, so this same skip fires again there too.
410 if any(is_unexpanded_ref(r) for r in all_reviewers):
411 return None
413 if len(all_reviewers) < require:
414 return f"has require={require} but only {len(all_reviewers)} reviewers/alternates specified"
416 return None
419def _resolve_extends_path(extending_path: str, extends_ref: str) -> str:
420 """Resolve an `extends` reference to a canonical repo-relative config key.
422 - `/x` is repo-root-relative.
423 - everything else (`../x`, `dir/x`, bare `x`) is relative to the extending
424 file's directory.
426 Raises if the reference escapes above the repo root.
427 """
428 if extends_ref.startswith("/"):
429 resolved = posixpath.normpath(extends_ref.lstrip("/"))
430 else:
431 base_dir = posixpath.dirname(extending_path)
432 resolved = posixpath.normpath(posixpath.join(base_dir, extends_ref))
434 if resolved == ".." or resolved.startswith("../"):
435 raise ValueError(
436 f"Invalid extends path: '{extends_ref}' points above the repo root"
437 )
439 return resolved
442def matches_path_patterns(*, path: Path, patterns: list[str]) -> bool:
443 """Whether `path` matches any of the config's path globs.
445 The one definition of the config's glob semantics.
446 """
447 # TODO paths shouldn't start with /
448 return glob.globmatch(
449 path,
450 patterns,
451 flags=glob.GLOBSTAR | glob.BRACE | glob.NEGATE | glob.IGNORECASE | glob.DOTGLOB,
452 )
455def _anchor_path(base_dir: str, pattern: str) -> str:
456 """Anchor a scope path glob at `base_dir` (the owning config's directory).
458 Scope paths are written relative to the config they live in. A leading `/`
459 makes a pattern repo-root-absolute (escape hatch); a leading `!` negation is
460 preserved. With an empty `base_dir` (root config) the pattern is unchanged.
461 """
462 negate = pattern.startswith("!")
463 if negate:
464 pattern = pattern[1:]
466 if pattern.startswith("/"):
467 anchored = pattern.lstrip("/")
468 elif base_dir:
469 anchored = f"{base_dir}/{pattern}"
470 else:
471 anchored = pattern
473 return f"!{anchored}" if negate else anchored
476class OwnershipChoices(StrEnum):
477 EMPTY = ""
478 APPEND = "append"
479 GLOBAL = "global"
482# A GitHub App has two names: the login it posts as (`name[bot]`) and the slug
483# that owns its check runs (`name`). The suffix is how the engine tells a bot
484# from a person -- a `[bot]` account never counts as a human and never sits in
485# a roster, no declaration needed.
486BOT_LOGIN_SUFFIX = "[bot]"
489def is_bot_login(username: str) -> bool:
490 """The engine rule, in one place: a `[bot]` account is never a person."""
491 return username.lower().endswith(BOT_LOGIN_SUFFIX)
494def _split_check_ref(ref: str) -> tuple[str, str]:
495 """An `unless` ref as (producer slug lowercased, check name).
497 Split on the FIRST `/`: App slugs cannot contain one, check names can.
498 Both parts are stripped -- the validator checks THIS function's output, so
499 a ref that validates is exactly a ref that matches at runtime.
500 """
501 producer, _, name = ref.partition("/")
502 return producer.strip().lower(), name.strip()
505def _reject_bot_reviewers(values: list[str], *, where: str) -> None:
506 """Reject any `[bot]` account listed in a roster surface (a scope's
507 reviewers/alternates/cc, or large_scale_change.reviewers/alternates).
509 A bot is never a person, so it can never sit in a roster: bots that open
510 pull requests are routed with `authors`, and bots that attest are
511 referenced from `unless`. Checked after alias expansion so `$alias`
512 indirection can't smuggle one in. `where` names the surface; the message
513 becomes the git-host commit status, so it stays under ~130 chars.
514 """
515 for entry in values:
516 if is_bot_login(entry):
517 raise ValueError(
518 f"{where}: '{entry}' is a bot and cannot be listed as a reviewer"
519 )
522def _first_case_insensitive_duplicate(values: Iterable[str]) -> str | None:
523 """The first value whose lowercased form was already seen, else None."""
524 seen: set[str] = set()
525 for value in values:
526 if value.lower() in seen:
527 return value
528 seen.add(value.lower())
529 return None
532class ScopeModel(BaseModel):
533 model_config = ConfigDict(extra="forbid")
535 # Required fields
536 name: str = Field(min_length=1)
537 paths: list[str] = Field(min_length=1)
539 # Optional fields
541 # Expanded version of lines could be dict
542 # with fnmatch, regex, exclude patterns, etc?
543 code: list[str] = []
545 # This only filtering field that can't be used with raw diff/files...
546 # If we get into that, the others are:
547 # - labels
548 # - ref
549 # - statuses
550 # - dates
551 # - body
552 # - title
553 # - other scopes
554 # (this is how I ended up with expressions...
555 # I'm not trying to build a general purpose workflow tool,
556 # but I do need to support the legit use cases and AI/bot review is one, so is team hierarchy)
557 authors: list[str] = []
559 # (defaults should be the "empty" values)
560 description: str = ""
561 reviewers: list[str] = []
562 alternates: list[str] = []
563 cc: list[str] = []
565 # Review scoring
566 # Negative values are rejected at compile time (_validate_review_counts)
567 require: int = 0
568 author_value: int = 0
570 # The checks that can attest this scope's review requirement away. Each ref
571 # is "producer/check-name" -- the slug of the App that produces the check,
572 # then the check run's name, split on the first "/". When every listed
573 # check has completed successfully on the current head commit, the scope is
574 # waived: its requirement drops to zero and the evidence is stored on the
575 # result. Failed, skipped, pending, missing -- the requirement stands
576 # unchanged; passing is the only state that subtracts.
577 #
578 # Deliberately placed next to the `require` it undermines, so reading a
579 # scope always reveals whether its human review is removable. A list is
580 # implicitly "all must pass" -- OR, thresholds, and 2-of-3 belong in the
581 # producer, which can post one combined check.
582 unless: list[str] = []
584 # How scopes are combined
585 ownership: OwnershipChoices = OwnershipChoices.EMPTY
587 # Actionable items
588 request: int = 0
589 labels: list[str] = []
590 instructions: str = ""
592 # Approval checklist
593 checklist: Checklist | None = None
595 @field_validator("name", mode="after")
596 @classmethod
597 def validate_name(cls, name: str) -> str:
598 if "," in name:
599 raise ValueError("Scope name cannot contain commas")
600 return name
602 @field_validator(*USER_LIST_FIELDS, mode="after")
603 @classmethod
604 def validate_team_ref_shape(cls, values: list[str]) -> list[str]:
605 return _validate_team_refs(values)
607 @field_validator("unless", mode="after")
608 @classmethod
609 def validate_unless_refs(cls, values: list[str]) -> list[str]:
610 """A check name alone is worthless -- any workflow with `checks: write`
611 can post any name -- so a bare name is a parse error, not a default.
612 Messages stay under ~130 chars: they become the git-host commit status.
613 """
614 for ref in values:
615 producer, name = _split_check_ref(ref)
616 if "/" not in ref or not producer or not name:
617 raise ValueError(
618 f"unless: '{ref}' must be 'producer/check-name' -- the App "
619 "slug that produces the check, then the check's name"
620 )
621 # To most users PullApprove *is* "the check" on their PRs.
622 # PullApprove never creates these checks, it only reads them -- and
623 # a config waiting on our own status would deadlock politely.
624 if producer == "pullapprove":
625 raise ValueError(
626 f"unless: '{ref}' references PullApprove itself -- "
627 "PullApprove never creates checks, it only reads them"
628 )
629 return values
631 @field_validator("code", mode="after")
632 @classmethod
633 def validate_code_patterns(cls, code: list[str]) -> list[str]:
634 for pattern in code:
635 try:
636 parsed = sre_parse.parse(pattern)
637 except re.error as e:
638 raise ValueError(f"Invalid regex pattern '{pattern}': {e}") from None
639 if _has_nested_quantifiers(parsed):
640 raise ValueError(
641 f"Regex pattern '{pattern}' contains nested quantifiers, "
642 "which can cause catastrophic backtracking."
643 )
644 return code
646 @model_validator(mode="after")
647 def validate_reviewers_for_require(self) -> ScopeModel:
648 if reason := _roster_shortfall(
649 reviewers=self.reviewers,
650 alternates=self.alternates,
651 require=self.require,
652 ):
653 raise ValueError(reason)
654 return self
656 def counts_toward(self, username: str) -> bool:
657 """Whether this person's review counts for this scope.
659 Its named `reviewers` and its `alternates`, plus everybody when
660 `reviewers` holds the wildcard. A wildcard in `alternates` does not
661 count everybody: the wildcard describes the scope's `reviewers`, not
662 its backups.
664 Case-insensitive, like every other username comparison here.
665 """
666 if "*" in self.reviewers:
667 return True
668 username_lower = username.lower()
669 return any(
670 username_lower == r.lower() for r in self.reviewers + self.alternates
671 )
673 def author_points(self, author_username: str) -> int:
674 """Author points only count if the author is explicitly listed as a
675 reviewer (a wildcard is not converted to usernames)."""
676 if author_username.lower() in {r.lower() for r in self.reviewers}:
677 return self.author_value
678 return 0
680 def unless_refs(self) -> list[tuple[str, str]]:
681 """Each `unless` entry as (producer slug lowercased, check name)."""
682 return [_split_check_ref(ref) for ref in self.unless]
684 def unsolvable_reason(self, author_username: str) -> str | None:
685 """
686 Explain why this scope can never pass for a PR authored by this user,
687 or None if it can.
689 Messages must stay under ~130 chars: they flow into the git-host
690 commit status description, which the adapters slice to 140.
691 """
692 if "*" in self.reviewers:
693 # Anyone can review, so any require is satisfiable
694 return None
696 # Count eligible reviewers (excluding author who can't self-approve)
697 eligible_reviewers = {r.lower() for r in self.reviewers + self.alternates} - {
698 author_username.lower()
699 }
700 max_possible_points = len(eligible_reviewers) + self.author_points(
701 author_username
702 )
704 if self.require > 0 and max_possible_points < self.require:
705 if not eligible_reviewers:
706 return (
707 "PR author is the only reviewer/alternate and cannot self-approve"
708 )
709 return f"require={self.require} but only {max_possible_points} possible approvals (excluding author)"
711 return None
713 def ownership_marker(self) -> str:
714 """The glyph a non-default ownership puts in front of the scope name.
716 Split out from printed_name so a renderer that styles the marker apart
717 from the name doesn't have to know which glyph goes with which mode.
718 """
719 match self.ownership:
720 case OwnershipChoices.APPEND:
721 return "+"
722 case OwnershipChoices.GLOBAL:
723 return "*"
725 return ""
727 def printed_name(self) -> str:
728 return self.ownership_marker() + self.name
730 def __eq__(self, other: object) -> bool:
731 if not isinstance(other, ScopeModel):
732 return NotImplemented
733 return self.name == other.name
735 def matches_path(self, path: Path) -> bool:
736 return matches_path_patterns(path=path, patterns=self.paths)
738 def matches_code(self, code: str) -> Generator[dict[str, int]]:
739 patterns = getattr(self, "_code_regex_patterns", [])
740 if not patterns:
741 patterns = [re.compile(pattern, re.MULTILINE) for pattern in self.code]
742 self._code_regex_patterns = patterns
744 for pattern in patterns:
745 for match in pattern.finditer(code):
746 start_index = match.start()
747 end_index = match.end()
749 start_line = code.count("\n", 0, start_index) + 1
750 start_col = start_index - code.rfind("\n", 0, start_index)
752 end_line = code.count("\n", 0, end_index) + 1
753 end_col = end_index - code.rfind("\n", 0, end_index)
755 yield {
756 "start_line": start_line,
757 "start_col": start_col,
758 "end_line": end_line,
759 "end_col": end_col,
760 }
762 def matches_author(self, author_username: str) -> bool:
763 if not self.authors:
764 # No authors specified, so assume it matches
765 return True
767 author_username_lower = author_username.lower()
769 negated_authors = [a[1:].lower() for a in self.authors if a.startswith("!")]
770 authors = [a.lower() for a in self.authors if not a.startswith("!")]
772 if author_username_lower in negated_authors:
773 # If the author is in the negated list, return False
774 return False
776 if not authors:
777 # Negation-only: everyone not negated matches
778 return True
780 return author_username_lower in authors
783# The scope a large-scale change is reviewed as (LargeScaleChangeModel.as_scope).
784LARGE_SCALE_CHANGE_SCOPE_NAME = "Large-scale change"
787class LargeScaleChangeModel(BaseModel):
788 model_config = ConfigDict(extra="forbid")
790 # Note, an LSC only applies to diffs, not raw files,
791 # because we have to know what *changed*.
793 # Reviewed as a scope (see as_scope), so each field means what it means
794 # on a scope. There has to be at least one reviewer, so a PR that turns
795 # out to be an LSC errors until the config names some.
796 # Negative values are rejected at compile time (_validate_review_counts)
797 require: int = 1
798 request: int = 0
799 reviewers: list[str] = []
800 alternates: list[str] = []
801 # min_paths: int = 300
802 # min_lines: int = 3000
803 labels: list[str] = []
805 @field_validator("reviewers", "alternates", mode="after")
806 @classmethod
807 def validate_team_ref_shape(cls, values: list[str]) -> list[str]:
808 return _validate_team_refs(values)
810 def as_scope(self) -> ScopeModel:
811 """The scope a large-scale change is reviewed as, in place of the
812 scopes its diff was too large to match. It covers every path, and is
813 scored, requested and shown like any other scope.
815 Raises ValueError when no set of reviewers could ever approve it.
816 Messages must stay under ~130 chars: they become the git-host commit
817 status description.
818 """
819 # A scope can get by on alternates alone, so this can too.
820 if not self.reviewers and not self.alternates:
821 raise ValueError(
822 "Large-scale change: configuration required (no reviewers defined)"
823 )
825 # ScopeModel rejects this too, but its message wouldn't say where the
826 # problem is.
827 if reason := _roster_shortfall(
828 reviewers=self.reviewers,
829 alternates=self.alternates,
830 require=self.require,
831 ):
832 raise ValueError(f"Large-scale change: {reason}")
834 return ScopeModel(
835 name=LARGE_SCALE_CHANGE_SCOPE_NAME,
836 paths=["**/*"],
837 reviewers=self.reviewers,
838 alternates=self.alternates,
839 require=self.require,
840 request=self.request,
841 labels=self.labels,
842 )
845class ConfigModel(BaseModel):
846 model_config = ConfigDict(extra="forbid")
848 # Nothing is technically required
849 extends: list[str] = []
850 template: bool = False
851 aliases: dict[str, list[str]] = {}
852 large_scale_change: LargeScaleChangeModel | None = None
853 scopes: list[ScopeModel] = []
855 @field_validator("scopes", mode="after")
856 @classmethod
857 def validate_unique_scope_names(cls, scopes: list[ScopeModel]) -> list[ScopeModel]:
858 if dup := _first_case_insensitive_duplicate(scope.name for scope in scopes):
859 raise ValueError(f"Duplicate scope name: {dup}")
860 return scopes
862 @field_validator("extends", mode="before")
863 @classmethod
864 def validate_extends(cls, extends: list[str]) -> list[str]:
865 for i, path in enumerate(extends):
866 basename = Path(path).name
867 if not basename.startswith(CONFIG_FILENAME_PREFIX):
868 raise ValueError(
869 f"Invalid extends path: {path}. It should start with '{CONFIG_FILENAME_PREFIX}'."
870 )
871 return extends
873 def compiled_config(
874 self,
875 config_path: Path,
876 other_configs: ConfigModels,
877 teams: dict[str, list[str]] | None = None,
878 ) -> ConfigModel:
879 """
880 Resolve `extends` and replace aliases, returning the effective config.
882 Two phases: flatten the whole extends chain into one merged (raw,
883 unexpanded) config, then expand aliases once. Expanding after the full
884 merge is what makes transitive inheritance and cross-chain alias
885 scoping work — an alias defined anywhere in the chain resolves anywhere.
887 `teams` maps team refs (any case, without the leading `@`) to member
888 usernames, and is only consulted for user-list fields (reviewers,
889 alternates, authors, cc, large_scale_change.reviewers/alternates). Keys are
890 lowercased here, so callers don't need to normalize case themselves.
891 With `teams=None` (the default), `@org/team` references in those
892 fields pass through unexpanded — the offline/CLI mode, since this
893 library never calls out to GitHub/GitLab itself. That
894 partially-resolved config is only sanctioned for offline use;
895 anything that needs real reviewer usernames must pass a `teams`
896 mapping.
898 Pure function of the raw `self` and `other_configs` (it never reads its
899 own anchored output), so it is safe to call uncached.
900 """
902 if teams is not None:
903 teams = {key.lower(): members for key, members in teams.items()}
905 compiled_data = self._merged_data(config_path, other_configs)
907 # A large-scale change is reviewed as a scope, so it compiles by a
908 # scope's rules.
909 for scope in compiled_data["scopes"]:
910 _compile_review_fields(
911 entry=scope,
912 label=f"Scope '{scope['name']}'",
913 aliases=compiled_data["aliases"],
914 teams=teams,
915 )
916 if large_scale_change := compiled_data.get("large_scale_change"):
917 _compile_review_fields(
918 entry=large_scale_change,
919 label="large_scale_change",
920 aliases=compiled_data["aliases"],
921 teams=teams,
922 )
924 # Anchor each scope's paths at the directory tagged during flattening
925 # (after alias expansion, so any `$path-alias` is resolved first). The
926 # transient tag is popped so it never reaches the model.
927 # Paths are relative to the config that owns the scope, `/` for
928 # repo-root-absolute.
929 for entry in compiled_data["scopes"]:
930 anchor_dir = entry.pop("_anchor_dir", "")
931 entry["paths"] = [_anchor_path(anchor_dir, p) for p in entry["paths"]]
933 # The compiled config is the self-contained effective config: extends
934 # are already merged in and aliases already expanded, so drop both. This
935 # keeps stored results lean and makes the compiled form standalone (it
936 # can never dangle on a missing extends target or re-expand differently).
937 compiled_data["extends"] = []
938 compiled_data["aliases"] = {}
940 return ConfigModel.from_data(
941 data=compiled_data,
942 path=config_path,
943 )
945 def _merged_data(
946 self,
947 config_path: Path,
948 other_configs: ConfigModels,
949 _in_progress: list[str] | None = None,
950 _seen: set[str] | None = None,
951 ) -> dict[str, Any]:
952 """
953 Flatten the `extends` chain into one merged, *unexpanded* config dict.
955 Parents are merged before this config (so a child can specialize), with
956 aliases unioned child-wins and the large-scale-change config taken from
957 the child if set else the first parent that defines one.
959 `_in_progress` is the current ancestor path, used to detect circular
960 extends. `_seen` is every config already merged into this flatten, used
961 to merge a shared ancestor only once (diamond dedup).
962 """
963 if _in_progress is None:
964 _in_progress = []
965 if _seen is None:
966 _seen = set()
968 config_path_str = str(config_path)
969 if config_path_str in _in_progress:
970 cycle = _in_progress[_in_progress.index(config_path_str) :] + [
971 config_path_str
972 ]
973 raise ValueError(
974 f"Circular reference detected in extends: {' -> '.join(cycle)}"
975 )
976 _in_progress.append(config_path_str)
978 inherited_scopes: list[dict[str, Any]] = []
979 inherited_aliases: dict[str, list[str]] = {}
980 inherited_lsc: dict[str, Any] | None = None
982 for extend_path in self.extends:
983 resolved_path = _resolve_extends_path(config_path_str, extend_path)
984 if resolved_path not in other_configs:
985 raise ValueError(
986 f"Config not found: '{extend_path}' (resolved to '{resolved_path}')"
987 )
988 if resolved_path in _seen:
989 # Already merged via another branch (diamond) — skip the dup.
990 continue
992 parent_data = other_configs[resolved_path]._merged_data(
993 Path(resolved_path), other_configs, _in_progress, _seen
994 )
995 inherited_scopes = inherited_scopes + parent_data["scopes"]
996 inherited_aliases = inherited_aliases | parent_data["aliases"]
997 inherited_lsc = inherited_lsc or parent_data["large_scale_change"]
999 merged = self.model_dump()
1000 merged["scopes"] = inherited_scopes + merged["scopes"]
1001 merged["aliases"] = inherited_aliases | merged["aliases"]
1002 merged["large_scale_change"] = merged["large_scale_change"] or inherited_lsc
1004 # Tag each scope with the directory its paths should anchor at. A scope's
1005 # paths are relative to the config that owns it, so the first
1006 # non-template config to consume a scope claims it: a non-template's own
1007 # scopes (and any it inherits from a template) anchor at its directory,
1008 # while a template defers to its consumer. `setdefault` means an
1009 # already-tagged scope (from a non-template ancestor) keeps its anchor.
1010 if not self.template:
1011 base_dir = posixpath.dirname(config_path_str)
1012 for entry in merged["scopes"]:
1013 entry.setdefault("_anchor_dir", base_dir)
1015 _seen.add(config_path_str)
1016 _in_progress.pop()
1018 return merged
1020 @classmethod
1021 def from_filesystem(cls, path: Path | str) -> ConfigModel:
1022 with open(path, "rb") as f:
1023 return cls.from_data(tomllib.load(f), path)
1025 @classmethod
1026 def from_content(cls, content: str, path: Path | str) -> ConfigModel:
1027 return cls.from_data(tomllib.loads(content), path)
1029 @classmethod
1030 def from_data(cls, data: dict[str, Any], path: Path | str) -> ConfigModel:
1031 return cls(**data)
1034class _ConfigModelsBase(RootModel):
1035 """Shared storage and accessors for a set of configs keyed by repo path."""
1037 root: dict[str, ConfigModel]
1039 @classmethod
1040 def from_config_models(cls, models: dict[str, ConfigModel]) -> Self:
1041 """Build from a dict of already-constructed configs keyed by path."""
1042 configs = cls(root={})
1043 for path, config_model in models.items():
1044 configs.root[str(Path(path))] = config_model
1045 return configs
1047 def get_config_models(self) -> dict[str, ConfigModel]:
1048 return dict(self.root.items())
1050 def __bool__(self) -> bool:
1051 return bool(self.root)
1053 def __getitem__(self, key: str) -> ConfigModel:
1054 return self.root[key]
1056 def __contains__(self, key: str) -> bool:
1057 return key in self.root
1059 def __len__(self) -> int:
1060 return len(self.root)
1062 def closest_config_path(self, file_path: Path) -> str | None:
1063 """The path of the closest non-template config governing this file,
1064 or None when nothing governs it."""
1065 for parent in file_path.parents:
1066 parent_config_path = str(parent / CONFIG_FILENAME)
1067 config = self.root.get(parent_config_path)
1068 if config is not None and not config.template:
1069 return parent_config_path
1070 return None
1073class ConfigModels(_ConfigModelsBase):
1074 """Configs exactly as loaded from the repo — extends unresolved, aliases
1075 unexpanded, paths unanchored. Build the set up, then call `compiled()`."""
1077 def declared_check_names(self) -> set[str]:
1078 """Every check-run name an effective (non-template) scope's `unless`
1079 references, across the whole config set.
1081 Collected from an offline compile (`teams=None`, the `team_refs`
1082 precedent) so a template's refs count only where a consumer actually
1083 inherits them -- an unconsumed template must not make every pull
1084 request fetch (or error on) checks that nothing evaluates. `unless`
1085 refs are literal strings (aliases and teams never expand inside
1086 them), so the offline compile is exact. Empty for the common case (no
1087 `unless` anywhere), which is what lets the processor skip fetching
1088 check runs entirely.
1089 """
1090 return {
1091 name
1092 for config in self.compiled().root.values()
1093 if not config.template
1094 for scope in config.scopes
1095 for _, name in scope.unless_refs()
1096 }
1098 @classmethod
1099 def from_configs_data(cls, data: dict[str, Any]) -> ConfigModels:
1100 """Load configs from a dict of parsed config data keyed by path."""
1101 configs = cls(root={})
1103 for path, config_data in data.items():
1104 config = ConfigModel.from_data(config_data, Path(path))
1105 configs.add_config(config, Path(path))
1107 return configs
1109 @classmethod
1110 def from_contents(cls, contents: dict[str, str]) -> ConfigModels:
1111 """Load configs from a dict of raw TOML content keyed by path."""
1112 configs = cls(root={})
1114 for path, content in contents.items():
1115 configs.add_config(ConfigModel.from_content(content, path), Path(path))
1117 return configs
1119 def add_config(self, config: ConfigModel, path: Path) -> None:
1120 self.root[str(path)] = config
1122 def team_refs(self) -> set[str]:
1123 """Collect every team ref (lowercase, no leading `@`/`!`) that
1124 `compiled(teams=...)` would actually try to expand: refs written
1125 directly in a user-list field (scopes' `USER_LIST_FIELDS` and
1126 `large_scale_change.reviewers`/`alternates`), plus any refs reachable from those
1127 fields through `$alias`/`!$alias` chains.
1129 Meant for callers that need to know which teams to fetch/sync before
1130 calling `compiled(teams=...)`.
1132 Implemented as an offline compile (`teams=None`): aliases expand but
1133 team refs pass through unexpanded, so whatever refs remain in the
1134 compiled user-list fields are — by construction — exactly the refs a
1135 real compile will try to expand. A ref that only appears in a
1136 non-user-list field (e.g. an npm-style `@vendor/pkg/**` in `paths`)
1137 or inside an alias nothing references never survives into a compiled
1138 user-list field, so it is never collected. Raises the same config
1139 errors `compiled()` would (unknown alias, circular refs, ...), just
1140 earlier.
1141 """
1142 # Cheap pre-check: a team ref can only enter a compile as a literal
1143 # `@`/`!@` value in a user-list field or an alias value. Most repos
1144 # have none, and skipping the compile keeps this near-free for them.
1145 candidate_lists: list[list[str]] = []
1146 for config in self.root.values():
1147 candidate_lists.extend(config.aliases.values())
1148 for scope in config.scopes:
1149 for field in USER_LIST_FIELDS:
1150 candidate_lists.append(getattr(scope, field))
1151 if config.large_scale_change:
1152 candidate_lists.append(config.large_scale_change.reviewers)
1153 candidate_lists.append(config.large_scale_change.alternates)
1154 if not any(
1155 _split_team_ref(value) is not None
1156 for values in candidate_lists
1157 for value in values
1158 ):
1159 return set()
1161 refs: set[str] = set()
1163 def collect_refs(values: list[str]) -> None:
1164 for value in values:
1165 if (split := _split_team_ref(value)) is not None:
1166 refs.add(split[1].lower())
1168 for config in self.compiled(teams=None).get_config_models().values():
1169 if config.template:
1170 continue
1171 for scope in config.scopes:
1172 for field in USER_LIST_FIELDS:
1173 collect_refs(getattr(scope, field))
1174 if config.large_scale_change:
1175 collect_refs(config.large_scale_change.reviewers)
1176 collect_refs(config.large_scale_change.alternates)
1178 return refs
1180 def compiled(
1181 self, teams: dict[str, list[str]] | None = None
1182 ) -> CompiledConfigModels:
1183 """Resolve the whole set into its effective, PR-independent form.
1185 Each non-template config is compiled once — extends merged, aliases
1186 expanded, paths anchored. Templates are NOT compiled standalone: a
1187 template scope may reference an alias the consuming config provides, and
1188 its paths anchor at the consumer. They are carried through untouched
1189 (folded into each consumer during that consumer's compile, and kept in
1190 the set for display).
1192 `teams` maps team refs (any case, without the leading `@`) to member
1193 usernames; passed straight through to each config's `compiled_config`
1194 (see there for case normalization and the `teams=None` vs provided
1195 semantics).
1197 The result is an immutable `CompiledConfigModels` — there is no way to
1198 compile it again, so the non-idempotent path anchoring can never
1199 double-apply.
1200 """
1201 effective: dict[str, ConfigModel] = {}
1202 for path, config in self.root.items():
1203 if config.template:
1204 effective[path] = config
1205 else:
1206 effective[path] = config.compiled_config(
1207 config_path=Path(path), other_configs=self, teams=teams
1208 )
1210 return CompiledConfigModels.from_config_models(effective)
1213class CompiledConfigModels(_ConfigModelsBase):
1214 """The effective configs used for matching: every non-template config is
1215 fully resolved. Produced by `ConfigModels.compiled()`; never recompiled."""
1217 def closest_config(self, file_path: Path) -> ConfigModel:
1218 """Return the closest non-template config governing this file."""
1219 config_path = self.closest_config_path(file_path)
1220 if config_path is None:
1221 raise ValueError(f"No config found for {file_path}")
1222 return self.root[config_path]
1224 def get_default_large_scale_change(self) -> LargeScaleChangeModel:
1225 """The primary (repo-root) config's large-scale-change section, if any.
1227 The primary was compiled by `compiled()`, so its reviewers/labels are
1228 already alias-expanded (e.g. ["$backend"] -> usernames). A `template =
1229 true` repo root is a misconfiguration (templates are meant to be
1230 extended, not be the primary); it is passed through uncompiled, so its
1231 LSC would read with aliases unexpanded.
1232 """
1233 if CONFIG_FILENAME in self.root and (
1234 lsc := self.root[CONFIG_FILENAME].large_scale_change
1235 ):
1236 return lsc
1238 return LargeScaleChangeModel()
1240 def filter_for_pullrequest(self, author_username: str) -> CompiledConfigModels:
1241 """
1242 Overlay PR-dependent scope gating: drop scopes that author rules disable
1243 for this pull request.
1245 This is the only PR-dependent step. The configs are already compiled, so
1246 each config's scopes are self-contained and dropping one is a plain list
1247 filter — no re-inheritance. Templates are passed through (they are never
1248 matched directly; their scopes already live in each consumer).
1249 """
1250 effective: dict[str, ConfigModel] = {}
1251 for config_path, config in self.root.items():
1252 if config.template:
1253 # Templates are never matched directly; pass them through.
1254 effective[config_path] = config
1255 continue
1257 kept_scopes = [
1258 scope
1259 for scope in config.scopes
1260 if scope.matches_author(author_username)
1261 ]
1262 effective[config_path] = config.model_copy(update={"scopes": kept_scopes})
1264 return CompiledConfigModels.from_config_models(effective)