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

1"""Compilation and path-level decisions for Extra CODEOWNERS policy.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from dataclasses import dataclass, field 

7 

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) 

17 

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) 

29 

30 

31@dataclass(frozen=True, slots=True) 

32class PolicyIssue: 

33 """A policy compilation problem with a stable machine code.""" 

34 

35 code: str 

36 message: str 

37 

38 

39class PolicyCompilationError(ValueError): 

40 """Raised when repository and organization policy cannot be combined.""" 

41 

42 def __init__(self, issues: tuple[PolicyIssue, ...]) -> None: 

43 self.issues = issues 

44 super().__init__("; ".join(issue.message for issue in issues)) 

45 

46 

47@dataclass(frozen=True, slots=True) 

48class DelegationDecision: 

49 """Eligibility of one delegation after path, owner, and label checks.""" 

50 

51 app_alias: str 

52 eligible: bool 

53 reasons: tuple[str, ...] 

54 

55 

56@dataclass(frozen=True, slots=True) 

57class CompiledDelegation: 

58 """A validated repository delegation and its enrolled application.""" 

59 

60 rule: Delegation 

61 application: EnrolledApplication 

62 _path_patterns: tuple[re.Pattern[str], ...] = field(init=False, repr=False, compare=False) 

63 

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 ) 

70 

71 def applies_to_path_and_owners(self, path: str, owners: frozenset[str]) -> bool: 

72 """Return whether the static path and owner selectors apply.""" 

73 

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 

78 

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.""" 

86 

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 ) 

101 

102 

103@dataclass(frozen=True, slots=True) 

104class CompiledPolicy: 

105 """Organization and repository policy validated as one trust boundary.""" 

106 

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 ) 

114 

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 ) 

121 

122 def is_non_delegable(self, path: str) -> bool: 

123 """Return whether application approvals are forbidden for ``path``.""" 

124 

125 normalized_path = normalize_repository_path(path) 

126 return any(pattern.fullmatch(normalized_path) for pattern in self._non_delegable_patterns) 

127 

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.""" 

135 

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) 

144 

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) 

155 

156 

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.""" 

163 

164 runtime = options or EvaluationOptions() 

165 issues: list[PolicyIssue] = [] 

166 

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 ) 

183 

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)) 

212 

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 )