@homericintelligence/athena-opencode 0.4.4 → 0.5.1
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/README.md +30 -17
- package/package.json +2 -2
- package/plugin.js +20 -5
- package/skills/TECHNICAL_ENGLISH.md +78 -0
- package/skills/_cli.py +26 -18
- package/skills/_support/docs/dependency-resolution.md +192 -0
- package/skills/_support/docs/policies/development.md +117 -0
- package/skills/_support/docs/policies/evidence-integrity.md +40 -0
- package/skills/_support/docs/principles/README.md +542 -0
- package/skills/_support/docs/principles/details/p001-kiss.md +114 -0
- package/skills/_support/docs/principles/details/p002-yagni.md +109 -0
- package/skills/_support/docs/principles/details/p003-dry.md +126 -0
- package/skills/_support/docs/principles/details/p004-solid.md +165 -0
- package/skills/_support/docs/principles/details/p005-modularity.md +123 -0
- package/skills/_support/docs/principles/details/p006-principle-of-least-astonishment.md +115 -0
- package/skills/_support/docs/principles/details/p007-subtraction-over-addition.md +113 -0
- package/skills/_support/docs/principles/details/p008-understand-before-subtracting.md +115 -0
- package/skills/_support/docs/principles/details/p009-general-mechanisms-over-special-cases.md +108 -0
- package/skills/_support/docs/principles/details/p010-scope-fidelity.md +117 -0
- package/skills/_support/docs/principles/details/p011-minimal-coherent-change.md +128 -0
- package/skills/_support/docs/principles/details/p012-evidence-before-modification.md +115 -0
- package/skills/_support/docs/principles/details/p013-avoid-hasty-abstractions.md +112 -0
- package/skills/_support/docs/principles/details/p014-preserve-unrequested-behavior.md +110 -0
- package/skills/_support/docs/principles/details/p015-architecture-conformance.md +124 -0
- package/skills/_support/docs/principles/details/p016-separation-of-concerns.md +125 -0
- package/skills/_support/docs/principles/details/p017-high-cohesion-low-coupling.md +130 -0
- package/skills/_support/docs/principles/details/p018-information-hiding.md +128 -0
- package/skills/_support/docs/principles/details/p019-explicit-contracts.md +134 -0
- package/skills/_support/docs/principles/details/p020-executable-architecture.md +130 -0
- package/skills/_support/docs/principles/details/p021-evolutionary-and-reversible-design.md +126 -0
- package/skills/_support/docs/principles/details/p022-test-behavior-not-implementation.md +129 -0
- package/skills/_support/docs/principles/details/p023-parameterized-table-driven-testing.md +129 -0
- package/skills/_support/docs/principles/details/p024-boundary-value-testing.md +128 -0
- package/skills/_support/docs/principles/details/p025-property-based-testing-for-invariants.md +133 -0
- package/skills/_support/docs/principles/details/p026-regression-before-repair.md +134 -0
- package/skills/_support/docs/principles/details/p027-deterministic-and-hermetic-tests.md +128 -0
- package/skills/_support/docs/principles/details/p028-test-failure-paths.md +133 -0
- package/skills/_support/docs/principles/details/p029-generalize-error-policy-preserve-specific-cause.md +131 -0
- package/skills/_support/docs/principles/details/p030-nearest-responsible-error-boundary.md +133 -0
- package/skills/_support/docs/principles/details/p031-propagate-rather-than-swallow.md +131 -0
- package/skills/_support/docs/principles/details/p032-handle-once-preserve-causality.md +137 -0
- package/skills/_support/docs/principles/details/p033-state-safe-failure-semantics.md +140 -0
- package/skills/_support/docs/principles/details/p034-fail-fast.md +132 -0
- package/skills/_support/docs/principles/details/p035-fail-secure-fail-closed.md +137 -0
- package/skills/_support/docs/principles/details/p036-graceful-degradation.md +137 -0
- package/skills/_support/docs/principles/details/p037-idempotency-before-retry.md +139 -0
- package/skills/_support/docs/principles/details/p038-bounded-retry.md +145 -0
- package/skills/_support/docs/principles/details/p039-bounded-waiting.md +151 -0
- package/skills/_support/docs/principles/details/p040-bounded-resources.md +138 -0
- package/skills/_support/docs/principles/details/p041-backpressure-and-load-shedding.md +141 -0
- package/skills/_support/docs/principles/details/p042-fault-isolation-bulkheads.md +140 -0
- package/skills/_support/docs/principles/details/p043-circuit-breakers.md +148 -0
- package/skills/_support/docs/principles/details/p044-atomicity-where-possible.md +141 -0
- package/skills/_support/docs/principles/details/p045-compensation-where-atomicity-is-impossible.md +163 -0
- package/skills/_support/docs/principles/details/p046-resumability.md +138 -0
- package/skills/_support/docs/principles/details/p047-observability-is-part-of-correctness.md +134 -0
- package/skills/_support/docs/principles/details/p048-secure-by-design.md +132 -0
- package/skills/_support/docs/principles/details/p049-secure-by-default.md +126 -0
- package/skills/_support/docs/principles/details/p050-least-privilege.md +123 -0
- package/skills/_support/docs/principles/details/p051-complete-mediation.md +124 -0
- package/skills/_support/docs/principles/details/p052-separation-of-duties.md +129 -0
- package/skills/_support/docs/principles/details/p053-validate-at-trust-boundaries.md +126 -0
- package/skills/_support/docs/principles/details/p054-defense-in-depth.md +135 -0
- package/skills/_support/docs/principles/details/p055-minimize-attack-surface.md +133 -0
- package/skills/_support/docs/principles/details/p056-secrets-stay-out-of-code-and-context.md +137 -0
- package/skills/_support/docs/principles/details/p057-supply-chain-integrity.md +135 -0
- package/skills/_support/docs/principles/details/p058-bounded-agent-authority.md +137 -0
- package/skills/_support/docs/principles/details/p059-data-is-not-instruction.md +130 -0
- package/skills/_support/docs/principles/details/p060-constrain-sub-agents.md +136 -0
- package/skills/_support/docs/principles/details/p061-separate-decision-from-high-impact-execution.md +131 -0
- package/skills/_support/docs/principles/details/p062-human-approval-for-irreversible-or-high-risk-actions.md +129 -0
- package/skills/_support/docs/principles/details/p063-requirement-to-code-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p064-requirement-to-test-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +129 -0
- package/skills/_support/docs/principles/details/p066-preserve-existing-work.md +114 -0
- package/skills/_support/docs/principles/details/p067-no-test-cheating.md +126 -0
- package/skills/_support/docs/principles/details/p068-no-validation-bypass.md +119 -0
- package/skills/_support/docs/principles/details/p069-independent-review-for-high-risk-changes.md +129 -0
- package/skills/_support/docs/principles/details/p070-code-health-must-not-regress.md +116 -0
- package/skills/_support/docs/principles/details/p071-consistency-over-personal-preference.md +117 -0
- package/skills/_support/docs/principles/details/p072-technical-evidence-over-preference.md +122 -0
- package/skills/_support/docs/principles/details/p073-optimize-only-with-evidence.md +123 -0
- package/skills/_support/docs/principles/details/p074-prefer-existing-mechanisms.md +121 -0
- package/skills/_support/docs/principles/details/p075-make-invalid-states-hard-to-represent.md +128 -0
- package/skills/_support/docs/principles/details/p076-parse-then-validate-then-operate.md +133 -0
- package/skills/_support/docs/principles/details/p077-separate-policy-from-mechanism.md +112 -0
- package/skills/_support/docs/principles/details/p078-single-source-of-truth.md +131 -0
- package/skills/_support/docs/principles/details/p079-explicit-ownership-and-lifetimes.md +124 -0
- package/skills/_support/docs/principles/details/p080-make-concurrency-deliberate.md +122 -0
- package/skills/_support/docs/principles/details/p081-forward-progress-with-safety.md +125 -0
- package/skills/_support/docs/principles/details/p082-design-for-cancellation.md +133 -0
- package/skills/_support/docs/principles/details/p083-irreversible-actions-last.md +124 -0
- package/skills/_support/docs/principles/details/p084-prefer-local-reasoning.md +131 -0
- package/skills/_support/docs/principles/details/p085-explicit-is-better-than-implicit.md +123 -0
- package/skills/_support/docs/principles/details/p086-readability-counts.md +113 -0
- package/skills/_support/docs/principles/details/p087-comments-explain-why-code-explains-what.md +120 -0
- package/skills/_support/docs/principles/details/p088-delete-dead-code.md +125 -0
- package/skills/_support/docs/principles/details/p089-delete-obsolete-configuration-and-dependencies.md +125 -0
- package/skills/_support/docs/principles/details/p090-prefer-negative-code.md +134 -0
- package/skills/_support/docs/principles/details/p091-test-driven-development.md +149 -0
- package/skills/_support/docs/review/README.md +59 -0
- package/skills/_support/docs/review/behavior-first-testing.md +73 -0
- package/skills/_support/docs/review/common.md +365 -0
- package/skills/_support/docs/review/design-docs.md +74 -0
- package/skills/_support/docs/review/issue-planning.md +220 -0
- package/skills/_support/docs/review/language-routing.md +141 -0
- package/skills/_support/docs/review/repository-scorecard.md +170 -0
- package/skills/advise/SKILL.md +102 -40
- package/skills/advise/scripts/list_retrievable_skills.py +3 -3
- package/skills/brainstorm/SKILL.md +167 -76
- package/skills/change-review/SKILL.md +99 -43
- package/skills/change-review/references/scope-resolution.md +70 -41
- package/skills/change-review/scripts/resolve_scope.py +119 -73
- package/skills/finalize-plan/SKILL.md +208 -100
- package/skills/git-worktrees/SKILL.md +108 -59
- package/skills/git-worktrees/scripts/prepare_worktree.py +16 -10
- package/skills/issue-review/SKILL.md +143 -47
- package/skills/learn/SKILL.md +284 -162
- package/skills/myrmidon-swarm/SKILL.md +136 -61
- package/skills/plan-issue/SKILL.md +129 -47
- package/skills/pr-review/SKILL.md +170 -71
- package/skills/pr-review/references/criteria.md +61 -14
- package/skills/pr-review/references/delivery.md +159 -80
- package/skills/pr-review/references/evidence.md +268 -139
- package/skills/pr-review/references/prevalidated.md +91 -48
- package/skills/pr-review/scripts/collect_evidence.py +211 -140
- package/skills/pr-review/scripts/diff_context.py +6 -4
- package/skills/pr-review/scripts/materialize_snapshot.py +112 -120
- package/skills/pr-review/scripts/pr_identity.py +27 -15
- package/skills/pr-review/scripts/resolve_pr.py +53 -31
- package/skills/repo-review/SKILL.md +209 -87
- package/skills/systematic-debugging/SKILL.md +195 -131
- package/skills/systematic-debugging/scripts/repository_evidence.py +9 -4
- package/skills/test-driven-development/SKILL.md +134 -53
- package/skills/tidy/SKILL.md +97 -33
- package/skills/tidy/scripts/run_tidy.py +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Collect GitHub
|
|
2
|
+
"""Collect GitHub pull-request metadata and immutable review evidence."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -42,8 +42,8 @@ from skills._cli import (
|
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
# Keep this query below GitHub's GraphQL complexity budget. Strict callers bind
|
|
45
|
-
# changed paths to local immutable Git objects
|
|
46
|
-
# file-list fallback for
|
|
45
|
+
# changed paths to local immutable Git objects. Earlier callers use the REST
|
|
46
|
+
# file-list fallback only for backward compatibility.
|
|
47
47
|
FIELDS = (
|
|
48
48
|
"number,title,body,state,isDraft,author,baseRefName,headRefName,"
|
|
49
49
|
"baseRefOid,headRefOid,reviews,statusCheckRollup,closingIssuesReferences,url"
|
|
@@ -90,7 +90,7 @@ class CheckEvidenceCoverageGap(RuntimeError):
|
|
|
90
90
|
|
|
91
91
|
@dataclass
|
|
92
92
|
class LinkedRequirementBudget:
|
|
93
|
-
"""
|
|
93
|
+
"""This budget controls both strict evidence reads from the provider."""
|
|
94
94
|
|
|
95
95
|
pages: int = 0
|
|
96
96
|
comments: int = 0
|
|
@@ -105,7 +105,7 @@ class LinkedRequirementBudget:
|
|
|
105
105
|
"""Reserve one bounded provider request before issuing it."""
|
|
106
106
|
if self.requests >= MAX_LINKED_REQUIREMENT_REQUESTS:
|
|
107
107
|
raise LinkedRequirementsCoverageGap(
|
|
108
|
-
"linked issue requirements exceed the safe aggregate request limit"
|
|
108
|
+
"The linked issue requirements exceed the safe aggregate request limit."
|
|
109
109
|
)
|
|
110
110
|
self.requests += 1
|
|
111
111
|
|
|
@@ -113,7 +113,7 @@ class LinkedRequirementBudget:
|
|
|
113
113
|
"""Reserve one aggregate comment page and its provider request."""
|
|
114
114
|
if self.pages >= MAX_LINKED_REQUIREMENT_PAGES:
|
|
115
115
|
raise LinkedRequirementsCoverageGap(
|
|
116
|
-
"linked issue requirements exceed the safe aggregate page limit"
|
|
116
|
+
"The linked issue requirements exceed the safe aggregate page limit."
|
|
117
117
|
)
|
|
118
118
|
self.reserve_request()
|
|
119
119
|
|
|
@@ -121,7 +121,7 @@ class LinkedRequirementBudget:
|
|
|
121
121
|
"""Account for one provider response without crossing the byte budget."""
|
|
122
122
|
if count > self.remaining_bytes():
|
|
123
123
|
raise LinkedRequirementsCoverageGap(
|
|
124
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
124
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
125
125
|
)
|
|
126
126
|
self.bytes_read += count
|
|
127
127
|
|
|
@@ -129,7 +129,7 @@ class LinkedRequirementBudget:
|
|
|
129
129
|
"""Account for one successful provider page and its item count."""
|
|
130
130
|
if self.comments + count > MAX_LINKED_REQUIREMENT_COMMENTS:
|
|
131
131
|
raise LinkedRequirementsCoverageGap(
|
|
132
|
-
"linked issue requirements exceed the safe aggregate comment limit"
|
|
132
|
+
"The linked issue requirements exceed the safe aggregate comment limit."
|
|
133
133
|
)
|
|
134
134
|
self.pages += 1
|
|
135
135
|
self.comments += count
|
|
@@ -137,7 +137,7 @@ class LinkedRequirementBudget:
|
|
|
137
137
|
|
|
138
138
|
@dataclass
|
|
139
139
|
class ProviderStream:
|
|
140
|
-
"""
|
|
140
|
+
"""This record contains one bounded asynchronous output stream."""
|
|
141
141
|
|
|
142
142
|
maximum_bytes: int
|
|
143
143
|
output: bytearray = field(default_factory=bytearray)
|
|
@@ -148,7 +148,7 @@ class ProviderStream:
|
|
|
148
148
|
|
|
149
149
|
@dataclass
|
|
150
150
|
class ChangedPathStream:
|
|
151
|
-
"""Incrementally validate a bounded
|
|
151
|
+
"""Incrementally validate a bounded null-byte-delimited Git path manifest."""
|
|
152
152
|
|
|
153
153
|
maximum_bytes: int
|
|
154
154
|
maximum_paths: int
|
|
@@ -164,7 +164,7 @@ class ChangedPathStream:
|
|
|
164
164
|
|
|
165
165
|
@dataclass(frozen=True)
|
|
166
166
|
class ImmutableIdentity:
|
|
167
|
-
"""
|
|
167
|
+
"""This record binds evidence to a review artifact and its exact revisions."""
|
|
168
168
|
|
|
169
169
|
repository: str
|
|
170
170
|
number: int
|
|
@@ -187,7 +187,7 @@ class ImmutableIdentity:
|
|
|
187
187
|
|
|
188
188
|
@dataclass(frozen=True)
|
|
189
189
|
class ExpectedReviewTarget:
|
|
190
|
-
"""
|
|
190
|
+
"""This record identifies the immutable GitHub target for strict collection."""
|
|
191
191
|
|
|
192
192
|
host: str
|
|
193
193
|
repository: str
|
|
@@ -201,7 +201,7 @@ class ExpectedReviewTarget:
|
|
|
201
201
|
|
|
202
202
|
@dataclass(frozen=True)
|
|
203
203
|
class ChangedPathManifest:
|
|
204
|
-
"""
|
|
204
|
+
"""This record describes a canonical changed-path manifest from Git objects."""
|
|
205
205
|
|
|
206
206
|
paths: tuple[str, ...]
|
|
207
207
|
sha256: str
|
|
@@ -217,7 +217,7 @@ class ChangedPathManifest:
|
|
|
217
217
|
|
|
218
218
|
@dataclass(frozen=True)
|
|
219
219
|
class ReviewScope:
|
|
220
|
-
"""
|
|
220
|
+
"""This record binds mutable review-context fields to an evidence collection."""
|
|
221
221
|
|
|
222
222
|
fields: dict[str, Any]
|
|
223
223
|
sha256: str
|
|
@@ -229,7 +229,7 @@ class ReviewScope:
|
|
|
229
229
|
|
|
230
230
|
@dataclass(frozen=True)
|
|
231
231
|
class LinkedRequirements:
|
|
232
|
-
"""
|
|
232
|
+
"""This record binds the canonical content of each linked issue used as requirements."""
|
|
233
233
|
|
|
234
234
|
items: tuple[LinkedRequirement, ...]
|
|
235
235
|
sha256: str
|
|
@@ -245,7 +245,7 @@ class LinkedRequirements:
|
|
|
245
245
|
|
|
246
246
|
@dataclass(frozen=True)
|
|
247
247
|
class LinkedRequirement:
|
|
248
|
-
"""
|
|
248
|
+
"""This record contains one linked issue identity and its requirements digest."""
|
|
249
249
|
|
|
250
250
|
id: str
|
|
251
251
|
repository: str
|
|
@@ -265,9 +265,9 @@ class LinkedRequirement:
|
|
|
265
265
|
|
|
266
266
|
|
|
267
267
|
def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str | None:
|
|
268
|
-
"""Return a diagnostic when GitHub returns partial
|
|
268
|
+
"""Return a diagnostic when GitHub returns partial pull-request metadata."""
|
|
269
269
|
if not isinstance(metadata, dict):
|
|
270
|
-
return "
|
|
270
|
+
return "The pull-request metadata must be a JSON object."
|
|
271
271
|
required_types = {
|
|
272
272
|
"number": int,
|
|
273
273
|
"title": str,
|
|
@@ -287,11 +287,13 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
287
287
|
if not isinstance(author, dict) or not isinstance(author.get("login"), str):
|
|
288
288
|
invalid.append("author.login")
|
|
289
289
|
if invalid:
|
|
290
|
-
return
|
|
291
|
-
|
|
290
|
+
return (
|
|
291
|
+
"GitHub returned pull-request metadata fields that are incomplete or not valid: "
|
|
292
|
+
+ ", ".join(f"'{field}'" for field in sorted(set(invalid)))
|
|
293
|
+
+ "."
|
|
292
294
|
)
|
|
293
295
|
if metadata["state"] != "OPEN":
|
|
294
|
-
return f"
|
|
296
|
+
return f"Pull request {metadata['number']} is not open."
|
|
295
297
|
identity_fields = ("baseRefOid", "headRefOid")
|
|
296
298
|
identity_values = [metadata.get(field) for field in identity_fields]
|
|
297
299
|
has_identity = any(value is not None for value in identity_values)
|
|
@@ -303,8 +305,10 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
303
305
|
]
|
|
304
306
|
if invalid_identity:
|
|
305
307
|
return (
|
|
306
|
-
"GitHub returned
|
|
307
|
-
|
|
308
|
+
"GitHub returned immutable pull-request identity fields that are "
|
|
309
|
+
"incomplete or not valid: "
|
|
310
|
+
+ ", ".join(f"'{field}'" for field in invalid_identity)
|
|
311
|
+
+ "."
|
|
308
312
|
)
|
|
309
313
|
if require_immutable_identity:
|
|
310
314
|
body = metadata.get("body")
|
|
@@ -320,8 +324,9 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
320
324
|
scope_invalid.append("closingIssuesReferences")
|
|
321
325
|
if scope_invalid:
|
|
322
326
|
return (
|
|
323
|
-
"GitHub returned incomplete or
|
|
324
|
-
+ ", ".join(scope_invalid)
|
|
327
|
+
"GitHub returned review-scope fields that are incomplete or not valid: "
|
|
328
|
+
+ ", ".join(f"'{field}'" for field in scope_invalid)
|
|
329
|
+
+ "."
|
|
325
330
|
)
|
|
326
331
|
return None
|
|
327
332
|
|
|
@@ -335,11 +340,11 @@ def immutable_identity(
|
|
|
335
340
|
if base_oid is None and head_oid is None and not require_immutable_identity:
|
|
336
341
|
return None
|
|
337
342
|
if not isinstance(base_oid, str) or not isinstance(head_oid, str):
|
|
338
|
-
raise TypeError("GitHub returned incomplete immutable pull-request identity")
|
|
343
|
+
raise TypeError("GitHub returned incomplete immutable pull-request identity.")
|
|
339
344
|
number = metadata.get("number")
|
|
340
345
|
url = metadata.get("url")
|
|
341
346
|
if not isinstance(number, int) or not isinstance(url, str):
|
|
342
|
-
raise TypeError("GitHub returned incomplete pull-request identity")
|
|
347
|
+
raise TypeError("GitHub returned incomplete pull-request identity.")
|
|
343
348
|
return ImmutableIdentity(
|
|
344
349
|
repository=repository,
|
|
345
350
|
number=number,
|
|
@@ -354,15 +359,19 @@ def expected_identity(
|
|
|
354
359
|
) -> tuple[str, str] | None:
|
|
355
360
|
"""Validate the optional immutable identity supplied by resolve_pr.py."""
|
|
356
361
|
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
|
-
)
|
|
362
|
+
parser.error("Specify --expected-base-oid and --expected-head-oid together.")
|
|
360
363
|
if base_oid is None or head_oid is None:
|
|
361
364
|
return None
|
|
362
365
|
if COMMIT_OID.fullmatch(base_oid) is None:
|
|
363
|
-
parser.error(
|
|
366
|
+
parser.error(
|
|
367
|
+
"--expected-base-oid must be a Git commit object identifier that contains "
|
|
368
|
+
"40 lowercase hexadecimal characters."
|
|
369
|
+
)
|
|
364
370
|
if COMMIT_OID.fullmatch(head_oid) is None:
|
|
365
|
-
parser.error(
|
|
371
|
+
parser.error(
|
|
372
|
+
"--expected-head-oid must be a Git commit object identifier that contains "
|
|
373
|
+
"40 lowercase hexadecimal characters."
|
|
374
|
+
)
|
|
366
375
|
return base_oid, head_oid
|
|
367
376
|
|
|
368
377
|
|
|
@@ -374,7 +383,7 @@ def expected_target(
|
|
|
374
383
|
number: int | None,
|
|
375
384
|
url: str | None,
|
|
376
385
|
) -> ExpectedReviewTarget | None:
|
|
377
|
-
"""Require the resolved GitHub target
|
|
386
|
+
"""Require the resolved GitHub target when immutable object identifiers are supplied."""
|
|
378
387
|
if identity is None:
|
|
379
388
|
if (
|
|
380
389
|
host is not None
|
|
@@ -383,21 +392,22 @@ def expected_target(
|
|
|
383
392
|
or url is not None
|
|
384
393
|
):
|
|
385
394
|
parser.error(
|
|
386
|
-
"
|
|
387
|
-
"--expected-
|
|
395
|
+
"If you specify an expected target, also specify --expected-base-oid "
|
|
396
|
+
"and --expected-head-oid."
|
|
388
397
|
)
|
|
389
398
|
return None
|
|
390
399
|
if host is None or repository is None or number is None or url is None:
|
|
391
400
|
parser.error(
|
|
401
|
+
"With --expected-base-oid and --expected-head-oid, specify "
|
|
392
402
|
"--expected-host, --expected-repository, --expected-pr-number, and "
|
|
393
|
-
"--expected-pr-url
|
|
403
|
+
"--expected-pr-url."
|
|
394
404
|
)
|
|
395
405
|
assert host is not None
|
|
396
406
|
assert repository is not None
|
|
397
407
|
assert number is not None
|
|
398
408
|
assert url is not None
|
|
399
409
|
if host != "github.com":
|
|
400
|
-
parser.error("--expected-host must be github.com")
|
|
410
|
+
parser.error("'--expected-host' must be 'github.com'.")
|
|
401
411
|
try:
|
|
402
412
|
canonical_repository = require_github_repository(
|
|
403
413
|
repository, "--expected-repository"
|
|
@@ -405,7 +415,7 @@ def expected_target(
|
|
|
405
415
|
except RuntimeError as error:
|
|
406
416
|
parser.error(str(error))
|
|
407
417
|
if number < 1:
|
|
408
|
-
parser.error("--expected-pr-number must be a positive pull-request number")
|
|
418
|
+
parser.error("--expected-pr-number must be a positive pull-request number.")
|
|
409
419
|
try:
|
|
410
420
|
canonical_url = require_canonical_pull_request_url(
|
|
411
421
|
url, canonical_repository, number, "--expected-pr-url"
|
|
@@ -423,14 +433,15 @@ def expected_target(
|
|
|
423
433
|
def ensure_expected_identity(
|
|
424
434
|
identity: ImmutableIdentity | None, expected: tuple[str, str] | None
|
|
425
435
|
) -> None:
|
|
426
|
-
"""Fail closed when collected revisions differ from the resolved
|
|
436
|
+
"""Fail closed when collected revisions differ from the resolved pull request."""
|
|
427
437
|
if expected is None:
|
|
428
438
|
return
|
|
429
439
|
if identity is None:
|
|
430
|
-
raise RuntimeError("GitHub
|
|
440
|
+
raise RuntimeError("GitHub did not return an immutable pull-request identity.")
|
|
431
441
|
if (identity.base_oid, identity.head_oid) != expected:
|
|
432
442
|
raise RuntimeError(
|
|
433
|
-
"immutable pull-request identity does not match the expected base
|
|
443
|
+
"The immutable pull-request identity does not match the expected base "
|
|
444
|
+
"object identifier and head object identifier."
|
|
434
445
|
)
|
|
435
446
|
|
|
436
447
|
|
|
@@ -441,20 +452,22 @@ def ensure_expected_target(
|
|
|
441
452
|
if target is None:
|
|
442
453
|
return
|
|
443
454
|
if identity is None:
|
|
444
|
-
raise RuntimeError("GitHub
|
|
455
|
+
raise RuntimeError("GitHub did not return an immutable pull-request identity.")
|
|
445
456
|
if (
|
|
446
457
|
identity.repository.casefold() != target.repository.casefold()
|
|
447
458
|
or identity.number != target.number
|
|
448
459
|
or identity.url != target.url
|
|
449
460
|
):
|
|
450
|
-
raise RuntimeError(
|
|
461
|
+
raise RuntimeError(
|
|
462
|
+
"The pull-request identity does not match the expected target."
|
|
463
|
+
)
|
|
451
464
|
|
|
452
465
|
|
|
453
466
|
def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
454
|
-
"""Bind mutable issue
|
|
467
|
+
"""Bind mutable issue fields and scope fields so they cannot change during review."""
|
|
455
468
|
closing_issues = metadata.get("closingIssuesReferences")
|
|
456
469
|
if not isinstance(closing_issues, list):
|
|
457
|
-
raise TypeError("GitHub returned incomplete review-scope fields")
|
|
470
|
+
raise TypeError("GitHub returned incomplete review-scope fields.")
|
|
458
471
|
try:
|
|
459
472
|
canonical_issues = sorted(
|
|
460
473
|
json.dumps(
|
|
@@ -485,7 +498,9 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
|
485
498
|
sort_keys=True,
|
|
486
499
|
)
|
|
487
500
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
488
|
-
raise RuntimeError(
|
|
501
|
+
raise RuntimeError(
|
|
502
|
+
"GitHub returned review-scope fields that are not valid."
|
|
503
|
+
) from error
|
|
489
504
|
return ReviewScope(
|
|
490
505
|
fields=fields,
|
|
491
506
|
sha256=sha256(canonical_scope.encode("utf-8")).hexdigest(),
|
|
@@ -495,7 +510,7 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
|
495
510
|
def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
496
511
|
"""Return one validated canonical linked-issue identity."""
|
|
497
512
|
if not isinstance(issue, dict):
|
|
498
|
-
raise TypeError("GitHub returned
|
|
513
|
+
raise TypeError("GitHub returned a linked issue reference that is not valid.")
|
|
499
514
|
issue_id = issue.get("id")
|
|
500
515
|
repository_data = issue.get("repository")
|
|
501
516
|
number = issue.get("number")
|
|
@@ -507,7 +522,7 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
|
507
522
|
or isinstance(number, bool)
|
|
508
523
|
or not isinstance(number, int)
|
|
509
524
|
):
|
|
510
|
-
raise RuntimeError("GitHub returned an incomplete linked issue reference")
|
|
525
|
+
raise RuntimeError("GitHub returned an incomplete linked issue reference.")
|
|
511
526
|
owner_data = repository_data.get("owner")
|
|
512
527
|
name = repository_data.get("name")
|
|
513
528
|
owner = owner_data.get("login") if isinstance(owner_data, dict) else None
|
|
@@ -519,18 +534,18 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
|
519
534
|
or number < 1
|
|
520
535
|
or not isinstance(url, str)
|
|
521
536
|
):
|
|
522
|
-
raise RuntimeError("GitHub returned an incomplete linked issue reference")
|
|
537
|
+
raise RuntimeError("GitHub returned an incomplete linked issue reference.")
|
|
523
538
|
try:
|
|
524
539
|
repository = require_github_repository(
|
|
525
540
|
f"{owner}/{name}", "GitHub linked issue repository"
|
|
526
541
|
)
|
|
527
542
|
except RuntimeError as error:
|
|
528
543
|
raise RuntimeError(
|
|
529
|
-
"GitHub returned
|
|
544
|
+
"GitHub returned a linked issue repository that is not valid."
|
|
530
545
|
) from error
|
|
531
546
|
canonical_url = f"https://github.com/{repository}/issues/{number}"
|
|
532
547
|
if url != canonical_url:
|
|
533
|
-
raise RuntimeError("GitHub returned
|
|
548
|
+
raise RuntimeError("GitHub returned a linked issue URL that is not valid.")
|
|
534
549
|
return issue_id, repository, number, canonical_url
|
|
535
550
|
|
|
536
551
|
|
|
@@ -545,7 +560,7 @@ def canonical_json(value: object, label: str) -> str:
|
|
|
545
560
|
sort_keys=True,
|
|
546
561
|
)
|
|
547
562
|
except (TypeError, ValueError) as error:
|
|
548
|
-
raise RuntimeError(f"
|
|
563
|
+
raise RuntimeError(f"The data for {label} is not valid.") from error
|
|
549
564
|
|
|
550
565
|
|
|
551
566
|
def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
@@ -571,7 +586,7 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
|
571
586
|
try:
|
|
572
587
|
stream.close()
|
|
573
588
|
except OSError:
|
|
574
|
-
#
|
|
589
|
+
# A different cleanup path can close this pipe first. Ignore the error.
|
|
575
590
|
pass
|
|
576
591
|
capture.completed.set()
|
|
577
592
|
|
|
@@ -579,15 +594,15 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
|
579
594
|
def validate_changed_path(entry: bytes) -> None:
|
|
580
595
|
"""Reject an empty or unsafe Git-relative path entry."""
|
|
581
596
|
if not entry:
|
|
582
|
-
raise RuntimeError("Git returned an empty changed path")
|
|
597
|
+
raise RuntimeError("Git returned an empty changed path.")
|
|
583
598
|
if entry.startswith(b"/") or any(
|
|
584
599
|
component in {b".", b".."} for component in entry.split(b"/")
|
|
585
600
|
):
|
|
586
|
-
raise RuntimeError("Git returned an unsafe changed path")
|
|
601
|
+
raise RuntimeError("Git returned an unsafe changed path.")
|
|
587
602
|
|
|
588
603
|
|
|
589
604
|
def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) -> None:
|
|
590
|
-
"""Incrementally collect one bounded,
|
|
605
|
+
"""Incrementally collect one bounded, null-byte-delimited Git path manifest."""
|
|
591
606
|
try:
|
|
592
607
|
while True:
|
|
593
608
|
read_size = min(
|
|
@@ -595,7 +610,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
595
610
|
)
|
|
596
611
|
if read_size <= 0:
|
|
597
612
|
capture.limit_error = (
|
|
598
|
-
"changed-path manifest exceeds the safe byte limit"
|
|
613
|
+
"The changed-path manifest exceeds the safe byte limit."
|
|
599
614
|
)
|
|
600
615
|
capture.overflowed.set()
|
|
601
616
|
return
|
|
@@ -603,12 +618,13 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
603
618
|
if not chunk:
|
|
604
619
|
if capture.trailing:
|
|
605
620
|
raise RuntimeError(
|
|
606
|
-
"Git returned a
|
|
621
|
+
"Git returned a null-byte-delimited changed-path manifest that "
|
|
622
|
+
"is not valid."
|
|
607
623
|
)
|
|
608
624
|
return
|
|
609
625
|
if capture.bytes_read + len(chunk) > capture.maximum_bytes:
|
|
610
626
|
capture.limit_error = (
|
|
611
|
-
"changed-path manifest exceeds the safe byte limit"
|
|
627
|
+
"The changed-path manifest exceeds the safe byte limit."
|
|
612
628
|
)
|
|
613
629
|
capture.overflowed.set()
|
|
614
630
|
return
|
|
@@ -618,10 +634,10 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
618
634
|
for entry in entries:
|
|
619
635
|
validate_changed_path(entry)
|
|
620
636
|
if entry in capture.seen_paths:
|
|
621
|
-
raise RuntimeError("Git returned duplicate changed paths")
|
|
637
|
+
raise RuntimeError("Git returned duplicate changed paths.")
|
|
622
638
|
if len(capture.paths) >= capture.maximum_paths:
|
|
623
639
|
capture.limit_error = (
|
|
624
|
-
"changed-path manifest exceeds the safe path limit"
|
|
640
|
+
"The changed-path manifest exceeds the safe path limit."
|
|
625
641
|
)
|
|
626
642
|
capture.overflowed.set()
|
|
627
643
|
return
|
|
@@ -633,7 +649,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
633
649
|
try:
|
|
634
650
|
stream.close()
|
|
635
651
|
except (OSError, ValueError):
|
|
636
|
-
# A
|
|
652
|
+
# A different cleanup path can close this pipe first. Ignore the error.
|
|
637
653
|
pass
|
|
638
654
|
capture.completed.set()
|
|
639
655
|
|
|
@@ -650,7 +666,7 @@ def reap_provider(
|
|
|
650
666
|
try:
|
|
651
667
|
stream.close()
|
|
652
668
|
except (OSError, ValueError):
|
|
653
|
-
#
|
|
669
|
+
# A reader can close this pipe first. Ignore the error.
|
|
654
670
|
pass
|
|
655
671
|
process.wait()
|
|
656
672
|
for reader in readers:
|
|
@@ -704,14 +720,18 @@ def bounded_gh_output(
|
|
|
704
720
|
limit_error: str,
|
|
705
721
|
timeout_seconds: float | None = None,
|
|
706
722
|
stderr_maximum_bytes: int | None = None,
|
|
707
|
-
stderr_limit_error: str =
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
723
|
+
stderr_limit_error: str = (
|
|
724
|
+
"The linked issue response exceeds the safe standard-error limit."
|
|
725
|
+
),
|
|
726
|
+
deadline_error: str = "The linked issue provider exceeded the safe deadline.",
|
|
727
|
+
output_error: str = "The tool cannot read linked issue provider output.",
|
|
728
|
+
unavailable_output_error: str = (
|
|
729
|
+
"The GitHub CLI process did not provide linked issue output."
|
|
730
|
+
),
|
|
731
|
+
operating_system_error: str = "The tool cannot collect linked issue comments",
|
|
712
732
|
coverage_gap: type[RuntimeError] = LinkedRequirementsCoverageGap,
|
|
713
733
|
) -> bytes:
|
|
714
|
-
"""Run one deadline-bound GitHub request with bounded
|
|
734
|
+
"""Run one deadline-bound GitHub request with bounded output streams."""
|
|
715
735
|
command = ["gh", *arguments]
|
|
716
736
|
effective_timeout = (
|
|
717
737
|
LINKED_ISSUE_COMMENT_REQUEST_TIMEOUT_SECONDS
|
|
@@ -732,7 +752,8 @@ def bounded_gh_output(
|
|
|
732
752
|
)
|
|
733
753
|
except FileNotFoundError as error:
|
|
734
754
|
raise RuntimeError(
|
|
735
|
-
|
|
755
|
+
"The required command is not available: "
|
|
756
|
+
f"'{error.filename or command[0]}'."
|
|
736
757
|
) from error
|
|
737
758
|
stdout = process.stdout
|
|
738
759
|
stderr = process.stderr
|
|
@@ -777,10 +798,16 @@ def bounded_gh_output(
|
|
|
777
798
|
message = (
|
|
778
799
|
bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
|
|
779
800
|
)
|
|
780
|
-
raise RuntimeError(
|
|
801
|
+
raise RuntimeError(
|
|
802
|
+
message
|
|
803
|
+
or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
|
|
804
|
+
)
|
|
781
805
|
return bytes(stdout_capture.output)
|
|
782
806
|
except OSError as error:
|
|
783
|
-
raise RuntimeError(
|
|
807
|
+
raise RuntimeError(
|
|
808
|
+
f"{operating_system_error}. "
|
|
809
|
+
f"The operation returned this diagnostic.\n{error}"
|
|
810
|
+
) from error
|
|
784
811
|
|
|
785
812
|
|
|
786
813
|
def paginated_issue_comments(
|
|
@@ -795,11 +822,11 @@ def paginated_issue_comments(
|
|
|
795
822
|
aggregate_remaining_bytes = collection_budget.remaining_bytes()
|
|
796
823
|
if remaining_bytes <= 0:
|
|
797
824
|
raise LinkedRequirementsCoverageGap(
|
|
798
|
-
"linked issue comments exceed the safe byte limit"
|
|
825
|
+
"The linked issue comments exceed the safe byte limit."
|
|
799
826
|
)
|
|
800
827
|
if aggregate_remaining_bytes <= 0:
|
|
801
828
|
raise LinkedRequirementsCoverageGap(
|
|
802
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
829
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
803
830
|
)
|
|
804
831
|
collection_budget.reserve_comment_page()
|
|
805
832
|
response = bounded_gh_output(
|
|
@@ -819,7 +846,7 @@ def paginated_issue_comments(
|
|
|
819
846
|
remaining_bytes,
|
|
820
847
|
aggregate_remaining_bytes,
|
|
821
848
|
),
|
|
822
|
-
limit_error="linked issue comments exceed the safe byte limit",
|
|
849
|
+
limit_error="The linked issue comments exceed the safe byte limit.",
|
|
823
850
|
)
|
|
824
851
|
bytes_read += len(response)
|
|
825
852
|
collection_budget.record_bytes(len(response))
|
|
@@ -827,27 +854,33 @@ def paginated_issue_comments(
|
|
|
827
854
|
page_comments = json.loads(response)
|
|
828
855
|
except json.JSONDecodeError as error:
|
|
829
856
|
raise RuntimeError(
|
|
830
|
-
"GitHub returned
|
|
857
|
+
"GitHub returned linked issue comment pages that are not valid."
|
|
831
858
|
) from error
|
|
832
859
|
if not isinstance(page_comments, list):
|
|
833
|
-
raise TypeError(
|
|
860
|
+
raise TypeError(
|
|
861
|
+
"GitHub returned linked issue comment pages that are not valid."
|
|
862
|
+
)
|
|
834
863
|
if not all(isinstance(comment, dict) for comment in page_comments):
|
|
835
|
-
raise RuntimeError(
|
|
864
|
+
raise RuntimeError(
|
|
865
|
+
"GitHub returned a linked issue comment that is not valid."
|
|
866
|
+
)
|
|
836
867
|
if page > MAX_LINKED_ISSUE_COMMENT_PAGES:
|
|
837
868
|
if page_comments:
|
|
838
869
|
raise LinkedRequirementsCoverageGap(
|
|
839
|
-
"linked issue comments exceed the safe page limit"
|
|
870
|
+
"The linked issue comments exceed the safe page limit."
|
|
840
871
|
)
|
|
841
872
|
return comments
|
|
842
873
|
if len(comments) + len(page_comments) > MAX_LINKED_ISSUE_COMMENTS:
|
|
843
874
|
raise LinkedRequirementsCoverageGap(
|
|
844
|
-
"linked issue comments exceed the safe comment limit"
|
|
875
|
+
"The linked issue comments exceed the safe comment limit."
|
|
845
876
|
)
|
|
846
877
|
collection_budget.record_comment_page(len(page_comments))
|
|
847
878
|
comments.extend(page_comments)
|
|
848
879
|
if len(page_comments) < LINKED_ISSUE_COMMENT_PAGE_SIZE:
|
|
849
880
|
return comments
|
|
850
|
-
raise AssertionError(
|
|
881
|
+
raise AssertionError(
|
|
882
|
+
"The bounded linked issue comment pagination did not terminate."
|
|
883
|
+
)
|
|
851
884
|
|
|
852
885
|
|
|
853
886
|
def structured_error(error: str, details: str) -> None:
|
|
@@ -862,7 +895,7 @@ def linked_issue_metadata(
|
|
|
862
895
|
aggregate_remaining_bytes = budget.remaining_bytes()
|
|
863
896
|
if aggregate_remaining_bytes <= 0:
|
|
864
897
|
raise LinkedRequirementsCoverageGap(
|
|
865
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
898
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
866
899
|
)
|
|
867
900
|
budget.reserve_request()
|
|
868
901
|
response = bounded_gh_output(
|
|
@@ -878,15 +911,17 @@ def linked_issue_metadata(
|
|
|
878
911
|
maximum_bytes=min(
|
|
879
912
|
MAX_LINKED_REQUIREMENT_METADATA_BYTES, aggregate_remaining_bytes
|
|
880
913
|
),
|
|
881
|
-
limit_error="linked issue metadata exceeds the safe metadata byte limit",
|
|
914
|
+
limit_error="The linked issue metadata exceeds the safe metadata byte limit.",
|
|
882
915
|
)
|
|
883
916
|
budget.record_bytes(len(response))
|
|
884
917
|
try:
|
|
885
918
|
issue_data = json.loads(response)
|
|
886
919
|
except json.JSONDecodeError as error:
|
|
887
|
-
raise RuntimeError(
|
|
920
|
+
raise RuntimeError(
|
|
921
|
+
"GitHub returned a linked issue that is not valid."
|
|
922
|
+
) from error
|
|
888
923
|
if not isinstance(issue_data, dict):
|
|
889
|
-
raise TypeError("GitHub returned
|
|
924
|
+
raise TypeError("GitHub returned a linked issue that is not valid.")
|
|
890
925
|
return issue_data
|
|
891
926
|
|
|
892
927
|
|
|
@@ -896,10 +931,10 @@ def linked_requirements(
|
|
|
896
931
|
"""Bind every linked issue's requirement content and complete comment history."""
|
|
897
932
|
references = metadata.get("closingIssuesReferences")
|
|
898
933
|
if not isinstance(references, list):
|
|
899
|
-
raise TypeError("GitHub returned incomplete linked issue references")
|
|
934
|
+
raise TypeError("GitHub returned incomplete linked issue references.")
|
|
900
935
|
identities = sorted(linked_issue_reference(issue) for issue in references)
|
|
901
936
|
if len(identities) != len(set(identities)):
|
|
902
|
-
raise RuntimeError("GitHub returned duplicate linked issue references")
|
|
937
|
+
raise RuntimeError("GitHub returned duplicate linked issue references.")
|
|
903
938
|
collection_budget = budget if budget is not None else LinkedRequirementBudget()
|
|
904
939
|
items: list[LinkedRequirement] = []
|
|
905
940
|
for expected_id, repository, number, expected_url in identities:
|
|
@@ -914,7 +949,7 @@ def linked_requirements(
|
|
|
914
949
|
or (body is not None and not isinstance(body, str))
|
|
915
950
|
or not isinstance(issue_data.get("state"), str)
|
|
916
951
|
):
|
|
917
|
-
raise RuntimeError("GitHub returned incomplete linked issue requirements")
|
|
952
|
+
raise RuntimeError("GitHub returned incomplete linked issue requirements.")
|
|
918
953
|
comments = sorted(
|
|
919
954
|
canonical_json(comment, "linked issue comment")
|
|
920
955
|
for comment in paginated_issue_comments(
|
|
@@ -962,14 +997,17 @@ def bounded_git_path_manifest(
|
|
|
962
997
|
)
|
|
963
998
|
except FileNotFoundError as error:
|
|
964
999
|
raise RuntimeError(
|
|
965
|
-
|
|
1000
|
+
"The required command is not available: "
|
|
1001
|
+
f"'{error.filename or command[0]}'."
|
|
966
1002
|
) from error
|
|
967
1003
|
stdout = process.stdout
|
|
968
1004
|
stderr = process.stderr
|
|
969
1005
|
if stdout is None or stderr is None:
|
|
970
1006
|
process.kill()
|
|
971
1007
|
process.wait()
|
|
972
|
-
raise RuntimeError(
|
|
1008
|
+
raise RuntimeError(
|
|
1009
|
+
"The Git process did not provide the required output streams."
|
|
1010
|
+
)
|
|
973
1011
|
stdout_capture = ChangedPathStream(
|
|
974
1012
|
MAX_CHANGED_PATH_MANIFEST_BYTES, MAX_CHANGED_PATHS
|
|
975
1013
|
)
|
|
@@ -993,11 +1031,15 @@ def bounded_git_path_manifest(
|
|
|
993
1031
|
process,
|
|
994
1032
|
stdout_capture,
|
|
995
1033
|
stderr_capture,
|
|
996
|
-
"changed-path manifest exceeds the safe byte limit",
|
|
1034
|
+
"The changed-path manifest exceeds the safe byte limit.",
|
|
997
1035
|
timeout_seconds=CHANGED_PATH_REQUEST_TIMEOUT_SECONDS,
|
|
998
|
-
stderr_limit_error=
|
|
999
|
-
|
|
1000
|
-
|
|
1036
|
+
stderr_limit_error=(
|
|
1037
|
+
"The changed-path response exceeds the safe standard-error limit."
|
|
1038
|
+
),
|
|
1039
|
+
deadline_error=(
|
|
1040
|
+
"The changed-path provider exceeded the safe deadline."
|
|
1041
|
+
),
|
|
1042
|
+
output_error="The tool cannot read immutable changed-path output.",
|
|
1001
1043
|
coverage_gap=ChangedPathCoverageGap,
|
|
1002
1044
|
)
|
|
1003
1045
|
except BaseException:
|
|
@@ -1009,16 +1051,19 @@ def bounded_git_path_manifest(
|
|
|
1009
1051
|
message = (
|
|
1010
1052
|
bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
|
|
1011
1053
|
)
|
|
1012
|
-
raise RuntimeError(
|
|
1054
|
+
raise RuntimeError(
|
|
1055
|
+
message or f"The Git command failed. Command: git {' '.join(arguments)}"
|
|
1056
|
+
)
|
|
1013
1057
|
return stdout_capture.paths
|
|
1014
1058
|
except OSError as error:
|
|
1015
1059
|
raise RuntimeError(
|
|
1016
|
-
|
|
1060
|
+
"The tool cannot collect immutable changed paths. "
|
|
1061
|
+
f"The operation returned this diagnostic.\n{error}"
|
|
1017
1062
|
) from error
|
|
1018
1063
|
|
|
1019
1064
|
|
|
1020
1065
|
def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
|
|
1021
|
-
"""Run a read-only Git query and return its byte-exact
|
|
1066
|
+
"""Run a read-only Git query and return its byte-exact standard output."""
|
|
1022
1067
|
result: Any = run_command(
|
|
1023
1068
|
["git", *git_read_arguments(), *arguments],
|
|
1024
1069
|
capture_output=True,
|
|
@@ -1032,17 +1077,19 @@ def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
|
|
|
1032
1077
|
message = stderr.decode("utf-8", errors="replace").strip()
|
|
1033
1078
|
else:
|
|
1034
1079
|
message = str(stderr).strip()
|
|
1035
|
-
raise RuntimeError(
|
|
1080
|
+
raise RuntimeError(
|
|
1081
|
+
message or f"The Git command failed. Command: git {' '.join(arguments)}"
|
|
1082
|
+
)
|
|
1036
1083
|
stdout = result.stdout
|
|
1037
1084
|
if not isinstance(stdout, bytes):
|
|
1038
|
-
raise TypeError("
|
|
1085
|
+
raise TypeError("Git returned non-byte output for immutable path evidence.")
|
|
1039
1086
|
return stdout
|
|
1040
1087
|
|
|
1041
1088
|
|
|
1042
1089
|
def immutable_range_paths(
|
|
1043
1090
|
base_oid: str, head_oid: str, *, cwd: Path | None = None
|
|
1044
1091
|
) -> list[bytes]:
|
|
1045
|
-
"""Return validated
|
|
1092
|
+
"""Return validated null-byte-safe paths from one immutable Git diff range."""
|
|
1046
1093
|
return bounded_git_path_manifest(
|
|
1047
1094
|
(
|
|
1048
1095
|
"-c",
|
|
@@ -1081,7 +1128,9 @@ def immutable_changed_paths(
|
|
|
1081
1128
|
try:
|
|
1082
1129
|
paths = tuple(entry.decode("utf-8") for entry in canonical_entries)
|
|
1083
1130
|
except UnicodeDecodeError as error:
|
|
1084
|
-
raise RuntimeError(
|
|
1131
|
+
raise RuntimeError(
|
|
1132
|
+
"Git returned a changed path that is not valid 'UTF-8'."
|
|
1133
|
+
) from error
|
|
1085
1134
|
canonical_bytes = b"".join(entry + b"\0" for entry in canonical_entries)
|
|
1086
1135
|
return ChangedPathManifest(paths=paths, sha256=sha256(canonical_bytes).hexdigest())
|
|
1087
1136
|
|
|
@@ -1101,13 +1150,15 @@ def strict_changed_paths(
|
|
|
1101
1150
|
expected: tuple[str, str],
|
|
1102
1151
|
target: ExpectedReviewTarget,
|
|
1103
1152
|
) -> tuple[ChangedPathManifest, MaterializedSnapshot | None]:
|
|
1104
|
-
"""Derive strict paths locally or from a verified host-owned snapshot."""
|
|
1153
|
+
"""Derive strict paths locally or from a verified host-owned materialized snapshot."""
|
|
1105
1154
|
base_oid, head_oid = expected
|
|
1106
1155
|
if local_immutable_objects_available(base_oid, head_oid):
|
|
1107
1156
|
return immutable_changed_paths(base_oid, head_oid), None
|
|
1108
1157
|
base_ref = metadata.get("baseRefName")
|
|
1109
1158
|
if not isinstance(base_ref, str):
|
|
1110
|
-
raise TypeError(
|
|
1159
|
+
raise TypeError(
|
|
1160
|
+
"GitHub returned a pull-request base reference that is not valid."
|
|
1161
|
+
)
|
|
1111
1162
|
snapshot = materialize_snapshot(
|
|
1112
1163
|
repository=target.repository,
|
|
1113
1164
|
number=target.number,
|
|
@@ -1129,7 +1180,10 @@ def gh(*arguments: str, accepted_codes: tuple[int, ...] = (0,)) -> str:
|
|
|
1129
1180
|
["gh", *arguments], capture_output=True, text=True, check=False
|
|
1130
1181
|
)
|
|
1131
1182
|
if result.returncode not in accepted_codes:
|
|
1132
|
-
raise RuntimeError(
|
|
1183
|
+
raise RuntimeError(
|
|
1184
|
+
result.stderr.strip()
|
|
1185
|
+
or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
|
|
1186
|
+
)
|
|
1133
1187
|
return result.stdout
|
|
1134
1188
|
|
|
1135
1189
|
|
|
@@ -1142,12 +1196,12 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1142
1196
|
for page_number in range(1, MAX_CHECK_RUN_PAGES + 2):
|
|
1143
1197
|
if page_number > MAX_CHECK_RUN_PAGES:
|
|
1144
1198
|
raise CheckEvidenceCoverageGap(
|
|
1145
|
-
"GitHub check runs exceed the safe page limit"
|
|
1199
|
+
"GitHub check runs exceed the safe page limit."
|
|
1146
1200
|
)
|
|
1147
1201
|
remaining_bytes = MAX_CHECK_RUN_BYTES - bytes_read
|
|
1148
1202
|
if remaining_bytes <= 0:
|
|
1149
1203
|
raise CheckEvidenceCoverageGap(
|
|
1150
|
-
"GitHub check runs exceed the safe aggregate byte limit"
|
|
1204
|
+
"GitHub check runs exceed the safe aggregate byte limit."
|
|
1151
1205
|
)
|
|
1152
1206
|
try:
|
|
1153
1207
|
response = bounded_gh_output(
|
|
@@ -1165,31 +1219,39 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1165
1219
|
),
|
|
1166
1220
|
),
|
|
1167
1221
|
maximum_bytes=min(MAX_CHECK_RUN_PAGE_BYTES, remaining_bytes),
|
|
1168
|
-
limit_error="GitHub check-run response exceeds the safe byte limit",
|
|
1222
|
+
limit_error="The GitHub check-run response exceeds the safe byte limit.",
|
|
1169
1223
|
timeout_seconds=CHECK_RUN_REQUEST_TIMEOUT_SECONDS,
|
|
1170
1224
|
stderr_maximum_bytes=MAX_CHECK_RUN_STDERR_BYTES,
|
|
1171
|
-
stderr_limit_error=
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1225
|
+
stderr_limit_error=(
|
|
1226
|
+
"The GitHub check-run response exceeds the safe standard-error limit."
|
|
1227
|
+
),
|
|
1228
|
+
deadline_error=(
|
|
1229
|
+
"The GitHub check-run provider exceeded the safe deadline."
|
|
1230
|
+
),
|
|
1231
|
+
output_error="The tool cannot read GitHub check-run provider output.",
|
|
1232
|
+
unavailable_output_error=(
|
|
1233
|
+
"The GitHub CLI process did not provide check-run output."
|
|
1234
|
+
),
|
|
1235
|
+
operating_system_error="The tool cannot collect GitHub check runs",
|
|
1176
1236
|
coverage_gap=CheckEvidenceCoverageGap,
|
|
1177
1237
|
)
|
|
1178
1238
|
except RuntimeError as error:
|
|
1179
1239
|
if isinstance(error, CheckEvidenceCoverageGap):
|
|
1180
1240
|
raise
|
|
1181
1241
|
raise CheckEvidenceCoverageGap(
|
|
1182
|
-
"GitHub did not return readable head
|
|
1242
|
+
"GitHub did not return readable check evidence for the head object identifier."
|
|
1183
1243
|
) from error
|
|
1184
1244
|
bytes_read += len(response)
|
|
1185
1245
|
try:
|
|
1186
1246
|
page = json.loads(response)
|
|
1187
1247
|
except json.JSONDecodeError as error:
|
|
1188
1248
|
raise CheckEvidenceCoverageGap(
|
|
1189
|
-
"GitHub did not return readable head
|
|
1249
|
+
"GitHub did not return readable check evidence for the head object identifier."
|
|
1190
1250
|
) from error
|
|
1191
1251
|
if not isinstance(page, dict):
|
|
1192
|
-
raise CheckEvidenceCoverageGap(
|
|
1252
|
+
raise CheckEvidenceCoverageGap(
|
|
1253
|
+
"GitHub returned a check-run page that is not valid."
|
|
1254
|
+
)
|
|
1193
1255
|
page_total = page.get("total_count")
|
|
1194
1256
|
page_runs = page.get("check_runs")
|
|
1195
1257
|
if (
|
|
@@ -1199,21 +1261,23 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1199
1261
|
or not isinstance(page_runs, list)
|
|
1200
1262
|
):
|
|
1201
1263
|
raise CheckEvidenceCoverageGap(
|
|
1202
|
-
"GitHub returned incomplete check-run evidence"
|
|
1264
|
+
"GitHub returned incomplete check-run evidence."
|
|
1203
1265
|
)
|
|
1204
1266
|
if total_count is None:
|
|
1205
1267
|
total_count = page_total
|
|
1206
1268
|
if total_count > MAX_CHECK_RUNS:
|
|
1207
1269
|
raise CheckEvidenceCoverageGap(
|
|
1208
|
-
"GitHub check runs exceed the safe run limit"
|
|
1270
|
+
"GitHub check runs exceed the safe run limit."
|
|
1209
1271
|
)
|
|
1210
1272
|
elif page_total != total_count:
|
|
1211
1273
|
raise CheckEvidenceCoverageGap(
|
|
1212
|
-
"GitHub returned inconsistent check-run totals"
|
|
1274
|
+
"GitHub returned inconsistent check-run totals."
|
|
1213
1275
|
)
|
|
1214
1276
|
for run in page_runs:
|
|
1215
1277
|
if not isinstance(run, dict):
|
|
1216
|
-
raise CheckEvidenceCoverageGap(
|
|
1278
|
+
raise CheckEvidenceCoverageGap(
|
|
1279
|
+
"GitHub returned a check run that is not valid."
|
|
1280
|
+
)
|
|
1217
1281
|
run_id = run.get("id")
|
|
1218
1282
|
run_head_oid = run.get("head_sha")
|
|
1219
1283
|
if (
|
|
@@ -1229,32 +1293,35 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1229
1293
|
or COMMIT_OID.fullmatch(run_head_oid) is None
|
|
1230
1294
|
):
|
|
1231
1295
|
raise CheckEvidenceCoverageGap(
|
|
1232
|
-
"GitHub returned incomplete check-run evidence"
|
|
1296
|
+
"GitHub returned incomplete check-run evidence."
|
|
1233
1297
|
)
|
|
1234
1298
|
if run_head_oid != head_oid:
|
|
1235
1299
|
raise CheckEvidenceCoverageGap(
|
|
1236
|
-
"GitHub returned a check run bound to a different head
|
|
1300
|
+
"GitHub returned a check run that is bound to a different head "
|
|
1301
|
+
"object identifier."
|
|
1237
1302
|
)
|
|
1238
1303
|
run_ids.add(run_id)
|
|
1239
1304
|
runs.append(run)
|
|
1240
1305
|
if len(runs) == total_count:
|
|
1241
1306
|
return runs
|
|
1242
1307
|
if not page_runs:
|
|
1243
|
-
raise CheckEvidenceCoverageGap(
|
|
1244
|
-
|
|
1308
|
+
raise CheckEvidenceCoverageGap(
|
|
1309
|
+
"GitHub returned partial check-run evidence."
|
|
1310
|
+
)
|
|
1311
|
+
raise AssertionError("The bounded check-run pagination did not terminate.")
|
|
1245
1312
|
|
|
1246
1313
|
|
|
1247
1314
|
def pr_metadata(
|
|
1248
1315
|
pull_request: str, target: ExpectedReviewTarget | None
|
|
1249
1316
|
) -> dict[str, Any]:
|
|
1250
|
-
"""Read one
|
|
1317
|
+
"""Read one pull request through the retained target when strict evidence is required."""
|
|
1251
1318
|
command = ["pr", "view", pull_request]
|
|
1252
1319
|
if target is not None:
|
|
1253
1320
|
command.extend(("--repo", target.repository_argument()))
|
|
1254
1321
|
command.extend(("--json", FIELDS))
|
|
1255
1322
|
metadata = json.loads(gh(*command))
|
|
1256
1323
|
if not isinstance(metadata, dict):
|
|
1257
|
-
raise TypeError("GitHub returned
|
|
1324
|
+
raise TypeError("GitHub returned a pull-request object that is not valid.")
|
|
1258
1325
|
return metadata
|
|
1259
1326
|
|
|
1260
1327
|
|
|
@@ -1263,33 +1330,33 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1263
1330
|
parser.add_argument(
|
|
1264
1331
|
"--expected-base-oid",
|
|
1265
1332
|
metavar="BASE_OID",
|
|
1266
|
-
help="immutable base revision
|
|
1333
|
+
help="Use the immutable base revision from 'resolve_pr.py'.",
|
|
1267
1334
|
)
|
|
1268
1335
|
parser.add_argument(
|
|
1269
1336
|
"--expected-head-oid",
|
|
1270
1337
|
metavar="HEAD_OID",
|
|
1271
|
-
help="immutable head revision
|
|
1338
|
+
help="Use the immutable head revision from 'resolve_pr.py'.",
|
|
1272
1339
|
)
|
|
1273
1340
|
parser.add_argument(
|
|
1274
1341
|
"--expected-host",
|
|
1275
1342
|
metavar="HOST",
|
|
1276
|
-
help="canonical GitHub host
|
|
1343
|
+
help="Use the canonical GitHub host from 'resolve_pr.py'.",
|
|
1277
1344
|
)
|
|
1278
1345
|
parser.add_argument(
|
|
1279
1346
|
"--expected-repository",
|
|
1280
1347
|
metavar="OWNER/REPOSITORY",
|
|
1281
|
-
help="canonical GitHub repository
|
|
1348
|
+
help="Use the canonical GitHub repository from 'resolve_pr.py'.",
|
|
1282
1349
|
)
|
|
1283
1350
|
parser.add_argument(
|
|
1284
1351
|
"--expected-pr-number",
|
|
1285
1352
|
metavar="NUMBER",
|
|
1286
1353
|
type=int,
|
|
1287
|
-
help="canonical pull-request number
|
|
1354
|
+
help="Use the canonical pull-request number from 'resolve_pr.py'.",
|
|
1288
1355
|
)
|
|
1289
1356
|
parser.add_argument(
|
|
1290
1357
|
"--expected-pr-url",
|
|
1291
1358
|
metavar="URL",
|
|
1292
|
-
help="canonical pull-request URL
|
|
1359
|
+
help="Use the canonical pull-request URL from 'resolve_pr.py'.",
|
|
1293
1360
|
)
|
|
1294
1361
|
parser.add_argument("pull_request", metavar="PR_NUMBER_OR_URL")
|
|
1295
1362
|
arguments = parser.parse_args(argv)
|
|
@@ -1312,11 +1379,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1312
1379
|
if target is not None:
|
|
1313
1380
|
if requested != target.number:
|
|
1314
1381
|
raise RuntimeError(
|
|
1315
|
-
"requested pull request does not match the expected target
|
|
1382
|
+
"The requested pull request does not match the expected target "
|
|
1383
|
+
"number."
|
|
1316
1384
|
)
|
|
1317
1385
|
if pull_request.startswith("https://") and pull_request != target.url:
|
|
1318
1386
|
raise RuntimeError(
|
|
1319
|
-
"requested pull request does not match the expected target URL"
|
|
1387
|
+
"The requested pull request does not match the expected target URL."
|
|
1320
1388
|
)
|
|
1321
1389
|
metadata = pr_metadata(pull_request, target)
|
|
1322
1390
|
metadata_problem = metadata_error(
|
|
@@ -1337,15 +1405,18 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1337
1405
|
or not isinstance(number, int)
|
|
1338
1406
|
or not isinstance(url, str)
|
|
1339
1407
|
):
|
|
1340
|
-
raise TypeError(
|
|
1408
|
+
raise TypeError(
|
|
1409
|
+
"GitHub returned an incomplete repository or pull-request identity."
|
|
1410
|
+
)
|
|
1341
1411
|
pull_repository = repository_from_pr_url(url, number)
|
|
1342
1412
|
if pull_repository.casefold() != repository.casefold():
|
|
1343
1413
|
raise RuntimeError(
|
|
1344
|
-
f"
|
|
1414
|
+
f"Pull request '{url}' is not in the current repository '{repository}'."
|
|
1345
1415
|
)
|
|
1346
1416
|
if number != requested:
|
|
1347
1417
|
raise RuntimeError(
|
|
1348
|
-
"GitHub returned a pull request different from the requested
|
|
1418
|
+
"GitHub returned a pull request that is different from the requested "
|
|
1419
|
+
"identifier."
|
|
1349
1420
|
)
|
|
1350
1421
|
identity = immutable_identity(
|
|
1351
1422
|
metadata,
|
|
@@ -1410,7 +1481,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1410
1481
|
)
|
|
1411
1482
|
)
|
|
1412
1483
|
if not isinstance(checks, list):
|
|
1413
|
-
raise RuntimeError("GitHub returned
|
|
1484
|
+
raise RuntimeError("GitHub returned check evidence that is not valid.")
|
|
1414
1485
|
final_metadata = pr_metadata(pull_request, target)
|
|
1415
1486
|
final_problem = metadata_error(
|
|
1416
1487
|
final_metadata, require_immutable_identity=require_immutable_identity
|
|
@@ -1425,13 +1496,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1425
1496
|
)
|
|
1426
1497
|
if final_identity != identity:
|
|
1427
1498
|
raise RuntimeError(
|
|
1428
|
-
"immutable pull-request identity changed
|
|
1499
|
+
"The immutable pull-request identity changed during evidence collection."
|
|
1429
1500
|
)
|
|
1430
1501
|
ensure_expected_identity(final_identity, expected)
|
|
1431
1502
|
ensure_expected_target(final_identity, target)
|
|
1432
1503
|
final_scope = review_scope(final_metadata) if expected is not None else None
|
|
1433
1504
|
if final_scope != reviewed_scope:
|
|
1434
|
-
raise RuntimeError("review scope changed
|
|
1505
|
+
raise RuntimeError("The review scope changed during evidence collection.")
|
|
1435
1506
|
final_linked_requirements = (
|
|
1436
1507
|
linked_requirements(final_metadata, linked_requirement_budget)
|
|
1437
1508
|
if expected is not None
|
|
@@ -1439,7 +1510,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1439
1510
|
)
|
|
1440
1511
|
if final_linked_requirements != reviewed_linked_requirements:
|
|
1441
1512
|
raise RuntimeError(
|
|
1442
|
-
"linked issue requirements changed
|
|
1513
|
+
"The linked issue requirements changed during evidence collection."
|
|
1443
1514
|
)
|
|
1444
1515
|
except ChangedPathCoverageGap as error:
|
|
1445
1516
|
structured_error("changed path coverage gap", str(error))
|