Coverage for extra_codeowners/policy.py: 98%
95 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-13 09:46 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-13 09:46 +0000
1"""Compilation and path-level decisions for Extra CODEOWNERS policy."""
3from __future__ import annotations
5import re
6from dataclasses import dataclass, field
8from extra_codeowners.codeowners import compile_pattern, validate_pattern
9from extra_codeowners.models import (
10 Delegation,
11 EnrolledApplication,
12 EvaluationOptions,
13 OrganizationPolicy,
14 RepositoryPolicy,
15 normalize_repository_path,
16)
18# These paths define or execute the approval boundary. Application delegation
19# is disabled for them unless an operator explicitly enables the process-level
20# insecure escape hatch. Organization-added paths are never removed by it.
21BUILTIN_NON_DELEGABLE_PATHS: tuple[str, ...] = (
22 "/CODEOWNERS",
23 "/.github/CODEOWNERS",
24 "/docs/CODEOWNERS",
25 "/stampbot.toml",
26 "/.github/workflows/**",
27 "/.github/actions/**",
28)
31@dataclass(frozen=True, slots=True)
32class PolicyIssue:
33 """A policy compilation problem with a stable machine code."""
35 code: str
36 message: str
39class PolicyCompilationError(ValueError):
40 """Raised when repository and organization policy cannot be combined."""
42 def __init__(self, issues: tuple[PolicyIssue, ...]) -> None:
43 self.issues = issues
44 super().__init__("; ".join(issue.message for issue in issues))
47@dataclass(frozen=True, slots=True)
48class DelegationDecision:
49 """Eligibility of one delegation after path, owner, and label checks."""
51 app_alias: str
52 eligible: bool
53 reasons: tuple[str, ...]
56@dataclass(frozen=True, slots=True)
57class CompiledDelegation:
58 """A validated repository delegation and its enrolled application."""
60 rule: Delegation
61 application: EnrolledApplication
62 _path_patterns: tuple[re.Pattern[str], ...] = field(init=False, repr=False, compare=False)
64 def __post_init__(self) -> None:
65 object.__setattr__(
66 self,
67 "_path_patterns",
68 tuple(compile_pattern(pattern) for pattern in self.rule.paths),
69 )
71 def applies_to_path_and_owners(self, path: str, owners: frozenset[str]) -> bool:
72 """Return whether the static path and owner selectors apply."""
74 normalized_path = normalize_repository_path(path)
75 path_matches = any(pattern.fullmatch(normalized_path) for pattern in self._path_patterns)
76 owner_matches = "*" in self.rule.for_owners or bool(owners & set(self.rule.for_owners))
77 return path_matches and owner_matches
79 def decide(
80 self,
81 path: str,
82 owners: frozenset[str],
83 labels: frozenset[str],
84 ) -> DelegationDecision | None:
85 """Evaluate labels for an otherwise matching path/owner delegation."""
87 if not self.applies_to_path_and_owners(path, owners):
88 return None
89 reasons: list[str] = []
90 missing = self.rule.required_labels - labels
91 forbidden = self.rule.forbidden_labels & labels
92 if missing:
93 reasons.append(f"missing required labels: {', '.join(sorted(missing))}")
94 if forbidden:
95 reasons.append(f"forbidden labels present: {', '.join(sorted(forbidden))}")
96 return DelegationDecision(
97 app_alias=self.rule.app,
98 eligible=not reasons,
99 reasons=tuple(reasons),
100 )
103@dataclass(frozen=True, slots=True)
104class CompiledPolicy:
105 """Organization and repository policy validated as one trust boundary."""
107 repository: RepositoryPolicy
108 organization: OrganizationPolicy
109 delegations: tuple[CompiledDelegation, ...]
110 non_delegable_patterns: tuple[str, ...]
111 _non_delegable_patterns: tuple[re.Pattern[str], ...] = field(
112 init=False, repr=False, compare=False
113 )
115 def __post_init__(self) -> None:
116 object.__setattr__(
117 self,
118 "_non_delegable_patterns",
119 tuple(compile_pattern(pattern) for pattern in self.non_delegable_patterns),
120 )
122 def is_non_delegable(self, path: str) -> bool:
123 """Return whether application approvals are forbidden for ``path``."""
125 normalized_path = normalize_repository_path(path)
126 return any(pattern.fullmatch(normalized_path) for pattern in self._non_delegable_patterns)
128 def delegation_decisions(
129 self,
130 path: str,
131 owners: frozenset[str],
132 labels: frozenset[str],
133 ) -> tuple[DelegationDecision, ...]:
134 """Return all relevant delegation decisions, coalesced by app alias."""
136 decisions = [
137 decision
138 for delegation in self.delegations
139 if (decision := delegation.decide(path, owners, labels)) is not None
140 ]
141 by_app: dict[str, list[DelegationDecision]] = {}
142 for decision in decisions:
143 by_app.setdefault(decision.app_alias, []).append(decision)
145 coalesced: list[DelegationDecision] = []
146 for alias, app_decisions in sorted(by_app.items()):
147 if any(decision.eligible for decision in app_decisions):
148 coalesced.append(DelegationDecision(alias, True, ()))
149 continue
150 reasons = tuple(
151 dict.fromkeys(reason for decision in app_decisions for reason in decision.reasons)
152 )
153 coalesced.append(DelegationDecision(alias, False, reasons))
154 return tuple(coalesced)
157def compile_policy(
158 organization: OrganizationPolicy,
159 repository: RepositoryPolicy,
160 options: EvaluationOptions | None = None,
161) -> CompiledPolicy:
162 """Validate cross-file references and construct effective safety policy."""
164 runtime = options or EvaluationOptions()
165 issues: list[PolicyIssue] = []
167 builtins = (
168 ()
169 if runtime.allow_insecure_changes
170 else (*BUILTIN_NON_DELEGABLE_PATHS, f"/{runtime.repository_policy_path}")
171 )
172 non_delegable_patterns = builtins + organization.guardrails.non_delegable_paths
173 for pattern in non_delegable_patterns:
174 try:
175 validate_pattern(pattern)
176 except ValueError as error:
177 issues.append(
178 PolicyIssue(
179 code="invalid_non_delegable_pattern",
180 message=f"invalid non-delegable path {pattern!r}: {error}",
181 )
182 )
184 compiled_delegations: list[CompiledDelegation] = []
185 for index, delegation in enumerate(repository.delegations, start=1):
186 application = organization.apps.get(delegation.app)
187 if application is None:
188 issues.append(
189 PolicyIssue(
190 code="unenrolled_application",
191 message=(
192 f"delegation {index} references application alias "
193 f"{delegation.app!r}, which organization policy has not enrolled"
194 ),
195 )
196 )
197 continue
198 invalid = False
199 for pattern in delegation.paths:
200 try:
201 validate_pattern(pattern)
202 except ValueError as error:
203 issues.append(
204 PolicyIssue(
205 code="invalid_delegation_pattern",
206 message=f"delegation {index} has invalid path {pattern!r}: {error}",
207 )
208 )
209 invalid = True
210 if not invalid:
211 compiled_delegations.append(CompiledDelegation(delegation, application))
213 if issues:
214 raise PolicyCompilationError(tuple(issues))
215 return CompiledPolicy(
216 repository=repository,
217 organization=organization,
218 delegations=tuple(compiled_delegations),
219 non_delegable_patterns=non_delegable_patterns,
220 )