@homericintelligence/athena-opencode 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +29 -0
  2. package/NOTICE +22 -0
  3. package/README.md +45 -0
  4. package/package.json +29 -0
  5. package/plugin.js +54 -0
  6. package/skills/THIRD_PARTY_LICENSES.md +50 -0
  7. package/skills/_cli.py +152 -0
  8. package/skills/advise/SKILL.md +62 -0
  9. package/skills/advise/scripts/list_retrievable_skills.py +49 -0
  10. package/skills/brainstorm/SKILL.md +110 -0
  11. package/skills/change-review/SKILL.md +68 -0
  12. package/skills/change-review/references/scope-resolution.md +52 -0
  13. package/skills/change-review/scripts/resolve_scope.py +1219 -0
  14. package/skills/finalize-plan/SKILL.md +129 -0
  15. package/skills/git-worktrees/SKILL.md +113 -0
  16. package/skills/git-worktrees/scripts/prepare_worktree.py +153 -0
  17. package/skills/issue-review/SKILL.md +67 -0
  18. package/skills/learn/SKILL.md +208 -0
  19. package/skills/myrmidon-swarm/SKILL.md +93 -0
  20. package/skills/plan-issue/SKILL.md +70 -0
  21. package/skills/pr-review/SKILL.md +114 -0
  22. package/skills/pr-review/references/criteria.md +26 -0
  23. package/skills/pr-review/references/delivery.md +135 -0
  24. package/skills/pr-review/references/evidence.md +233 -0
  25. package/skills/pr-review/references/prevalidated.md +155 -0
  26. package/skills/pr-review/scripts/collect_evidence.py +1478 -0
  27. package/skills/pr-review/scripts/diff_context.py +74 -0
  28. package/skills/pr-review/scripts/materialize_snapshot.py +731 -0
  29. package/skills/pr-review/scripts/pr_identity.py +80 -0
  30. package/skills/pr-review/scripts/resolve_pr.py +258 -0
  31. package/skills/repo-review/SKILL.md +119 -0
  32. package/skills/systematic-debugging/SKILL.md +199 -0
  33. package/skills/systematic-debugging/scripts/repository_evidence.py +77 -0
  34. package/skills/test-driven-development/SKILL.md +75 -0
  35. package/skills/tidy/SKILL.md +71 -0
  36. package/skills/tidy/scripts/run_tidy.py +43 -0
