Coverage for extra_codeowners/evaluator.py: 99%

133 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-13 09:46 +0000

1"""Pure evaluation of human-or-application CODEOWNER approval evidence.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Iterable 

6 

7from extra_codeowners.codeowners import CodeownersParseError, parse_codeowners 

8from extra_codeowners.models import ( 

9 ActorKind, 

10 EvaluationConclusion, 

11 EvaluationInput, 

12 EvaluationMessage, 

13 EvaluationResult, 

14 OwnerSetResult, 

15 PathEvidence, 

16 PullRequestReview, 

17 ReviewState, 

18) 

19from extra_codeowners.policy import PolicyCompilationError, compile_policy 

20 

21_OPINIONATED_STATES = { 

22 ReviewState.APPROVED, 

23 ReviewState.CHANGES_REQUESTED, 

24 ReviewState.DISMISSED, 

25} 

26 

27 

28def latest_opinionated_reviews( 

29 reviews: Iterable[PullRequestReview], 

30) -> tuple[PullRequestReview, ...]: 

31 """Return each actor's latest approval, rejection, or dismissal. 

32 

33 Comments do not supersede an approval. Dismissed reviews do, ensuring an 

34 older approval cannot become effective again after GitHub dismisses it. 

35 """ 

36 

37 latest: dict[tuple[ActorKind, int], PullRequestReview] = {} 

38 for review in reviews: 

39 if review.state not in _OPINIONATED_STATES: 

40 continue 

41 actor_key = (review.actor.kind, review.actor.user_id) 

42 previous = latest.get(actor_key) 

43 ordering = (review.submitted_at, review.review_id) 

44 if previous is None or ordering > (previous.submitted_at, previous.review_id): 44 ↛ 38line 44 didn't jump to line 38 because the condition on line 44 was always true

45 latest[actor_key] = review 

46 return tuple( 

47 sorted(latest.values(), key=lambda review: (review.submitted_at, review.review_id)) 

48 ) 

49 

50 

51def _evaluation_paths(data: EvaluationInput) -> tuple[str, ...]: 

52 paths: list[str] = [] 

53 for changed_file in data.changed_files: 

54 if changed_file.previous_path is not None: 

55 paths.append(changed_file.previous_path) 

56 paths.append(changed_file.path) 

57 return tuple(dict.fromkeys(paths)) 

58 

59 

60def _current_approvals( 

61 data: EvaluationInput, 

62) -> tuple[tuple[PullRequestReview, ...], tuple[EvaluationMessage, ...]]: 

63 approvals: list[PullRequestReview] = [] 

64 warnings: list[EvaluationMessage] = [] 

65 for review in latest_opinionated_reviews(data.reviews): 

66 if review.state is not ReviewState.APPROVED: 

67 continue 

68 if data.options.exact_head_reviews and review.commit_sha != data.head_sha: 

69 warnings.append( 

70 EvaluationMessage( 

71 code="stale_approval_ignored", 

72 message=( 

73 f"approval {review.review_id} from @{review.actor.login} does not target " 

74 "the current pull request head" 

75 ), 

76 ) 

77 ) 

78 continue 

79 approvals.append(review) 

80 return tuple(approvals), tuple(warnings) 

81 

82 

83def _recognized_app_approvals( 

84 data: EvaluationInput, 

85 approvals: Iterable[PullRequestReview], 

86) -> tuple[frozenset[str], tuple[EvaluationMessage, ...]]: 

87 approved: set[str] = set() 

88 warnings: list[EvaluationMessage] = [] 

89 for review in approvals: 

90 actor = review.actor 

91 if actor.kind is not ActorKind.APPLICATION: 

92 continue 

93 alias = next( 

94 ( 

95 app_alias 

96 for app_alias, enrolled in data.organization_policy.apps.items() 

97 if enrolled.app_id == actor.app_id 

98 and enrolled.slug == actor.app_slug 

99 and enrolled.bot_user_id == actor.user_id 

100 and actor.login == f"{enrolled.slug}[bot]" 

101 ), 

102 None, 

103 ) 

104 if alias is None: 

105 warnings.append( 

106 EvaluationMessage( 

107 code="untrusted_application_review_ignored", 

108 message=( 

109 f"approval {review.review_id} from @{actor.login} did not match an " 

110 "organization-enrolled app_id, slug, bot login, and bot_user_id" 

111 ), 

112 ) 

113 ) 

114 continue 

115 approved.add(alias) 

116 return frozenset(approved), tuple(warnings) 

117 

118 

119def _human_approvals_by_owner( 

120 approvals: Iterable[PullRequestReview], 

121) -> tuple[tuple[PullRequestReview, frozenset[str]], ...]: 

122 resolved: list[tuple[PullRequestReview, frozenset[str]]] = [] 

123 for review in approvals: 

124 if review.actor.kind is not ActorKind.HUMAN: 

125 continue 

126 identities = set(review.actor.owner_aliases) 

127 if review.actor.direct_owner_eligible: 

128 identities.add(f"@{review.actor.login.lower()}") 

129 resolved.append((review, frozenset(identities))) 

130 return tuple(resolved) 

131 

132 

133def _error_result( 

134 messages: tuple[EvaluationMessage, ...], 

135 *, 

136 warnings: tuple[EvaluationMessage, ...] = (), 

137) -> EvaluationResult: 

138 return EvaluationResult( 

139 conclusion=EvaluationConclusion.FAILURE, 

140 summary="Extra CODEOWNERS could not evaluate safely; approval is denied.", 

141 errors=messages, 

142 warnings=warnings, 

143 ) 

144 

145 

146def evaluate(data: EvaluationInput) -> EvaluationResult: 

147 """Evaluate one pull request using only supplied, already-fetched evidence.""" 

148 

149 if not data.repository_policy.enabled: 

150 return EvaluationResult( 

151 conclusion=EvaluationConclusion.FAILURE, 

152 summary="Repository policy is missing or disabled; approval is denied.", 

153 errors=( 

154 EvaluationMessage( 

155 code="repository_policy_disabled", 

156 message=( 

157 "repository policy is missing or enabled=false; remove the required " 

158 "check from the ruleset before disabling Extra CODEOWNERS" 

159 ), 

160 ), 

161 ), 

162 ) 

163 

164 try: 

165 document = parse_codeowners(data.codeowners_text) 

166 except CodeownersParseError as error: 

167 return _error_result( 

168 tuple( 

169 EvaluationMessage(code=issue.code, message=issue.render()) for issue in error.issues 

170 ) 

171 ) 

172 

173 try: 

174 policy = compile_policy( 

175 data.organization_policy, 

176 data.repository_policy, 

177 data.options, 

178 ) 

179 except PolicyCompilationError as error: 

180 return _error_result( 

181 tuple( 

182 EvaluationMessage(code=issue.code, message=issue.message) for issue in error.issues 

183 ) 

184 ) 

185 

186 warnings: list[EvaluationMessage] = [] 

187 if data.options.allow_insecure_changes: 

188 warnings.append( 

189 EvaluationMessage( 

190 code="insecure_changes_enabled", 

191 message=( 

192 "the runtime allow-insecure-changes escape hatch disabled built-in " 

193 "non-delegable paths; organization-added paths remain enforced" 

194 ), 

195 ) 

196 ) 

197 

198 approvals, approval_warnings = _current_approvals(data) 

199 warnings.extend(approval_warnings) 

200 approved_apps, app_warnings = _recognized_app_approvals(data, approvals) 

201 warnings.extend(app_warnings) 

202 human_approvals = _human_approvals_by_owner(approvals) 

203 

204 owner_paths: dict[tuple[str, ...], list[str]] = {} 

205 unowned_paths: list[str] = [] 

206 for path in _evaluation_paths(data): 

207 owners = tuple(sorted(document.owners_for(path))) 

208 if not owners: 

209 unowned_paths.append(path) 

210 continue 

211 paths = owner_paths.setdefault(owners, []) 

212 if path not in paths: 212 ↛ 206line 212 didn't jump to line 206 because the condition on line 212 was always true

213 paths.append(path) 

214 

215 requirements: list[OwnerSetResult] = [] 

216 for owners, paths in owner_paths.items(): 

217 owner_set = frozenset(owners) 

218 satisfying_humans = [ 

219 review for review, identities in human_approvals if identities & owner_set 

220 ] 

221 if satisfying_humans: 

222 actors = tuple(f"human:@{review.actor.login}" for review in satisfying_humans) 

223 requirements.append( 

224 OwnerSetResult( 

225 owners=owners, 

226 paths=tuple(paths), 

227 satisfied=True, 

228 satisfied_by=actors, 

229 explanation=( 

230 "approved by human CODEOWNER " 

231 + ", ".join(f"@{review.actor.login}" for review in satisfying_humans) 

232 ), 

233 path_evidence=tuple( 

234 PathEvidence( 

235 path=path, 

236 non_delegable=policy.is_non_delegable(path), 

237 explanation="covered by the human CODEOWNER approval", 

238 ) 

239 for path in paths 

240 ), 

241 ) 

242 ) 

243 continue 

244 

245 path_evidence: list[PathEvidence] = [] 

246 approving_apps: set[str] = set() 

247 all_paths_satisfied = True 

248 for path in paths: 

249 if policy.is_non_delegable(path): 

250 all_paths_satisfied = False 

251 path_evidence.append( 

252 PathEvidence( 

253 path=path, 

254 non_delegable=True, 

255 explanation=( 

256 "application delegation is disabled for this security-sensitive path" 

257 ), 

258 ) 

259 ) 

260 continue 

261 

262 decisions = policy.delegation_decisions(path, owner_set, data.labels) 

263 eligible_apps = tuple(decision.app_alias for decision in decisions if decision.eligible) 

264 path_approvals = tuple(sorted(set(eligible_apps) & approved_apps)) 

265 if path_approvals: 

266 approving_apps.update(path_approvals) 

267 explanation = "covered by approved application " + ", ".join(path_approvals) 

268 else: 

269 all_paths_satisfied = False 

270 if eligible_apps: 

271 explanation = "eligible application approval is missing: " + ", ".join( 

272 eligible_apps 

273 ) 

274 elif decisions: 

275 blocked = "; ".join( 

276 f"{decision.app_alias}: {', '.join(decision.reasons)}" 

277 for decision in decisions 

278 ) 

279 explanation = "delegation label conditions were not met: " + blocked 

280 else: 

281 explanation = "no application delegation covers this path and owner set" 

282 path_evidence.append( 

283 PathEvidence( 

284 path=path, 

285 non_delegable=False, 

286 eligible_apps=eligible_apps, 

287 approved_apps=path_approvals, 

288 explanation=explanation, 

289 ) 

290 ) 

291 

292 if all_paths_satisfied: 

293 aliases = tuple(sorted(approving_apps)) 

294 requirements.append( 

295 OwnerSetResult( 

296 owners=owners, 

297 paths=tuple(paths), 

298 satisfied=True, 

299 satisfied_by=tuple(f"app:{alias}" for alias in aliases), 

300 explanation="all paths are covered by approved application delegation", 

301 path_evidence=tuple(path_evidence), 

302 ) 

303 ) 

304 else: 

305 requirements.append( 

306 OwnerSetResult( 

307 owners=owners, 

308 paths=tuple(paths), 

309 satisfied=False, 

310 explanation=( 

311 "requires an approval from a listed human CODEOWNER or an eligible " 

312 "application for every path" 

313 ), 

314 path_evidence=tuple(path_evidence), 

315 ) 

316 ) 

317 

318 failed = sum(not requirement.satisfied for requirement in requirements) 

319 if failed: 

320 conclusion = EvaluationConclusion.FAILURE 

321 summary = ( 

322 f"{failed} of {len(requirements)} distinct CODEOWNER requirements are unsatisfied." 

323 ) 

324 elif requirements: 

325 conclusion = EvaluationConclusion.SUCCESS 

326 summary = f"All {len(requirements)} distinct CODEOWNER requirements are satisfied." 

327 else: 

328 conclusion = EvaluationConclusion.SUCCESS 

329 summary = "No changed path has a CODEOWNER requirement." 

330 

331 return EvaluationResult( 

332 conclusion=conclusion, 

333 summary=summary, 

334 requirements=tuple(requirements), 

335 warnings=tuple(warnings), 

336 unowned_paths=tuple(unowned_paths), 

337 )