@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.
- package/LICENSE +29 -0
- package/NOTICE +22 -0
- package/README.md +45 -0
- package/package.json +29 -0
- package/plugin.js +54 -0
- package/skills/THIRD_PARTY_LICENSES.md +50 -0
- package/skills/_cli.py +152 -0
- package/skills/advise/SKILL.md +62 -0
- package/skills/advise/scripts/list_retrievable_skills.py +49 -0
- package/skills/brainstorm/SKILL.md +110 -0
- package/skills/change-review/SKILL.md +68 -0
- package/skills/change-review/references/scope-resolution.md +52 -0
- package/skills/change-review/scripts/resolve_scope.py +1219 -0
- package/skills/finalize-plan/SKILL.md +129 -0
- package/skills/git-worktrees/SKILL.md +113 -0
- package/skills/git-worktrees/scripts/prepare_worktree.py +153 -0
- package/skills/issue-review/SKILL.md +67 -0
- package/skills/learn/SKILL.md +208 -0
- package/skills/myrmidon-swarm/SKILL.md +93 -0
- package/skills/plan-issue/SKILL.md +70 -0
- package/skills/pr-review/SKILL.md +114 -0
- package/skills/pr-review/references/criteria.md +26 -0
- package/skills/pr-review/references/delivery.md +135 -0
- package/skills/pr-review/references/evidence.md +233 -0
- package/skills/pr-review/references/prevalidated.md +155 -0
- package/skills/pr-review/scripts/collect_evidence.py +1478 -0
- package/skills/pr-review/scripts/diff_context.py +74 -0
- package/skills/pr-review/scripts/materialize_snapshot.py +731 -0
- package/skills/pr-review/scripts/pr_identity.py +80 -0
- package/skills/pr-review/scripts/resolve_pr.py +258 -0
- package/skills/repo-review/SKILL.md +119 -0
- package/skills/systematic-debugging/SKILL.md +199 -0
- package/skills/systematic-debugging/scripts/repository_evidence.py +77 -0
- package/skills/test-driven-development/SKILL.md +75 -0
- package/skills/tidy/SKILL.md +71 -0
- 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())
|