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

1"""Typed, immutable models shared by the Extra CODEOWNERS policy engine. 

2 

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

7 

8from __future__ import annotations 

9 

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 

18 

19from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator 

20 

21SCHEMA_VERSION: Literal[1] = 1 

22StrictPositiveInt = Annotated[int, Field(strict=True, gt=0)] 

23 

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) 

31 

32 

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) 

49 

50 

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 ) 

57 

58 

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

62 

63 

64def normalize_owner(value: str) -> str: 

65 """Return a canonical CODEOWNERS identity or raise ``ValueError``. 

66 

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

71 

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 

80 

81 

82def normalize_repository_path(value: str) -> str: 

83 """Normalize a GitHub repository-relative path and reject unsafe forms.""" 

84 

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 

99 

100 

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 

108 

109 

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 

115 

116 

117class StrictModel(BaseModel): 

118 """Base class for policy data that must reject misspelled fields.""" 

119 

120 model_config = ConfigDict(extra="forbid", frozen=True) 

121 

122 

123class EnrolledApplication(StrictModel): 

124 """A GitHub App identity trusted by organization policy.""" 

125 

126 slug: str 

127 app_id: StrictPositiveInt 

128 bot_user_id: StrictPositiveInt 

129 

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 

137 

138 

139class Guardrails(StrictModel): 

140 """Organization-enforced restrictions repositories cannot weaken.""" 

141 

142 non_delegable_paths: tuple[str, ...] = Field(default=(), max_length=100) 

143 

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) 

148 

149 

150class OrganizationPolicy(StrictModel): 

151 """Organization-owned application enrollment and mandatory guardrails.""" 

152 

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) 

156 

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 

163 

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 

178 

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 

195 

196 @classmethod 

197 def from_toml(cls, content: str | bytes) -> Self: 

198 """Parse and validate an organization policy TOML document.""" 

199 

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) 

205 

206 

207class Delegation(StrictModel): 

208 """Allow one enrolled application to stand in for selected CODEOWNERS. 

209 

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

214 

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) 

220 

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 

228 

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) 

235 

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 

246 

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 

257 

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) 

264 

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 

271 

272 

273class RepositoryPolicy(StrictModel): 

274 """Repository policy read from ``.github/extra-codeowners.toml``.""" 

275 

276 schema_version: Literal[1] = SCHEMA_VERSION 

277 enabled: StrictBool = False 

278 delegations: tuple[Delegation, ...] = Field(default=(), max_length=100) 

279 

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 

286 

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 

292 

293 @classmethod 

294 def from_toml(cls, content: str | bytes) -> Self: 

295 """Parse and validate a repository policy TOML document.""" 

296 

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) 

304 

305 

306class ChangedFileStatus(StrEnum): 

307 ADDED = "added" 

308 MODIFIED = "modified" 

309 REMOVED = "removed" 

310 RENAMED = "renamed" 

311 COPIED = "copied" 

312 CHANGED = "changed" 

313 UNCHANGED = "unchanged" 

314 

315 

316class ChangedFile(StrictModel): 

317 """A changed PR file, including the prior name when GitHub reports a rename.""" 

318 

319 path: str 

320 status: ChangedFileStatus = ChangedFileStatus.MODIFIED 

321 previous_path: str | None = None 

322 

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) 

327 

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 

339 

340 

341class ActorKind(StrEnum): 

342 HUMAN = "human" 

343 APPLICATION = "application" 

344 

345 

346class ReviewActor(StrictModel): 

347 """A review actor plus caller-resolved CODEOWNER identities. 

348 

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

353 

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 

361 

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 

369 

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 

379 

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) 

386 

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 

397 

398 

399class ReviewState(StrEnum): 

400 APPROVED = "APPROVED" 

401 CHANGES_REQUESTED = "CHANGES_REQUESTED" 

402 COMMENTED = "COMMENTED" 

403 DISMISSED = "DISMISSED" 

404 PENDING = "PENDING" 

405 

406 

407class PullRequestReview(StrictModel): 

408 """Review evidence as returned by GitHub and normalized by the adapter.""" 

409 

410 review_id: StrictPositiveInt 

411 actor: ReviewActor 

412 state: ReviewState 

413 commit_sha: str | None 

414 submitted_at: datetime 

415 

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 

425 

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 

432 

433 

434class EvaluationOptions(StrictModel): 

435 """Runtime safety controls, normally populated from process settings.""" 

436 

437 exact_head_reviews: bool = True 

438 allow_insecure_changes: bool = False 

439 repository_policy_path: str = ".github/extra-codeowners.toml" 

440 

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 

454 

455 

456class EvaluationInput(StrictModel): 

457 """Complete, network-free evidence needed for one PR evaluation.""" 

458 

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) 

467 

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 

475 

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) 

482 

483 

484class EvaluationConclusion(StrEnum): 

485 SUCCESS = "success" 

486 FAILURE = "failure" 

487 

488 

489class EvaluationMessage(StrictModel): 

490 """A stable machine code and human-readable check annotation.""" 

491 

492 code: str 

493 message: str 

494 paths: tuple[str, ...] = () 

495 

496 

497class PathEvidence(StrictModel): 

498 """How one path within an owner-set requirement was evaluated.""" 

499 

500 path: str 

501 non_delegable: bool 

502 eligible_apps: tuple[str, ...] = () 

503 approved_apps: tuple[str, ...] = () 

504 explanation: str 

505 

506 

507class OwnerSetResult(StrictModel): 

508 """The result for one distinct CODEOWNERS owner set.""" 

509 

510 owners: tuple[str, ...] 

511 paths: tuple[str, ...] 

512 satisfied: bool 

513 satisfied_by: tuple[str, ...] = () 

514 explanation: str 

515 path_evidence: tuple[PathEvidence, ...] = () 

516 

517 

518class EvaluationResult(StrictModel): 

519 """Policy result ready to render into a GitHub Check Run.""" 

520 

521 conclusion: EvaluationConclusion 

522 summary: str 

523 requirements: tuple[OwnerSetResult, ...] = () 

524 errors: tuple[EvaluationMessage, ...] = () 

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

526 unowned_paths: tuple[str, ...] = () 

527 

528 @property 

529 def passed(self) -> bool: 

530 """Whether the result is an affirmative policy success.""" 

531 

532 return self.conclusion is EvaluationConclusion.SUCCESS 

533 

534 def check_output(self) -> str: 

535 """Render concise Markdown suitable for a GitHub Check Run body.""" 

536 

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)