@@ -0,0 +1,1478 @@
1
+ #!/usr/bin/env python3
2
+ """Collect GitHub PR metadata and immutable review evidence."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass, field
13
+ from hashlib import sha256
14
+ from pathlib import Path
15
+ from typing import IO, Any
16
+
17
+ if __package__ in {None, ""}:
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
19
+
20
+ from materialize_snapshot import (
21
+ MaterializedSnapshot,
22
+ materialize_snapshot,
23
+ remove_snapshot,
24
+ )
25
+ from pr_identity import (
26
+ COMMIT_OID,
27
+ pull_request_number,
28
+ repository_from_pr_url,
29
+ require_canonical_pull_request_url,
30
+ require_commit_oid,
31
+ require_github_repository,
32
+ validate_pr_identifier,
33
+ )
34
+
35
+ from skills._cli import (
36
+ argument_parser,
37
+ git_read_arguments,
38
+ git_read_environment,
39
+ require_complete_git_history,
40
+ require_unambiguous_git_merge_base,
41
+ run_command,
42
+ )
43
+
44
+ # Keep this query below GitHub's GraphQL complexity budget. Strict callers bind
45
+ # changed paths to local immutable Git objects; legacy callers retain the REST
46
+ # file-list fallback for backwards compatibility only.
47
+ FIELDS = (
48
+ "number,title,body,state,isDraft,author,baseRefName,headRefName,"
49
+ "baseRefOid,headRefOid,reviews,statusCheckRollup,closingIssuesReferences,url"
50
+ )
51
+ ISSUE_FIELDS = "id,number,url,title,body,state"
52
+ READ_CHUNK_SIZE = 64 * 1024
53
+ LINKED_ISSUE_COMMENT_PAGE_SIZE = 100
54
+ MAX_LINKED_ISSUE_COMMENT_PAGES = 10
55
+ MAX_LINKED_ISSUE_COMMENTS = 1_000
56
+ MAX_LINKED_ISSUE_COMMENT_PAGE_BYTES = 256 * 1024
57
+ MAX_LINKED_ISSUE_COMMENT_BYTES = 1024 * 1024
58
+ MAX_LINKED_ISSUE_COMMENT_STDERR_BYTES = 16 * 1024
59
+ LINKED_ISSUE_COMMENT_REQUEST_TIMEOUT_SECONDS = 30.0
60
+ PROVIDER_POLL_SECONDS = 0.01
61
+ PROVIDER_READER_JOIN_SECONDS = 1.0
62
+ MAX_LINKED_REQUIREMENT_METADATA_BYTES = 256 * 1024
63
+ MAX_LINKED_REQUIREMENT_REQUESTS = 48
64
+ MAX_LINKED_REQUIREMENT_PAGES = 24
65
+ MAX_LINKED_REQUIREMENT_COMMENTS = 2_000
66
+ MAX_LINKED_REQUIREMENT_BYTES = 2 * 1024 * 1024
67
+ MAX_CHANGED_PATH_MANIFEST_BYTES = 2 * 1024 * 1024
68
+ MAX_CHANGED_PATHS = 10_000
69
+ MAX_CHANGED_PATH_STDERR_BYTES = 16 * 1024
70
+ CHANGED_PATH_REQUEST_TIMEOUT_SECONDS = 30.0
71
+ MAX_CHECK_RUN_PAGE_BYTES = 256 * 1024
72
+ MAX_CHECK_RUN_BYTES = 2 * 1024 * 1024
73
+ MAX_CHECK_RUN_PAGES = 100
74
+ MAX_CHECK_RUNS = 10_000
75
+ MAX_CHECK_RUN_STDERR_BYTES = 16 * 1024
76
+ CHECK_RUN_REQUEST_TIMEOUT_SECONDS = 30.0
77
+
78
+
79
+ class LinkedRequirementsCoverageGap(RuntimeError):
80
+ """A linked requirement exceeds bounded evidence collection limits."""
81
+
82
+
83
+ class ChangedPathCoverageGap(RuntimeError):
84
+ """Changed paths exceed the bounded immutable-evidence collector."""
85
+
86
+
87
+ class CheckEvidenceCoverageGap(RuntimeError):
88
+ """GitHub checks cannot be bound completely to the reviewed head."""
89
+
90
+
91
+ @dataclass
92
+ class LinkedRequirementBudget:
93
+ """One cumulative provider budget shared by both strict evidence reads."""
94
+
95
+ pages: int = 0
96
+ comments: int = 0
97
+ bytes_read: int = 0
98
+ requests: int = 0
99
+
100
+ def remaining_bytes(self) -> int:
101
+ """Return remaining aggregate provider-output capacity."""
102
+ return MAX_LINKED_REQUIREMENT_BYTES - self.bytes_read
103
+
104
+ def reserve_request(self) -> None:
105
+ """Reserve one bounded provider request before issuing it."""
106
+ if self.requests >= MAX_LINKED_REQUIREMENT_REQUESTS:
107
+ raise LinkedRequirementsCoverageGap(
108
+ "linked issue requirements exceed the safe aggregate request limit"
109
+ )
110
+ self.requests += 1
111
+
112
+ def reserve_comment_page(self) -> None:
113
+ """Reserve one aggregate comment page and its provider request."""
114
+ if self.pages >= MAX_LINKED_REQUIREMENT_PAGES:
115
+ raise LinkedRequirementsCoverageGap(
116
+ "linked issue requirements exceed the safe aggregate page limit"
117
+ )
118
+ self.reserve_request()
119
+
120
+ def record_bytes(self, count: int) -> None:
121
+ """Account for one provider response without crossing the byte budget."""
122
+ if count > self.remaining_bytes():
123
+ raise LinkedRequirementsCoverageGap(
124
+ "linked issue requirements exceed the safe aggregate byte limit"
125
+ )
126
+ self.bytes_read += count
127
+
128
+ def record_comment_page(self, count: int) -> None:
129
+ """Account for one successful provider page and its item count."""
130
+ if self.comments + count > MAX_LINKED_REQUIREMENT_COMMENTS:
131
+ raise LinkedRequirementsCoverageGap(
132
+ "linked issue requirements exceed the safe aggregate comment limit"
133
+ )
134
+ self.pages += 1
135
+ self.comments += count
136
+
137
+
138
+ @dataclass
139
+ class ProviderStream:
140
+ """One bounded asynchronous stdout or stderr capture."""
141
+
142
+ maximum_bytes: int
143
+ output: bytearray = field(default_factory=bytearray)
144
+ overflowed: threading.Event = field(default_factory=threading.Event)
145
+ completed: threading.Event = field(default_factory=threading.Event)
146
+ error: OSError | ValueError | None = None
147
+
148
+
149
+ @dataclass
150
+ class ChangedPathStream:
151
+ """Incrementally validate a bounded NUL-delimited Git path manifest."""
152
+
153
+ maximum_bytes: int
154
+ maximum_paths: int
155
+ bytes_read: int = 0
156
+ paths: list[bytes] = field(default_factory=list)
157
+ seen_paths: set[bytes] = field(default_factory=set)
158
+ trailing: bytearray = field(default_factory=bytearray)
159
+ limit_error: str | None = None
160
+ overflowed: threading.Event = field(default_factory=threading.Event)
161
+ completed: threading.Event = field(default_factory=threading.Event)
162
+ error: OSError | RuntimeError | ValueError | None = None
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ImmutableIdentity:
167
+ """The review artifact and exact revisions that evidence is bound to."""
168
+
169
+ repository: str
170
+ number: int
171
+ url: str
172
+ base_oid: str
173
+ head_oid: str
174
+
175
+ def as_json(self) -> dict[str, object]:
176
+ """Return the stable, serializable evidence binding."""
177
+ return {
178
+ "forge_host": "github.com",
179
+ "repository": self.repository,
180
+ "number": self.number,
181
+ "url": self.url,
182
+ "state": "OPEN",
183
+ "base_oid": self.base_oid,
184
+ "head_oid": self.head_oid,
185
+ }
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class ExpectedReviewTarget:
190
+ """The immutable GitHub artifact target resolved before strict collection."""
191
+
192
+ host: str
193
+ repository: str
194
+ number: int
195
+ url: str
196
+
197
+ def repository_argument(self) -> str:
198
+ """Return the fully qualified repository argument accepted by gh."""
199
+ return f"{self.host}/{self.repository}"
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ChangedPathManifest:
204
+ """A canonical, immutable changed-path manifest derived from Git objects."""
205
+
206
+ paths: tuple[str, ...]
207
+ sha256: str
208
+
209
+ def as_json(self) -> dict[str, object]:
210
+ """Return the manifest binding carried with strict evidence."""
211
+ return {
212
+ "encoding": "utf-8-nul",
213
+ "count": len(self.paths),
214
+ "sha256": self.sha256,
215
+ }
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ReviewScope:
220
+ """Canonical mutable review-context fields bound to an evidence collection."""
221
+
222
+ fields: dict[str, Any]
223
+ sha256: str
224
+
225
+ def as_json(self) -> dict[str, object]:
226
+ """Return the revalidated review-context binding."""
227
+ return {"fields": self.fields, "sha256": self.sha256}
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class LinkedRequirements:
232
+ """Canonical content binding for every linked issue used as requirements."""
233
+
234
+ items: tuple[LinkedRequirement, ...]
235
+ sha256: str
236
+
237
+ def as_json(self) -> dict[str, object]:
238
+ """Return the serializable linked-requirements binding."""
239
+ return {
240
+ "count": len(self.items),
241
+ "items": [item.as_json() for item in self.items],
242
+ "sha256": self.sha256,
243
+ }
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class LinkedRequirement:
248
+ """One linked issue's stable identity and content-only requirements digest."""
249
+
250
+ id: str
251
+ repository: str
252
+ number: int
253
+ url: str
254
+ content_sha256: str
255
+
256
+ def as_json(self) -> dict[str, str | int]:
257
+ """Return the individual requirement record carried in review evidence."""
258
+ return {
259
+ "content_sha256": self.content_sha256,
260
+ "id": self.id,
261
+ "number": self.number,
262
+ "repository": self.repository,
263
+ "url": self.url,
264
+ }
265
+
266
+
267
+ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str | None:
268
+ """Return a diagnostic when GitHub returns partial PR metadata."""
269
+ if not isinstance(metadata, dict):
270
+ return "PR metadata must be a JSON object"
271
+ required_types = {
272
+ "number": int,
273
+ "title": str,
274
+ "state": str,
275
+ "author": dict,
276
+ "baseRefName": str,
277
+ "headRefName": str,
278
+ "statusCheckRollup": list,
279
+ "url": str,
280
+ }
281
+ invalid = [
282
+ field
283
+ for field, expected_type in required_types.items()
284
+ if not isinstance(metadata.get(field), expected_type)
285
+ ]
286
+ author = metadata.get("author")
287
+ if not isinstance(author, dict) or not isinstance(author.get("login"), str):
288
+ invalid.append("author.login")
289
+ if invalid:
290
+ return "GitHub returned incomplete or invalid PR metadata fields: " + ", ".join(
291
+ sorted(set(invalid))
292
+ )
293
+ if metadata["state"] != "OPEN":
294
+ return f"pull request {metadata['number']} is not open"
295
+ identity_fields = ("baseRefOid", "headRefOid")
296
+ identity_values = [metadata.get(field) for field in identity_fields]
297
+ has_identity = any(value is not None for value in identity_values)
298
+ if require_immutable_identity or has_identity:
299
+ invalid_identity = [
300
+ field
301
+ for field, value in zip(identity_fields, identity_values, strict=True)
302
+ if not isinstance(value, str) or COMMIT_OID.fullmatch(value) is None
303
+ ]
304
+ if invalid_identity:
305
+ return (
306
+ "GitHub returned incomplete or invalid immutable PR identity fields: "
307
+ + ", ".join(invalid_identity)
308
+ )
309
+ if require_immutable_identity:
310
+ body = metadata.get("body")
311
+ closing_issues = metadata.get("closingIssuesReferences")
312
+ scope_invalid: list[str] = []
313
+ if "body" not in metadata or (body is not None and not isinstance(body, str)):
314
+ scope_invalid.append("body")
315
+ if not isinstance(metadata.get("isDraft"), bool):
316
+ scope_invalid.append("isDraft")
317
+ if not isinstance(closing_issues, list) or not all(
318
+ isinstance(issue, dict) for issue in closing_issues
319
+ ):
320
+ scope_invalid.append("closingIssuesReferences")
321
+ if scope_invalid:
322
+ return (
323
+ "GitHub returned incomplete or invalid review-scope fields: "
324
+ + ", ".join(scope_invalid)
325
+ )
326
+ return None
327
+
328
+
329
+ def immutable_identity(
330
+ metadata: dict[str, Any], repository: str, *, require_immutable_identity: bool
331
+ ) -> ImmutableIdentity | None:
332
+ """Build a validated identity when immutable revisions are available."""
333
+ base_oid = metadata.get("baseRefOid")
334
+ head_oid = metadata.get("headRefOid")
335
+ if base_oid is None and head_oid is None and not require_immutable_identity:
336
+ return None
337
+ if not isinstance(base_oid, str) or not isinstance(head_oid, str):
338
+ raise TypeError("GitHub returned incomplete immutable pull-request identity")
339
+ number = metadata.get("number")
340
+ url = metadata.get("url")
341
+ if not isinstance(number, int) or not isinstance(url, str):
342
+ raise TypeError("GitHub returned incomplete pull-request identity")
343
+ return ImmutableIdentity(
344
+ repository=repository,
345
+ number=number,
346
+ url=url,
347
+ base_oid=base_oid,
348
+ head_oid=head_oid,
349
+ )
350
+
351
+
352
+ def expected_identity(
353
+ parser: Any, base_oid: str | None, head_oid: str | None
354
+ ) -> tuple[str, str] | None:
355
+ """Validate the optional immutable identity supplied by resolve_pr.py."""
356
+ if (base_oid is None) != (head_oid is None):
357
+ parser.error(
358
+ "--expected-base-oid and --expected-head-oid must be supplied together"
359
+ )
360
+ if base_oid is None or head_oid is None:
361
+ return None
362
+ if COMMIT_OID.fullmatch(base_oid) is None:
363
+ parser.error("--expected-base-oid must be a lowercase 40-hex Git commit OID")
364
+ if COMMIT_OID.fullmatch(head_oid) is None:
365
+ parser.error("--expected-head-oid must be a lowercase 40-hex Git commit OID")
366
+ return base_oid, head_oid
367
+
368
+
369
+ def expected_target(
370
+ parser: Any,
371
+ identity: tuple[str, str] | None,
372
+ host: str | None,
373
+ repository: str | None,
374
+ number: int | None,
375
+ url: str | None,
376
+ ) -> ExpectedReviewTarget | None:
377
+ """Require the resolved GitHub target whenever immutable OIDs are supplied."""
378
+ if identity is None:
379
+ if (
380
+ host is not None
381
+ or repository is not None
382
+ or number is not None
383
+ or url is not None
384
+ ):
385
+ parser.error(
386
+ "--expected-host, --expected-repository, --expected-pr-number, and "
387
+ "--expected-pr-url require immutable expected OIDs"
388
+ )
389
+ return None
390
+ if host is None or repository is None or number is None or url is None:
391
+ parser.error(
392
+ "--expected-host, --expected-repository, --expected-pr-number, and "
393
+ "--expected-pr-url are required with immutable expected OIDs"
394
+ )
395
+ assert host is not None
396
+ assert repository is not None
397
+ assert number is not None
398
+ assert url is not None
399
+ if host != "github.com":
400
+ parser.error("--expected-host must be github.com")
401
+ try:
402
+ canonical_repository = require_github_repository(
403
+ repository, "--expected-repository"
404
+ )
405
+ except RuntimeError as error:
406
+ parser.error(str(error))
407
+ if number < 1:
408
+ parser.error("--expected-pr-number must be a positive pull-request number")
409
+ try:
410
+ canonical_url = require_canonical_pull_request_url(
411
+ url, canonical_repository, number, "--expected-pr-url"
412
+ )
413
+ except RuntimeError as error:
414
+ parser.error(str(error))
415
+ return ExpectedReviewTarget(
416
+ host=host,
417
+ repository=canonical_repository,
418
+ number=number,
419
+ url=canonical_url,
420
+ )
421
+
422
+
423
+ def ensure_expected_identity(
424
+ identity: ImmutableIdentity | None, expected: tuple[str, str] | None
425
+ ) -> None:
426
+ """Fail closed when collected revisions differ from the resolved PR."""
427
+ if expected is None:
428
+ return
429
+ if identity is None:
430
+ raise RuntimeError("GitHub returned no immutable pull-request identity")
431
+ if (identity.base_oid, identity.head_oid) != expected:
432
+ raise RuntimeError(
433
+ "immutable pull-request identity does not match the expected base/head OIDs"
434
+ )
435
+
436
+
437
+ def ensure_expected_target(
438
+ identity: ImmutableIdentity | None, target: ExpectedReviewTarget | None
439
+ ) -> None:
440
+ """Fail closed when collected artifact identity differs from the resolved target."""
441
+ if target is None:
442
+ return
443
+ if identity is None:
444
+ raise RuntimeError("GitHub returned no immutable pull-request identity")
445
+ if (
446
+ identity.repository.casefold() != target.repository.casefold()
447
+ or identity.number != target.number
448
+ or identity.url != target.url
449
+ ):
450
+ raise RuntimeError("pull-request identity does not match the expected target")
451
+
452
+
453
+ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
454
+ """Bind mutable issue/scope fields so they cannot drift during review."""
455
+ closing_issues = metadata.get("closingIssuesReferences")
456
+ if not isinstance(closing_issues, list):
457
+ raise TypeError("GitHub returned incomplete review-scope fields")
458
+ try:
459
+ canonical_issues = sorted(
460
+ json.dumps(
461
+ issue,
462
+ allow_nan=False,
463
+ ensure_ascii=False,
464
+ separators=(",", ":"),
465
+ sort_keys=True,
466
+ )
467
+ for issue in closing_issues
468
+ )
469
+ fields: dict[str, Any] = {
470
+ "title": metadata["title"],
471
+ "body": metadata.get("body"),
472
+ "closingIssuesReferences": [
473
+ json.loads(issue) for issue in canonical_issues
474
+ ],
475
+ "state": metadata["state"],
476
+ "isDraft": metadata["isDraft"],
477
+ "baseRefName": metadata["baseRefName"],
478
+ "headRefName": metadata["headRefName"],
479
+ }
480
+ canonical_scope = json.dumps(
481
+ fields,
482
+ allow_nan=False,
483
+ ensure_ascii=False,
484
+ separators=(",", ":"),
485
+ sort_keys=True,
486
+ )
487
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
488
+ raise RuntimeError("GitHub returned invalid review-scope fields") from error
489
+ return ReviewScope(
490
+ fields=fields,
491
+ sha256=sha256(canonical_scope.encode("utf-8")).hexdigest(),
492
+ )
493
+
494
+
495
+ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
496
+ """Return one validated canonical linked-issue identity."""
497
+ if not isinstance(issue, dict):
498
+ raise TypeError("GitHub returned an invalid linked issue reference")
499
+ issue_id = issue.get("id")
500
+ repository_data = issue.get("repository")
501
+ number = issue.get("number")
502
+ url = issue.get("url")
503
+ if (
504
+ not isinstance(issue_id, str)
505
+ or not issue_id
506
+ or not isinstance(repository_data, dict)
507
+ or isinstance(number, bool)
508
+ or not isinstance(number, int)
509
+ ):
510
+ raise RuntimeError("GitHub returned an incomplete linked issue reference")
511
+ owner_data = repository_data.get("owner")
512
+ name = repository_data.get("name")
513
+ owner = owner_data.get("login") if isinstance(owner_data, dict) else None
514
+ if (
515
+ not isinstance(owner, str)
516
+ or not owner
517
+ or not isinstance(name, str)
518
+ or not name
519
+ or number < 1
520
+ or not isinstance(url, str)
521
+ ):
522
+ raise RuntimeError("GitHub returned an incomplete linked issue reference")
523
+ try:
524
+ repository = require_github_repository(
525
+ f"{owner}/{name}", "GitHub linked issue repository"
526
+ )
527
+ except RuntimeError as error:
528
+ raise RuntimeError(
529
+ "GitHub returned an invalid linked issue repository"
530
+ ) from error
531
+ canonical_url = f"https://github.com/{repository}/issues/{number}"
532
+ if url != canonical_url:
533
+ raise RuntimeError("GitHub returned an invalid linked issue URL")
534
+ return issue_id, repository, number, canonical_url
535
+
536
+
537
+ def canonical_json(value: object, label: str) -> str:
538
+ """Serialize provider data canonically or reject malformed content."""
539
+ try:
540
+ return json.dumps(
541
+ value,
542
+ allow_nan=False,
543
+ ensure_ascii=False,
544
+ separators=(",", ":"),
545
+ sort_keys=True,
546
+ )
547
+ except (TypeError, ValueError) as error:
548
+ raise RuntimeError(f"GitHub returned invalid {label}") from error
549
+
550
+
551
+ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
552
+ """Read one provider pipe without allowing it to grow without bound."""
553
+ try:
554
+ while True:
555
+ read_size = min(
556
+ READ_CHUNK_SIZE, capture.maximum_bytes - len(capture.output) + 1
557
+ )
558
+ if read_size <= 0:
559
+ capture.overflowed.set()
560
+ return
561
+ chunk = stream.read(read_size)
562
+ if not chunk:
563
+ return
564
+ if len(capture.output) + len(chunk) > capture.maximum_bytes:
565
+ capture.overflowed.set()
566
+ return
567
+ capture.output.extend(chunk)
568
+ except (OSError, ValueError) as error:
569
+ capture.error = error
570
+ finally:
571
+ try:
572
+ stream.close()
573
+ except OSError:
574
+ # Another cleanup path may already have closed this best-effort pipe.
575
+ pass
576
+ capture.completed.set()
577
+
578
+
579
+ def validate_changed_path(entry: bytes) -> None:
580
+ """Reject an empty or unsafe Git-relative path entry."""
581
+ if not entry:
582
+ raise RuntimeError("Git returned an empty changed path")
583
+ if entry.startswith(b"/") or any(
584
+ component in {b".", b".."} for component in entry.split(b"/")
585
+ ):
586
+ raise RuntimeError("Git returned an unsafe changed path")
587
+
588
+
589
+ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) -> None:
590
+ """Incrementally collect one bounded, NUL-delimited Git path manifest."""
591
+ try:
592
+ while True:
593
+ read_size = min(
594
+ READ_CHUNK_SIZE, capture.maximum_bytes - capture.bytes_read + 1
595
+ )
596
+ if read_size <= 0:
597
+ capture.limit_error = (
598
+ "changed-path manifest exceeds the safe byte limit"
599
+ )
600
+ capture.overflowed.set()
601
+ return
602
+ chunk = stream.read(read_size)
603
+ if not chunk:
604
+ if capture.trailing:
605
+ raise RuntimeError(
606
+ "Git returned a malformed NUL-delimited changed-path manifest"
607
+ )
608
+ return
609
+ if capture.bytes_read + len(chunk) > capture.maximum_bytes:
610
+ capture.limit_error = (
611
+ "changed-path manifest exceeds the safe byte limit"
612
+ )
613
+ capture.overflowed.set()
614
+ return
615
+ capture.bytes_read += len(chunk)
616
+ entries = (bytes(capture.trailing) + chunk).split(b"\0")
617
+ capture.trailing = bytearray(entries.pop())
618
+ for entry in entries:
619
+ validate_changed_path(entry)
620
+ if entry in capture.seen_paths:
621
+ raise RuntimeError("Git returned duplicate changed paths")
622
+ if len(capture.paths) >= capture.maximum_paths:
623
+ capture.limit_error = (
624
+ "changed-path manifest exceeds the safe path limit"
625
+ )
626
+ capture.overflowed.set()
627
+ return
628
+ capture.seen_paths.add(entry)
629
+ capture.paths.append(entry)
630
+ except (OSError, RuntimeError, ValueError) as error:
631
+ capture.error = error
632
+ finally:
633
+ try:
634
+ stream.close()
635
+ except (OSError, ValueError):
636
+ # A sibling cleanup path can close the pipe before this reader exits.
637
+ pass
638
+ capture.completed.set()
639
+
640
+
641
+ def reap_provider(
642
+ process: subprocess.Popen[bytes],
643
+ streams: Sequence[IO[bytes]],
644
+ readers: Sequence[threading.Thread],
645
+ ) -> None:
646
+ """Kill and reap a failed provider request without leaking reader threads."""
647
+ if process.poll() is None:
648
+ process.kill()
649
+ for stream in streams:
650
+ try:
651
+ stream.close()
652
+ except (OSError, ValueError):
653
+ # Reaping is best effort after either reader may have closed the pipe.
654
+ pass
655
+ process.wait()
656
+ for reader in readers:
657
+ reader.join(PROVIDER_READER_JOIN_SECONDS)
658
+
659
+
660
+ def provider_return_code(
661
+ process: subprocess.Popen[bytes],
662
+ stdout: ProviderStream | ChangedPathStream,
663
+ stderr: ProviderStream,
664
+ stdout_limit_error: str,
665
+ *,
666
+ timeout_seconds: float,
667
+ stderr_limit_error: str,
668
+ deadline_error: str,
669
+ output_error: str,
670
+ coverage_gap: type[RuntimeError],
671
+ ) -> int:
672
+ """Wait for bounded output and a completed provider before the deadline."""
673
+ deadline = time.monotonic() + timeout_seconds
674
+ while True:
675
+ if stdout.overflowed.is_set():
676
+ path_limit_error = (
677
+ stdout.limit_error if isinstance(stdout, ChangedPathStream) else None
678
+ )
679
+ raise coverage_gap(path_limit_error or stdout_limit_error)
680
+ if stderr.overflowed.is_set():
681
+ raise coverage_gap(stderr_limit_error)
682
+ if stdout.error is not None:
683
+ if isinstance(stdout.error, RuntimeError):
684
+ raise stdout.error
685
+ raise RuntimeError(output_error)
686
+ if stderr.error is not None:
687
+ raise RuntimeError(output_error)
688
+ return_code = process.poll()
689
+ if (
690
+ return_code is not None
691
+ and stdout.completed.is_set()
692
+ and stderr.completed.is_set()
693
+ ):
694
+ return process.wait()
695
+ if time.monotonic() >= deadline:
696
+ raise coverage_gap(deadline_error)
697
+ time.sleep(PROVIDER_POLL_SECONDS)
698
+
699
+
700
+ def bounded_gh_output(
701
+ arguments: Sequence[str],
702
+ *,
703
+ maximum_bytes: int,
704
+ limit_error: str,
705
+ timeout_seconds: float | None = None,
706
+ stderr_maximum_bytes: int | None = None,
707
+ stderr_limit_error: str = "linked issue response exceeds the safe stderr limit",
708
+ deadline_error: str = "linked issue provider exceeded the safe provider deadline",
709
+ output_error: str = "cannot read linked issue provider output",
710
+ unavailable_output_error: str = "GitHub did not provide linked issue provider output",
711
+ operating_system_error: str = "cannot collect linked issue comments",
712
+ coverage_gap: type[RuntimeError] = LinkedRequirementsCoverageGap,
713
+ ) -> bytes:
714
+ """Run one deadline-bound GitHub request with bounded stdout and stderr."""
715
+ command = ["gh", *arguments]
716
+ effective_timeout = (
717
+ LINKED_ISSUE_COMMENT_REQUEST_TIMEOUT_SECONDS
718
+ if timeout_seconds is None
719
+ else timeout_seconds
720
+ )
721
+ effective_stderr_maximum_bytes = (
722
+ MAX_LINKED_ISSUE_COMMENT_STDERR_BYTES
723
+ if stderr_maximum_bytes is None
724
+ else stderr_maximum_bytes
725
+ )
726
+ try:
727
+ try:
728
+ process = subprocess.Popen(
729
+ command,
730
+ stdout=subprocess.PIPE,
731
+ stderr=subprocess.PIPE,
732
+ )
733
+ except FileNotFoundError as error:
734
+ raise RuntimeError(
735
+ f"required command unavailable: {error.filename or command[0]}"
736
+ ) from error
737
+ stdout = process.stdout
738
+ stderr = process.stderr
739
+ if stdout is None or stderr is None:
740
+ process.kill()
741
+ process.wait()
742
+ raise RuntimeError(unavailable_output_error)
743
+ stdout_capture = ProviderStream(maximum_bytes)
744
+ stderr_capture = ProviderStream(effective_stderr_maximum_bytes)
745
+ readers = (
746
+ threading.Thread(
747
+ target=drain_provider_stream,
748
+ args=(stdout, stdout_capture),
749
+ daemon=True,
750
+ ),
751
+ threading.Thread(
752
+ target=drain_provider_stream,
753
+ args=(stderr, stderr_capture),
754
+ daemon=True,
755
+ ),
756
+ )
757
+ for reader in readers:
758
+ reader.start()
759
+ try:
760
+ return_code = provider_return_code(
761
+ process,
762
+ stdout_capture,
763
+ stderr_capture,
764
+ limit_error,
765
+ timeout_seconds=effective_timeout,
766
+ stderr_limit_error=stderr_limit_error,
767
+ deadline_error=deadline_error,
768
+ output_error=output_error,
769
+ coverage_gap=coverage_gap,
770
+ )
771
+ except BaseException:
772
+ reap_provider(process, (stdout, stderr), readers)
773
+ raise
774
+ for reader in readers:
775
+ reader.join(PROVIDER_READER_JOIN_SECONDS)
776
+ if return_code != 0:
777
+ message = (
778
+ bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
779
+ )
780
+ raise RuntimeError(message or f"gh {' '.join(arguments)} failed")
781
+ return bytes(stdout_capture.output)
782
+ except OSError as error:
783
+ raise RuntimeError(f"{operating_system_error}: {error}") from error
784
+
785
+
786
+ def paginated_issue_comments(
787
+ repository: str, number: int, budget: LinkedRequirementBudget | None = None
788
+ ) -> list[dict[str, Any]]:
789
+ """Read bounded linked-issue comments through explicit canonical pages."""
790
+ collection_budget = budget if budget is not None else LinkedRequirementBudget()
791
+ comments: list[dict[str, Any]] = []
792
+ bytes_read = 0
793
+ for page in range(1, MAX_LINKED_ISSUE_COMMENT_PAGES + 2):
794
+ remaining_bytes = MAX_LINKED_ISSUE_COMMENT_BYTES - bytes_read
795
+ aggregate_remaining_bytes = collection_budget.remaining_bytes()
796
+ if remaining_bytes <= 0:
797
+ raise LinkedRequirementsCoverageGap(
798
+ "linked issue comments exceed the safe byte limit"
799
+ )
800
+ if aggregate_remaining_bytes <= 0:
801
+ raise LinkedRequirementsCoverageGap(
802
+ "linked issue requirements exceed the safe aggregate byte limit"
803
+ )
804
+ collection_budget.reserve_comment_page()
805
+ response = bounded_gh_output(
806
+ (
807
+ "api",
808
+ "--hostname",
809
+ "github.com",
810
+ "--method",
811
+ "GET",
812
+ (
813
+ f"repos/{repository}/issues/{number}/comments?"
814
+ f"per_page={LINKED_ISSUE_COMMENT_PAGE_SIZE}&page={page}"
815
+ ),
816
+ ),
817
+ maximum_bytes=min(
818
+ MAX_LINKED_ISSUE_COMMENT_PAGE_BYTES,
819
+ remaining_bytes,
820
+ aggregate_remaining_bytes,
821
+ ),
822
+ limit_error="linked issue comments exceed the safe byte limit",
823
+ )
824
+ bytes_read += len(response)
825
+ collection_budget.record_bytes(len(response))
826
+ try:
827
+ page_comments = json.loads(response)
828
+ except json.JSONDecodeError as error:
829
+ raise RuntimeError(
830
+ "GitHub returned invalid linked issue comment pages"
831
+ ) from error
832
+ if not isinstance(page_comments, list):
833
+ raise TypeError("GitHub returned invalid linked issue comment pages")
834
+ if not all(isinstance(comment, dict) for comment in page_comments):
835
+ raise RuntimeError("GitHub returned an invalid linked issue comment")
836
+ if page > MAX_LINKED_ISSUE_COMMENT_PAGES:
837
+ if page_comments:
838
+ raise LinkedRequirementsCoverageGap(
839
+ "linked issue comments exceed the safe page limit"
840
+ )
841
+ return comments
842
+ if len(comments) + len(page_comments) > MAX_LINKED_ISSUE_COMMENTS:
843
+ raise LinkedRequirementsCoverageGap(
844
+ "linked issue comments exceed the safe comment limit"
845
+ )
846
+ collection_budget.record_comment_page(len(page_comments))
847
+ comments.extend(page_comments)
848
+ if len(page_comments) < LINKED_ISSUE_COMMENT_PAGE_SIZE:
849
+ return comments
850
+ raise AssertionError("bounded linked issue comment pagination did not terminate")
851
+
852
+
853
+ def structured_error(error: str, details: str) -> None:
854
+ """Emit a machine-readable, fail-closed evidence error."""
855
+ print(json.dumps({"error": error, "details": details}, sort_keys=True))
856
+
857
+
858
+ def linked_issue_metadata(
859
+ repository: str, number: int, budget: LinkedRequirementBudget
860
+ ) -> dict[str, Any]:
861
+ """Read bounded linked-issue metadata through the shared provider budget."""
862
+ aggregate_remaining_bytes = budget.remaining_bytes()
863
+ if aggregate_remaining_bytes <= 0:
864
+ raise LinkedRequirementsCoverageGap(
865
+ "linked issue requirements exceed the safe aggregate byte limit"
866
+ )
867
+ budget.reserve_request()
868
+ response = bounded_gh_output(
869
+ (
870
+ "issue",
871
+ "view",
872
+ str(number),
873
+ "--repo",
874
+ f"github.com/{repository}",
875
+ "--json",
876
+ ISSUE_FIELDS,
877
+ ),
878
+ maximum_bytes=min(
879
+ MAX_LINKED_REQUIREMENT_METADATA_BYTES, aggregate_remaining_bytes
880
+ ),
881
+ limit_error="linked issue metadata exceeds the safe metadata byte limit",
882
+ )
883
+ budget.record_bytes(len(response))
884
+ try:
885
+ issue_data = json.loads(response)
886
+ except json.JSONDecodeError as error:
887
+ raise RuntimeError("GitHub returned an invalid linked issue") from error
888
+ if not isinstance(issue_data, dict):
889
+ raise TypeError("GitHub returned an invalid linked issue")
890
+ return issue_data
891
+
892
+
893
+ def linked_requirements(
894
+ metadata: dict[str, Any], budget: LinkedRequirementBudget | None = None
895
+ ) -> LinkedRequirements:
896
+ """Bind every linked issue's requirement content and complete comment history."""
897
+ references = metadata.get("closingIssuesReferences")
898
+ if not isinstance(references, list):
899
+ raise TypeError("GitHub returned incomplete linked issue references")
900
+ identities = sorted(linked_issue_reference(issue) for issue in references)
901
+ if len(identities) != len(set(identities)):
902
+ raise RuntimeError("GitHub returned duplicate linked issue references")
903
+ collection_budget = budget if budget is not None else LinkedRequirementBudget()
904
+ items: list[LinkedRequirement] = []
905
+ for expected_id, repository, number, expected_url in identities:
906
+ issue_data = linked_issue_metadata(repository, number, collection_budget)
907
+ issue_id = issue_data.get("id")
908
+ body = issue_data.get("body")
909
+ if (
910
+ issue_id != expected_id
911
+ or issue_data.get("number") != number
912
+ or issue_data.get("url") != expected_url
913
+ or not isinstance(issue_data.get("title"), str)
914
+ or (body is not None and not isinstance(body, str))
915
+ or not isinstance(issue_data.get("state"), str)
916
+ ):
917
+ raise RuntimeError("GitHub returned incomplete linked issue requirements")
918
+ comments = sorted(
919
+ canonical_json(comment, "linked issue comment")
920
+ for comment in paginated_issue_comments(
921
+ repository, number, collection_budget
922
+ )
923
+ )
924
+ content = {
925
+ "body": body,
926
+ "comments": [json.loads(comment) for comment in comments],
927
+ "state": issue_data["state"],
928
+ "title": issue_data["title"],
929
+ }
930
+ items.append(
931
+ LinkedRequirement(
932
+ id=expected_id,
933
+ repository=repository,
934
+ number=number,
935
+ url=expected_url,
936
+ content_sha256=sha256(
937
+ canonical_json(content, "linked issue requirements").encode("utf-8")
938
+ ).hexdigest(),
939
+ )
940
+ )
941
+ document = canonical_json(
942
+ [item.as_json() for item in items], "linked issue requirements"
943
+ )
944
+ return LinkedRequirements(
945
+ items=tuple(items), sha256=sha256(document.encode("utf-8")).hexdigest()
946
+ )
947
+
948
+
949
+ def bounded_git_path_manifest(
950
+ arguments: Sequence[str], *, cwd: Path | None = None
951
+ ) -> list[bytes]:
952
+ """Collect one immutable Git path manifest without unbounded buffering."""
953
+ command = ["git", *git_read_arguments(), *arguments]
954
+ try:
955
+ try:
956
+ process = subprocess.Popen(
957
+ command,
958
+ stdout=subprocess.PIPE,
959
+ stderr=subprocess.PIPE,
960
+ env=git_read_environment(),
961
+ cwd=cwd,
962
+ )
963
+ except FileNotFoundError as error:
964
+ raise RuntimeError(
965
+ f"required command unavailable: {error.filename or command[0]}"
966
+ ) from error
967
+ stdout = process.stdout
968
+ stderr = process.stderr
969
+ if stdout is None or stderr is None:
970
+ process.kill()
971
+ process.wait()
972
+ raise RuntimeError("Git did not provide changed-path output")
973
+ stdout_capture = ChangedPathStream(
974
+ MAX_CHANGED_PATH_MANIFEST_BYTES, MAX_CHANGED_PATHS
975
+ )
976
+ stderr_capture = ProviderStream(MAX_CHANGED_PATH_STDERR_BYTES)
977
+ readers = (
978
+ threading.Thread(
979
+ target=drain_changed_path_stream,
980
+ args=(stdout, stdout_capture),
981
+ daemon=True,
982
+ ),
983
+ threading.Thread(
984
+ target=drain_provider_stream,
985
+ args=(stderr, stderr_capture),
986
+ daemon=True,
987
+ ),
988
+ )
989
+ for reader in readers:
990
+ reader.start()
991
+ try:
992
+ return_code = provider_return_code(
993
+ process,
994
+ stdout_capture,
995
+ stderr_capture,
996
+ "changed-path manifest exceeds the safe byte limit",
997
+ timeout_seconds=CHANGED_PATH_REQUEST_TIMEOUT_SECONDS,
998
+ stderr_limit_error="changed-path response exceeds the safe stderr limit",
999
+ deadline_error="changed-path provider exceeded the safe provider deadline",
1000
+ output_error="cannot read immutable changed-path output",
1001
+ coverage_gap=ChangedPathCoverageGap,
1002
+ )
1003
+ except BaseException:
1004
+ reap_provider(process, (stdout, stderr), readers)
1005
+ raise
1006
+ for reader in readers:
1007
+ reader.join(PROVIDER_READER_JOIN_SECONDS)
1008
+ if return_code != 0:
1009
+ message = (
1010
+ bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
1011
+ )
1012
+ raise RuntimeError(message or f"git {' '.join(arguments)} failed")
1013
+ return stdout_capture.paths
1014
+ except OSError as error:
1015
+ raise RuntimeError(
1016
+ f"cannot collect immutable changed paths: {error}"
1017
+ ) from error
1018
+
1019
+
1020
+ def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
1021
+ """Run a read-only Git query and return its byte-exact stdout."""
1022
+ result: Any = run_command(
1023
+ ["git", *git_read_arguments(), *arguments],
1024
+ capture_output=True,
1025
+ check=False,
1026
+ env=git_read_environment(),
1027
+ cwd=cwd,
1028
+ )
1029
+ if result.returncode != 0:
1030
+ stderr = result.stderr
1031
+ if isinstance(stderr, bytes):
1032
+ message = stderr.decode("utf-8", errors="replace").strip()
1033
+ else:
1034
+ message = str(stderr).strip()
1035
+ raise RuntimeError(message or f"git {' '.join(arguments)} failed")
1036
+ stdout = result.stdout
1037
+ if not isinstance(stdout, bytes):
1038
+ raise TypeError("git returned non-byte output for immutable path evidence")
1039
+ return stdout
1040
+
1041
+
1042
+ def immutable_range_paths(
1043
+ base_oid: str, head_oid: str, *, cwd: Path | None = None
1044
+ ) -> list[bytes]:
1045
+ """Return validated NUL-safe paths from one immutable Git diff range."""
1046
+ return bounded_git_path_manifest(
1047
+ (
1048
+ "-c",
1049
+ "diff.external=",
1050
+ "-c",
1051
+ "diff.autoRefreshIndex=false",
1052
+ "diff",
1053
+ "--name-only",
1054
+ "-z",
1055
+ "--no-renames",
1056
+ "--ignore-submodules=none",
1057
+ "--no-ext-diff",
1058
+ "--no-textconv",
1059
+ base_oid,
1060
+ head_oid,
1061
+ ),
1062
+ cwd=cwd,
1063
+ )
1064
+
1065
+
1066
+ def immutable_changed_paths(
1067
+ base_oid: str, head_oid: str, *, cwd: Path | None = None
1068
+ ) -> ChangedPathManifest:
1069
+ """Bind the union of author-intent and current-target immutable paths."""
1070
+ require_complete_git_history(cwd=cwd)
1071
+ for oid in (base_oid, head_oid):
1072
+ git_bytes("cat-file", "-e", f"{oid}^{{commit}}", cwd=cwd)
1073
+ merge_base = require_commit_oid(
1074
+ require_unambiguous_git_merge_base(base_oid, head_oid, cwd=cwd),
1075
+ "immutable merge base",
1076
+ )
1077
+ git_bytes("cat-file", "-e", f"{merge_base}^{{commit}}", cwd=cwd)
1078
+ author_intent_paths = immutable_range_paths(merge_base, head_oid, cwd=cwd)
1079
+ current_target_paths = immutable_range_paths(base_oid, head_oid, cwd=cwd)
1080
+ canonical_entries = sorted(set(author_intent_paths) | set(current_target_paths))
1081
+ try:
1082
+ paths = tuple(entry.decode("utf-8") for entry in canonical_entries)
1083
+ except UnicodeDecodeError as error:
1084
+ raise RuntimeError("Git returned a non-UTF-8 changed path") from error
1085
+ canonical_bytes = b"".join(entry + b"\0" for entry in canonical_entries)
1086
+ return ChangedPathManifest(paths=paths, sha256=sha256(canonical_bytes).hexdigest())
1087
+
1088
+
1089
+ def local_immutable_objects_available(base_oid: str, head_oid: str) -> bool:
1090
+ """Return whether the caller already has both immutable commit objects."""
1091
+ try:
1092
+ for oid in (base_oid, head_oid):
1093
+ git_bytes("cat-file", "-e", f"{oid}^{{commit}}")
1094
+ except RuntimeError:
1095
+ return False
1096
+ return True
1097
+
1098
+
1099
+ def strict_changed_paths(
1100
+ metadata: dict[str, Any],
1101
+ expected: tuple[str, str],
1102
+ target: ExpectedReviewTarget,
1103
+ ) -> tuple[ChangedPathManifest, MaterializedSnapshot | None]:
1104
+ """Derive strict paths locally or from a verified host-owned snapshot."""
1105
+ base_oid, head_oid = expected
1106
+ if local_immutable_objects_available(base_oid, head_oid):
1107
+ return immutable_changed_paths(base_oid, head_oid), None
1108
+ base_ref = metadata.get("baseRefName")
1109
+ if not isinstance(base_ref, str):
1110
+ raise TypeError("GitHub returned an invalid pull-request base ref")
1111
+ snapshot = materialize_snapshot(
1112
+ repository=target.repository,
1113
+ number=target.number,
1114
+ base_ref=base_ref,
1115
+ base_oid=base_oid,
1116
+ head_oid=head_oid,
1117
+ )
1118
+ try:
1119
+ return immutable_changed_paths(
1120
+ base_oid, head_oid, cwd=snapshot.source_path
1121
+ ), snapshot
1122
+ except BaseException:
1123
+ remove_snapshot(snapshot.root)
1124
+ raise
1125
+
1126
+
1127
+ def gh(*arguments: str, accepted_codes: tuple[int, ...] = (0,)) -> str:
1128
+ result = run_command(
1129
+ ["gh", *arguments], capture_output=True, text=True, check=False
1130
+ )
1131
+ if result.returncode not in accepted_codes:
1132
+ raise RuntimeError(result.stderr.strip() or f"gh {' '.join(arguments)} failed")
1133
+ return result.stdout
1134
+
1135
+
1136
+ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]]:
1137
+ """Return complete GitHub check-run evidence bound to one immutable commit."""
1138
+ total_count: int | None = None
1139
+ runs: list[dict[str, Any]] = []
1140
+ run_ids: set[int] = set()
1141
+ bytes_read = 0
1142
+ for page_number in range(1, MAX_CHECK_RUN_PAGES + 2):
1143
+ if page_number > MAX_CHECK_RUN_PAGES:
1144
+ raise CheckEvidenceCoverageGap(
1145
+ "GitHub check runs exceed the safe page limit"
1146
+ )
1147
+ remaining_bytes = MAX_CHECK_RUN_BYTES - bytes_read
1148
+ if remaining_bytes <= 0:
1149
+ raise CheckEvidenceCoverageGap(
1150
+ "GitHub check runs exceed the safe aggregate byte limit"
1151
+ )
1152
+ try:
1153
+ response = bounded_gh_output(
1154
+ (
1155
+ "api",
1156
+ "--hostname",
1157
+ "github.com",
1158
+ "--method",
1159
+ "GET",
1160
+ "-H",
1161
+ "Accept: application/vnd.github+json",
1162
+ (
1163
+ f"repos/{repository}/commits/{head_oid}/check-runs?"
1164
+ f"per_page=100&page={page_number}"
1165
+ ),
1166
+ ),
1167
+ maximum_bytes=min(MAX_CHECK_RUN_PAGE_BYTES, remaining_bytes),
1168
+ limit_error="GitHub check-run response exceeds the safe byte limit",
1169
+ timeout_seconds=CHECK_RUN_REQUEST_TIMEOUT_SECONDS,
1170
+ stderr_maximum_bytes=MAX_CHECK_RUN_STDERR_BYTES,
1171
+ stderr_limit_error="GitHub check-run response exceeds the safe stderr limit",
1172
+ deadline_error="GitHub check-run provider exceeded the safe provider deadline",
1173
+ output_error="cannot read GitHub check-run provider output",
1174
+ unavailable_output_error="GitHub did not provide check-run provider output",
1175
+ operating_system_error="cannot collect GitHub check runs",
1176
+ coverage_gap=CheckEvidenceCoverageGap,
1177
+ )
1178
+ except RuntimeError as error:
1179
+ if isinstance(error, CheckEvidenceCoverageGap):
1180
+ raise
1181
+ raise CheckEvidenceCoverageGap(
1182
+ "GitHub did not return readable head-bound check evidence"
1183
+ ) from error
1184
+ bytes_read += len(response)
1185
+ try:
1186
+ page = json.loads(response)
1187
+ except json.JSONDecodeError as error:
1188
+ raise CheckEvidenceCoverageGap(
1189
+ "GitHub did not return readable head-bound check evidence"
1190
+ ) from error
1191
+ if not isinstance(page, dict):
1192
+ raise CheckEvidenceCoverageGap("GitHub returned a malformed check-run page")
1193
+ page_total = page.get("total_count")
1194
+ page_runs = page.get("check_runs")
1195
+ if (
1196
+ isinstance(page_total, bool)
1197
+ or not isinstance(page_total, int)
1198
+ or page_total < 0
1199
+ or not isinstance(page_runs, list)
1200
+ ):
1201
+ raise CheckEvidenceCoverageGap(
1202
+ "GitHub returned incomplete check-run evidence"
1203
+ )
1204
+ if total_count is None:
1205
+ total_count = page_total
1206
+ if total_count > MAX_CHECK_RUNS:
1207
+ raise CheckEvidenceCoverageGap(
1208
+ "GitHub check runs exceed the safe run limit"
1209
+ )
1210
+ elif page_total != total_count:
1211
+ raise CheckEvidenceCoverageGap(
1212
+ "GitHub returned inconsistent check-run totals"
1213
+ )
1214
+ for run in page_runs:
1215
+ if not isinstance(run, dict):
1216
+ raise CheckEvidenceCoverageGap("GitHub returned a malformed check run")
1217
+ run_id = run.get("id")
1218
+ run_head_oid = run.get("head_sha")
1219
+ if (
1220
+ isinstance(run_id, bool)
1221
+ or not isinstance(run_id, int)
1222
+ or run_id < 1
1223
+ or run_id in run_ids
1224
+ or not isinstance(run.get("name"), str)
1225
+ or not run["name"]
1226
+ or not isinstance(run.get("status"), str)
1227
+ or not isinstance(run.get("conclusion"), str | type(None))
1228
+ or not isinstance(run_head_oid, str)
1229
+ or COMMIT_OID.fullmatch(run_head_oid) is None
1230
+ ):
1231
+ raise CheckEvidenceCoverageGap(
1232
+ "GitHub returned incomplete check-run evidence"
1233
+ )
1234
+ if run_head_oid != head_oid:
1235
+ raise CheckEvidenceCoverageGap(
1236
+ "GitHub returned a check run bound to a different head OID"
1237
+ )
1238
+ run_ids.add(run_id)
1239
+ runs.append(run)
1240
+ if len(runs) == total_count:
1241
+ return runs
1242
+ if not page_runs:
1243
+ raise CheckEvidenceCoverageGap("GitHub returned partial check-run evidence")
1244
+ raise AssertionError("bounded check-run pagination did not terminate")
1245
+
1246
+
1247
+ def pr_metadata(
1248
+ pull_request: str, target: ExpectedReviewTarget | None
1249
+ ) -> dict[str, Any]:
1250
+ """Read one PR through the retained target when strict evidence is required."""
1251
+ command = ["pr", "view", pull_request]
1252
+ if target is not None:
1253
+ command.extend(("--repo", target.repository_argument()))
1254
+ command.extend(("--json", FIELDS))
1255
+ metadata = json.loads(gh(*command))
1256
+ if not isinstance(metadata, dict):
1257
+ raise TypeError("GitHub returned an invalid pull-request object")
1258
+ return metadata
1259
+
1260
+
1261
+ def main(argv: Sequence[str] | None = None) -> int:
1262
+ parser = argument_parser(description=__doc__)
1263
+ parser.add_argument(
1264
+ "--expected-base-oid",
1265
+ metavar="BASE_OID",
1266
+ help="immutable base revision returned by resolve_pr.py",
1267
+ )
1268
+ parser.add_argument(
1269
+ "--expected-head-oid",
1270
+ metavar="HEAD_OID",
1271
+ help="immutable head revision returned by resolve_pr.py",
1272
+ )
1273
+ parser.add_argument(
1274
+ "--expected-host",
1275
+ metavar="HOST",
1276
+ help="canonical GitHub host returned by resolve_pr.py",
1277
+ )
1278
+ parser.add_argument(
1279
+ "--expected-repository",
1280
+ metavar="OWNER/REPOSITORY",
1281
+ help="canonical GitHub repository returned by resolve_pr.py",
1282
+ )
1283
+ parser.add_argument(
1284
+ "--expected-pr-number",
1285
+ metavar="NUMBER",
1286
+ type=int,
1287
+ help="canonical pull-request number returned by resolve_pr.py",
1288
+ )
1289
+ parser.add_argument(
1290
+ "--expected-pr-url",
1291
+ metavar="URL",
1292
+ help="canonical pull-request URL returned by resolve_pr.py",
1293
+ )
1294
+ parser.add_argument("pull_request", metavar="PR_NUMBER_OR_URL")
1295
+ arguments = parser.parse_args(argv)
1296
+ pull_request = arguments.pull_request
1297
+ expected = expected_identity(
1298
+ parser, arguments.expected_base_oid, arguments.expected_head_oid
1299
+ )
1300
+ target = expected_target(
1301
+ parser,
1302
+ expected,
1303
+ arguments.expected_host,
1304
+ arguments.expected_repository,
1305
+ arguments.expected_pr_number,
1306
+ arguments.expected_pr_url,
1307
+ )
1308
+ require_immutable_identity = expected is not None
1309
+ try:
1310
+ validate_pr_identifier(pull_request)
1311
+ requested = pull_request_number(pull_request)
1312
+ if target is not None:
1313
+ if requested != target.number:
1314
+ raise RuntimeError(
1315
+ "requested pull request does not match the expected target number"
1316
+ )
1317
+ if pull_request.startswith("https://") and pull_request != target.url:
1318
+ raise RuntimeError(
1319
+ "requested pull request does not match the expected target URL"
1320
+ )
1321
+ metadata = pr_metadata(pull_request, target)
1322
+ metadata_problem = metadata_error(
1323
+ metadata, require_immutable_identity=require_immutable_identity
1324
+ )
1325
+ if metadata_problem:
1326
+ structured_error("incomplete PR metadata", metadata_problem)
1327
+ return 1
1328
+ if target is None:
1329
+ repository_data = json.loads(gh("repo", "view", "--json", "nameWithOwner"))
1330
+ repository = repository_data.get("nameWithOwner")
1331
+ else:
1332
+ repository = target.repository
1333
+ number = metadata.get("number")
1334
+ url = metadata.get("url")
1335
+ if (
1336
+ not isinstance(repository, str)
1337
+ or not isinstance(number, int)
1338
+ or not isinstance(url, str)
1339
+ ):
1340
+ raise TypeError("GitHub returned incomplete repository or PR identity")
1341
+ pull_repository = repository_from_pr_url(url, number)
1342
+ if pull_repository.casefold() != repository.casefold():
1343
+ raise RuntimeError(
1344
+ f"pull request {url} does not belong to current repository {repository}"
1345
+ )
1346
+ if number != requested:
1347
+ raise RuntimeError(
1348
+ "GitHub returned a pull request different from the requested identifier"
1349
+ )
1350
+ identity = immutable_identity(
1351
+ metadata,
1352
+ repository,
1353
+ require_immutable_identity=require_immutable_identity,
1354
+ )
1355
+ ensure_expected_identity(identity, expected)
1356
+ ensure_expected_target(identity, target)
1357
+ reviewed_scope = review_scope(metadata) if expected is not None else None
1358
+ linked_requirement_budget = (
1359
+ LinkedRequirementBudget() if expected is not None else None
1360
+ )
1361
+ reviewed_linked_requirements = (
1362
+ linked_requirements(metadata, linked_requirement_budget)
1363
+ if expected is not None
1364
+ else None
1365
+ )
1366
+ changed_path_manifest: ChangedPathManifest | None = None
1367
+ source_snapshot: MaterializedSnapshot | None = None
1368
+ check_evidence: dict[str, str | int] | None = None
1369
+ if expected is not None:
1370
+ assert target is not None
1371
+ changed_path_manifest, source_snapshot = strict_changed_paths(
1372
+ metadata, expected, target
1373
+ )
1374
+ changed_files = list(changed_path_manifest.paths)
1375
+ try:
1376
+ checks = head_bound_check_runs(repository, expected[1])
1377
+ except CheckEvidenceCoverageGap as error:
1378
+ checks = []
1379
+ check_evidence = {
1380
+ "status": "coverage_gap",
1381
+ "reason": str(error),
1382
+ "head_oid": expected[1],
1383
+ }
1384
+ else:
1385
+ check_evidence = {
1386
+ "status": "head_bound",
1387
+ "head_oid": expected[1],
1388
+ "count": len(checks),
1389
+ }
1390
+ else:
1391
+ changed_files = [
1392
+ line
1393
+ for line in gh(
1394
+ "api",
1395
+ "--paginate",
1396
+ f"repos/{repository}/pulls/{number}/files",
1397
+ "--jq",
1398
+ ".[].filename",
1399
+ ).splitlines()
1400
+ if line
1401
+ ]
1402
+ checks = json.loads(
1403
+ gh(
1404
+ "pr",
1405
+ "checks",
1406
+ pull_request,
1407
+ "--json",
1408
+ "name,state,startedAt,completedAt,link,workflow",
1409
+ accepted_codes=(0, 1, 8),
1410
+ )
1411
+ )
1412
+ if not isinstance(checks, list):
1413
+ raise RuntimeError("GitHub returned invalid check evidence")
1414
+ final_metadata = pr_metadata(pull_request, target)
1415
+ final_problem = metadata_error(
1416
+ final_metadata, require_immutable_identity=require_immutable_identity
1417
+ )
1418
+ if final_problem:
1419
+ structured_error("incomplete PR metadata", final_problem)
1420
+ return 1
1421
+ final_identity = immutable_identity(
1422
+ final_metadata,
1423
+ repository,
1424
+ require_immutable_identity=require_immutable_identity,
1425
+ )
1426
+ if final_identity != identity:
1427
+ raise RuntimeError(
1428
+ "immutable pull-request identity changed while collecting evidence"
1429
+ )
1430
+ ensure_expected_identity(final_identity, expected)
1431
+ ensure_expected_target(final_identity, target)
1432
+ final_scope = review_scope(final_metadata) if expected is not None else None
1433
+ if final_scope != reviewed_scope:
1434
+ raise RuntimeError("review scope changed while collecting evidence")
1435
+ final_linked_requirements = (
1436
+ linked_requirements(final_metadata, linked_requirement_budget)
1437
+ if expected is not None
1438
+ else None
1439
+ )
1440
+ if final_linked_requirements != reviewed_linked_requirements:
1441
+ raise RuntimeError(
1442
+ "linked issue requirements changed while collecting evidence"
1443
+ )
1444
+ except ChangedPathCoverageGap as error:
1445
+ structured_error("changed path coverage gap", str(error))
1446
+ return 1
1447
+ except LinkedRequirementsCoverageGap as error:
1448
+ structured_error("linked issue requirements coverage gap", str(error))
1449
+ return 1
1450
+ except (RuntimeError, TypeError, json.JSONDecodeError) as error:
1451
+ print(error, file=sys.stderr)
1452
+ return 1
1453
+ evidence: dict[str, object] = {
1454
+ "changed_files": changed_files,
1455
+ "changed_paths": changed_files,
1456
+ "checks": checks,
1457
+ "pull_request": final_metadata,
1458
+ }
1459
+ if identity is not None:
1460
+ evidence["reviewed_identity"] = identity.as_json()
1461
+ if reviewed_scope is not None:
1462
+ evidence["reviewed_scope"] = reviewed_scope.as_json()
1463
+ if reviewed_linked_requirements is not None:
1464
+ evidence["reviewed_linked_requirements"] = (
1465
+ reviewed_linked_requirements.as_json()
1466
+ )
1467
+ if changed_path_manifest is not None:
1468
+ evidence["changed_path_manifest"] = changed_path_manifest.as_json()
1469
+ if source_snapshot is not None:
1470
+ evidence["source_snapshot"] = source_snapshot.as_json()
1471
+ if check_evidence is not None:
1472
+ evidence["check_evidence"] = check_evidence
1473
+ print(json.dumps(evidence, sort_keys=True))
1474
+ return 0
1475
+
1476
+
1477
+ if __name__ == "__main__":
1478
+ raise SystemExit(main())