Coverage for extra_codeowners/models.py: 86%
385 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"""Typed, immutable models shared by the Extra CODEOWNERS policy engine.
3The models in this module deliberately contain no GitHub or network behavior. A
4caller is expected to fetch repository state, resolve team membership, and then
5provide that evidence to :class:`EvaluationInput`.
6"""
8from __future__ import annotations
10import html
11import re
12import string
13import tomllib
14import unicodedata
15from datetime import datetime
16from enum import StrEnum
17from typing import Annotated, Any, Literal, Self
19from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
21SCHEMA_VERSION: Literal[1] = 1
22StrictPositiveInt = Annotated[int, Field(strict=True, gt=0)]
24_APP_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
25_APP_ALIAS_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$")
26_OWNER_RE = re.compile(
27 r"^@[a-z0-9][a-z0-9_.-]*(?:/[a-z0-9][a-z0-9_.-]*)?$",
28 re.IGNORECASE,
29)
30_MARKDOWN_META = frozenset(string.punctuation)
33def _visible_text(value: str) -> str:
34 """Render controls explicitly so untrusted evidence cannot forge layout."""
35 rendered: list[str] = []
36 for character in value:
37 category = unicodedata.category(character)
38 if character == "\n":
39 rendered.append(r"\n")
40 elif character == "\r": 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 rendered.append(r"\r")
42 elif character == "\t": 42 ↛ 43line 42 didn't jump to line 43 because the condition on line 42 was never true
43 rendered.append(r"\t")
44 elif category in {"Cc", "Cf", "Zl", "Zp"}:
45 rendered.append(f"<U+{ord(character):04X}>")
46 else:
47 rendered.append(character)
48 return "".join(rendered)
51def _markdown_text(value: str) -> str:
52 """Escape untrusted text for a Markdown prose position."""
53 return "".join(
54 f"\\{character}" if character in _MARKDOWN_META else character
55 for character in _visible_text(value)
56 )
59def _markdown_code(value: str) -> str:
60 """Render untrusted text in an HTML code element without delimiter ambiguity."""
61 return f"<code>{html.escape(_visible_text(value), quote=True)}</code>"
64def normalize_owner(value: str) -> str:
65 """Return a canonical CODEOWNERS identity or raise ``ValueError``.
67 GitHub identity comparisons are case-insensitive. Email CODEOWNERS entries
68 are intentionally unsupported because they cannot be resolved safely to a
69 review actor.
70 """
72 owner = value.strip().lower()
73 if "@" in owner and not owner.startswith("@"):
74 msg = f"email CODEOWNER {value!r} is unsupported; use an @user or @org/team"
75 raise ValueError(msg)
76 if not _OWNER_RE.fullmatch(owner): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 msg = f"invalid CODEOWNER identity {value!r}; expected @user or @org/team"
78 raise ValueError(msg)
79 return owner
82def normalize_repository_path(value: str) -> str:
83 """Normalize a GitHub repository-relative path and reject unsafe forms."""
85 # Repository filenames are exact, case-sensitive evidence. Never normalize
86 # one path into another. Edge whitespace is rejected because the supported
87 # CODEOWNERS parser cannot represent it unambiguously.
88 path = value
89 if not path: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true
90 raise ValueError("repository path must not be empty")
91 if path != path.strip():
92 raise ValueError("repository paths with leading or trailing whitespace are unsupported")
93 if path.startswith("/") or "\\" in path: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise ValueError("repository paths must be relative POSIX paths")
95 parts = path.split("/")
96 if any(part in {"", ".", ".."} for part in parts): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 raise ValueError("repository paths must not contain empty, '.' or '..' segments")
98 return path
101def _normalize_pattern(value: str) -> str:
102 pattern = value.strip()
103 if not pattern: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 raise ValueError("path pattern must not be empty")
105 if "\\" in pattern: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 raise ValueError("path patterns must use POSIX '/' separators")
107 return pattern
110def _normalize_label(value: str) -> str:
111 label = value.strip().lower()
112 if not label: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 raise ValueError("label must not be empty")
114 return label
117class StrictModel(BaseModel):
118 """Base class for policy data that must reject misspelled fields."""
120 model_config = ConfigDict(extra="forbid", frozen=True)
123class EnrolledApplication(StrictModel):
124 """A GitHub App identity trusted by organization policy."""
126 slug: str
127 app_id: StrictPositiveInt
128 bot_user_id: StrictPositiveInt
130 @field_validator("slug")
131 @classmethod
132 def validate_slug(cls, value: str) -> str:
133 slug = value.strip().lower()
134 if not _APP_SLUG_RE.fullmatch(slug): 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 raise ValueError("GitHub App slug must contain lowercase letters, digits, and hyphens")
136 return slug
139class Guardrails(StrictModel):
140 """Organization-enforced restrictions repositories cannot weaken."""
142 non_delegable_paths: tuple[str, ...] = Field(default=(), max_length=100)
144 @field_validator("non_delegable_paths")
145 @classmethod
146 def validate_non_delegable_paths(cls, value: tuple[str, ...]) -> tuple[str, ...]:
147 return tuple(_normalize_pattern(pattern) for pattern in value)
150class OrganizationPolicy(StrictModel):
151 """Organization-owned application enrollment and mandatory guardrails."""
153 schema_version: Literal[1] = SCHEMA_VERSION
154 apps: dict[str, EnrolledApplication] = Field(default_factory=dict, max_length=50)
155 guardrails: Guardrails = Field(default_factory=Guardrails)
157 @field_validator("schema_version", mode="before")
158 @classmethod
159 def validate_schema_version_type(cls, value: Any) -> Any:
160 if type(value) is not int: # bool is an int subclass and must not select a schema.
161 raise ValueError("schema_version must be the integer 1")
162 return value
164 @field_validator("apps", mode="before")
165 @classmethod
166 def normalize_app_aliases(cls, value: Any) -> Any:
167 if not isinstance(value, dict): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 return value
169 normalized: dict[str, Any] = {}
170 for raw_alias, app in value.items():
171 alias = str(raw_alias).strip().lower()
172 if not _APP_ALIAS_RE.fullmatch(alias): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 raise ValueError(f"invalid application alias {raw_alias!r}")
174 if alias in normalized: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 raise ValueError(f"duplicate application alias {alias!r}")
176 normalized[alias] = app
177 return normalized
179 @model_validator(mode="after")
180 def identities_are_unique(self) -> Self:
181 seen_app_ids: set[int] = set()
182 seen_bot_ids: set[int] = set()
183 seen_slugs: set[str] = set()
184 for alias, app in self.apps.items():
185 if app.app_id in seen_app_ids:
186 raise ValueError(f"duplicate GitHub App id {app.app_id} (at {alias!r})")
187 if app.bot_user_id in seen_bot_ids: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 raise ValueError(f"duplicate GitHub bot user id {app.bot_user_id} (at {alias!r})")
189 if app.slug in seen_slugs: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 raise ValueError(f"duplicate GitHub App slug {app.slug!r}")
191 seen_app_ids.add(app.app_id)
192 seen_bot_ids.add(app.bot_user_id)
193 seen_slugs.add(app.slug)
194 return self
196 @classmethod
197 def from_toml(cls, content: str | bytes) -> Self:
198 """Parse and validate an organization policy TOML document."""
200 raw = content.encode() if isinstance(content, str) else content
201 values = tomllib.loads(raw.decode())
202 if "schema_version" not in values:
203 raise ValueError("organization policy requires schema_version")
204 return cls.model_validate(values)
207class Delegation(StrictModel):
208 """Allow one enrolled application to stand in for selected CODEOWNERS.
210 ``for_owners`` follows GitHub CODEOWNERS alternatives: matching any listed
211 owner is sufficient. ``["*"]`` must be written explicitly to delegate for
212 every owner; an omitted or empty list is rejected.
213 """
215 app: str
216 paths: tuple[str, ...] = Field(min_length=1, max_length=100)
217 for_owners: tuple[str, ...] = Field(min_length=1, max_length=100)
218 required_labels: frozenset[str] = Field(default_factory=frozenset, max_length=50)
219 forbidden_labels: frozenset[str] = Field(default_factory=frozenset, max_length=50)
221 @field_validator("app")
222 @classmethod
223 def normalize_app(cls, value: str) -> str:
224 alias = value.strip().lower()
225 if not _APP_ALIAS_RE.fullmatch(alias): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 raise ValueError("delegation app must be a valid application alias")
227 return alias
229 @field_validator("paths")
230 @classmethod
231 def validate_paths(cls, value: tuple[str, ...]) -> tuple[str, ...]:
232 if not value: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true
233 raise ValueError("delegation paths must not be empty")
234 return tuple(_normalize_pattern(pattern) for pattern in value)
236 @field_validator("for_owners", mode="before")
237 @classmethod
238 def normalize_for_owners(cls, value: Any) -> Any:
239 if not isinstance(value, (list, tuple, set, frozenset)): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 return value
241 owners: list[str] = []
242 for raw_owner in value:
243 owner = str(raw_owner).strip()
244 owners.append("*" if owner == "*" else normalize_owner(owner))
245 return owners
247 @field_validator("for_owners")
248 @classmethod
249 def validate_for_owners(cls, value: tuple[str, ...]) -> tuple[str, ...]:
250 if not value: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 raise ValueError("for_owners must list owners or contain an explicit '*'")
252 if "*" in value and len(value) != 1:
253 raise ValueError("'*' cannot be combined with named CODEOWNERS")
254 if len(set(value)) != len(value): 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 raise ValueError("for_owners contains duplicate entries")
256 return value
258 @field_validator("required_labels", "forbidden_labels", mode="before")
259 @classmethod
260 def normalize_labels(cls, value: Any) -> Any:
261 if not isinstance(value, (list, tuple, set, frozenset)): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 return value
263 return frozenset(_normalize_label(str(label)) for label in value)
265 @model_validator(mode="after")
266 def label_sets_do_not_overlap(self) -> Self:
267 overlap = self.required_labels & self.forbidden_labels
268 if overlap:
269 raise ValueError(f"labels cannot be both required and forbidden: {sorted(overlap)!r}")
270 return self
273class RepositoryPolicy(StrictModel):
274 """Repository policy read from ``.github/extra-codeowners.toml``."""
276 schema_version: Literal[1] = SCHEMA_VERSION
277 enabled: StrictBool = False
278 delegations: tuple[Delegation, ...] = Field(default=(), max_length=100)
280 @field_validator("schema_version", mode="before")
281 @classmethod
282 def validate_schema_version_type(cls, value: Any) -> Any:
283 if type(value) is not int: # bool is an int subclass and must not select a schema.
284 raise ValueError("schema_version must be the integer 1")
285 return value
287 @model_validator(mode="after")
288 def delegation_complexity_is_bounded(self) -> Self:
289 if sum(len(delegation.paths) for delegation in self.delegations) > 1000: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 raise ValueError("repository policy may contain at most 1,000 delegation patterns")
291 return self
293 @classmethod
294 def from_toml(cls, content: str | bytes) -> Self:
295 """Parse and validate a repository policy TOML document."""
297 raw = content.encode() if isinstance(content, str) else content
298 values = tomllib.loads(raw.decode())
299 if "schema_version" not in values:
300 raise ValueError("repository policy requires schema_version")
301 if "enabled" not in values:
302 raise ValueError("repository policy requires an explicit enabled setting")
303 return cls.model_validate(values)
306class ChangedFileStatus(StrEnum):
307 ADDED = "added"
308 MODIFIED = "modified"
309 REMOVED = "removed"
310 RENAMED = "renamed"
311 COPIED = "copied"
312 CHANGED = "changed"
313 UNCHANGED = "unchanged"
316class ChangedFile(StrictModel):
317 """A changed PR file, including the prior name when GitHub reports a rename."""
319 path: str
320 status: ChangedFileStatus = ChangedFileStatus.MODIFIED
321 previous_path: str | None = None
323 @field_validator("path", "previous_path")
324 @classmethod
325 def validate_path(cls, value: str | None) -> str | None:
326 return None if value is None else normalize_repository_path(value)
328 @model_validator(mode="after")
329 def rename_has_previous_path(self) -> Self:
330 if self.status is ChangedFileStatus.RENAMED and self.previous_path is None:
331 raise ValueError(
332 "renamed files require previous_path so both ownership rules are evaluated"
333 )
334 if self.previous_path is not None and self.status is not ChangedFileStatus.RENAMED: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 raise ValueError("previous_path is only valid for renamed files")
336 if self.previous_path == self.path: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 raise ValueError("renamed file path and previous_path must differ")
338 return self
341class ActorKind(StrEnum):
342 HUMAN = "human"
343 APPLICATION = "application"
346class ReviewActor(StrictModel):
347 """A review actor plus caller-resolved CODEOWNER identities.
349 ``owner_aliases`` is the trusted result of resolving team membership. A
350 human's own ``@login`` identity is added automatically by the evaluator.
351 Application actors must carry all three enrolled identity attributes.
352 """
354 kind: ActorKind
355 login: str
356 user_id: StrictPositiveInt
357 app_id: StrictPositiveInt | None = None
358 app_slug: str | None = None
359 owner_aliases: frozenset[str] = frozenset()
360 direct_owner_eligible: StrictBool = True
362 @field_validator("login")
363 @classmethod
364 def validate_login(cls, value: str) -> str:
365 login = value.strip().lower()
366 if not login: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true
367 raise ValueError("review actor login must not be empty")
368 return login
370 @field_validator("app_slug")
371 @classmethod
372 def normalize_app_slug(cls, value: str | None) -> str | None:
373 if value is None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 return None
375 slug = value.strip().lower()
376 if not _APP_SLUG_RE.fullmatch(slug): 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
377 raise ValueError("invalid GitHub App slug on review actor")
378 return slug
380 @field_validator("owner_aliases", mode="before")
381 @classmethod
382 def normalize_owner_aliases(cls, value: Any) -> Any:
383 if not isinstance(value, (list, tuple, set, frozenset)): 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 return value
385 return frozenset(normalize_owner(str(owner)) for owner in value)
387 @model_validator(mode="after")
388 def validate_kind_fields(self) -> Self:
389 if self.kind is ActorKind.APPLICATION:
390 if self.app_id is None or self.app_slug is None:
391 raise ValueError("application review actors require app_id and app_slug")
392 if self.owner_aliases: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 raise ValueError("application review actors cannot carry human owner aliases")
394 elif self.app_id is not None or self.app_slug is not None: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 raise ValueError("human review actors cannot carry GitHub App identity fields")
396 return self
399class ReviewState(StrEnum):
400 APPROVED = "APPROVED"
401 CHANGES_REQUESTED = "CHANGES_REQUESTED"
402 COMMENTED = "COMMENTED"
403 DISMISSED = "DISMISSED"
404 PENDING = "PENDING"
407class PullRequestReview(StrictModel):
408 """Review evidence as returned by GitHub and normalized by the adapter."""
410 review_id: StrictPositiveInt
411 actor: ReviewActor
412 state: ReviewState
413 commit_sha: str | None
414 submitted_at: datetime
416 @field_validator("commit_sha")
417 @classmethod
418 def validate_commit_sha(cls, value: str | None) -> str | None:
419 if value is None: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true
420 return None
421 sha = value.strip().lower()
422 if not sha: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 raise ValueError("review commit_sha must not be blank")
424 return sha
426 @field_validator("submitted_at")
427 @classmethod
428 def require_timezone(cls, value: datetime) -> datetime:
429 if value.tzinfo is None or value.utcoffset() is None:
430 raise ValueError("submitted_at must include a timezone")
431 return value
434class EvaluationOptions(StrictModel):
435 """Runtime safety controls, normally populated from process settings."""
437 exact_head_reviews: bool = True
438 allow_insecure_changes: bool = False
439 repository_policy_path: str = ".github/extra-codeowners.toml"
441 @field_validator("repository_policy_path")
442 @classmethod
443 def validate_repository_policy_path(cls, value: str) -> str:
444 path = value.strip()
445 if ( 445 ↛ 452line 445 didn't jump to line 452 because the condition on line 445 was never true
446 not path
447 or path.startswith("/")
448 or "\\" in path
449 or any(part in {"", ".", ".."} for part in path.split("/"))
450 or any(character in path for character in "*?[]!")
451 ):
452 raise ValueError("repository_policy_path must be a literal relative POSIX path")
453 return path
456class EvaluationInput(StrictModel):
457 """Complete, network-free evidence needed for one PR evaluation."""
459 head_sha: str
460 codeowners_text: str
461 changed_files: tuple[ChangedFile, ...]
462 reviews: tuple[PullRequestReview, ...] = ()
463 labels: frozenset[str] = frozenset()
464 organization_policy: OrganizationPolicy
465 repository_policy: RepositoryPolicy
466 options: EvaluationOptions = Field(default_factory=EvaluationOptions)
468 @field_validator("head_sha")
469 @classmethod
470 def validate_head_sha(cls, value: str) -> str:
471 sha = value.strip().lower()
472 if not sha: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 raise ValueError("head_sha must not be empty")
474 return sha
476 @field_validator("labels", mode="before")
477 @classmethod
478 def normalize_input_labels(cls, value: Any) -> Any:
479 if not isinstance(value, (list, tuple, set, frozenset)): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true
480 return value
481 return frozenset(_normalize_label(str(label)) for label in value)
484class EvaluationConclusion(StrEnum):
485 SUCCESS = "success"
486 FAILURE = "failure"
489class EvaluationMessage(StrictModel):
490 """A stable machine code and human-readable check annotation."""
492 code: str
493 message: str
494 paths: tuple[str, ...] = ()
497class PathEvidence(StrictModel):
498 """How one path within an owner-set requirement was evaluated."""
500 path: str
501 non_delegable: bool
502 eligible_apps: tuple[str, ...] = ()
503 approved_apps: tuple[str, ...] = ()
504 explanation: str
507class OwnerSetResult(StrictModel):
508 """The result for one distinct CODEOWNERS owner set."""
510 owners: tuple[str, ...]
511 paths: tuple[str, ...]
512 satisfied: bool
513 satisfied_by: tuple[str, ...] = ()
514 explanation: str
515 path_evidence: tuple[PathEvidence, ...] = ()
518class EvaluationResult(StrictModel):
519 """Policy result ready to render into a GitHub Check Run."""
521 conclusion: EvaluationConclusion
522 summary: str
523 requirements: tuple[OwnerSetResult, ...] = ()
524 errors: tuple[EvaluationMessage, ...] = ()
525 warnings: tuple[EvaluationMessage, ...] = ()
526 unowned_paths: tuple[str, ...] = ()
528 @property
529 def passed(self) -> bool:
530 """Whether the result is an affirmative policy success."""
532 return self.conclusion is EvaluationConclusion.SUCCESS
534 def check_output(self) -> str:
535 """Render concise Markdown suitable for a GitHub Check Run body."""
537 lines = [_markdown_text(self.summary)]
538 if self.errors:
539 lines.extend(["", "### Errors"])
540 lines.extend(
541 f"- {_markdown_code(item.code)}: {_markdown_text(item.message)}"
542 for item in self.errors
543 )
544 if self.warnings:
545 lines.extend(["", "### Warnings"])
546 lines.extend(
547 f"- {_markdown_code(item.code)}: {_markdown_text(item.message)}"
548 for item in self.warnings
549 )
550 if self.requirements:
551 lines.extend(["", "### CODEOWNER requirements"])
552 for requirement in self.requirements:
553 marker = "✅" if requirement.satisfied else "❌"
554 owners = ", ".join(_markdown_code(owner) for owner in requirement.owners)
555 lines.append(f"- {marker} {owners}: {_markdown_text(requirement.explanation)}")
556 lines.extend(
557 f" - {_markdown_code(evidence.path)}: {_markdown_text(evidence.explanation)}"
558 for evidence in requirement.path_evidence
559 )
560 if self.unowned_paths:
561 lines.extend(["", "### Unowned paths"])
562 lines.extend(f"- {_markdown_code(path)}" for path in self.unowned_paths)
563 return "\n".join(lines)