@homericintelligence/athena-opencode 0.5.0 → 0.5.2
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 +33 -22
- package/skills/_plugin.json +1 -0
- package/skills/_support/docs/dependency-resolution.md +203 -0
- package/skills/_support/docs/policies/development.md +131 -0
- package/skills/_support/docs/policies/evidence-integrity.md +40 -0
- package/skills/_support/docs/principles/README.md +565 -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 +131 -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 +63 -0
- package/skills/_support/docs/review/behavior-first-testing.md +78 -0
- package/skills/_support/docs/review/common.md +400 -0
- package/skills/_support/docs/review/design-docs.md +74 -0
- package/skills/_support/docs/review/issue-planning.md +247 -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 +108 -74
- package/skills/advise/scripts/list_retrievable_skills.py +20 -8
- package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
- package/skills/brainstorm/SKILL.md +163 -127
- package/skills/change-review/SKILL.md +91 -71
- package/skills/change-review/references/scope-resolution.md +69 -46
- package/skills/change-review/scripts/resolve_scope.py +144 -84
- package/skills/finalize-plan/SKILL.md +205 -139
- package/skills/git-worktrees/SKILL.md +100 -80
- package/skills/git-worktrees/scripts/prepare_worktree.py +34 -15
- package/skills/issue-review/SKILL.md +136 -79
- package/skills/learn/SKILL.md +359 -192
- package/skills/myrmidon-swarm/SKILL.md +126 -86
- package/skills/plan-issue/SKILL.md +122 -81
- package/skills/pr-review/SKILL.md +184 -116
- package/skills/pr-review/references/criteria.md +61 -37
- package/skills/pr-review/references/delivery.md +277 -101
- package/skills/pr-review/references/evidence.md +346 -149
- package/skills/pr-review/references/prevalidated.md +93 -61
- package/skills/pr-review/scripts/collect_evidence.py +312 -162
- package/skills/pr-review/scripts/deliver_go.py +701 -0
- package/skills/pr-review/scripts/diff_context.py +34 -15
- package/skills/pr-review/scripts/materialize_snapshot.py +141 -130
- package/skills/pr-review/scripts/pr_identity.py +27 -15
- package/skills/pr-review/scripts/resolve_pr.py +77 -41
- package/skills/realign/SKILL.md +516 -0
- package/skills/realign/references/aislop-integration.md +215 -0
- package/skills/realign/references/architecture-and-structure.md +271 -0
- package/skills/realign/references/control-flow-and-errors.md +344 -0
- package/skills/realign/references/tests-dependencies-and-security.md +261 -0
- package/skills/realign/scripts/resolve_assessment.py +1525 -0
- package/skills/repo-review/SKILL.md +202 -131
- package/skills/simplify/SKILL.md +174 -0
- package/skills/systematic-debugging/SKILL.md +212 -192
- package/skills/systematic-debugging/scripts/repository_evidence.py +26 -8
- package/skills/test-driven-development/SKILL.md +126 -96
- package/skills/tidy/SKILL.md +104 -59
- package/skills/tidy/scripts/run_tidy.py +54 -6
|
@@ -1,9 +1,11 @@
|
|
|
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
|
|
|
6
|
+
import importlib.util
|
|
6
7
|
import json
|
|
8
|
+
import re
|
|
7
9
|
import subprocess
|
|
8
10
|
import sys
|
|
9
11
|
import threading
|
|
@@ -12,10 +14,7 @@ from collections.abc import Sequence
|
|
|
12
14
|
from dataclasses import dataclass, field
|
|
13
15
|
from hashlib import sha256
|
|
14
16
|
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]))
|
|
17
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
19
18
|
|
|
20
19
|
from materialize_snapshot import (
|
|
21
20
|
MaterializedSnapshot,
|
|
@@ -32,21 +31,40 @@ from pr_identity import (
|
|
|
32
31
|
validate_pr_identifier,
|
|
33
32
|
)
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
if TYPE_CHECKING or __package__ not in {None, ""}:
|
|
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
|
+
else:
|
|
44
|
+
_cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
|
|
45
|
+
_cli_spec = importlib.util.spec_from_file_location(
|
|
46
|
+
"athena_installed_cli", _cli_path
|
|
47
|
+
)
|
|
48
|
+
if _cli_spec is None or _cli_spec.loader is None:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
|
|
51
|
+
)
|
|
52
|
+
_cli = importlib.util.module_from_spec(_cli_spec)
|
|
53
|
+
_cli_spec.loader.exec_module(_cli)
|
|
54
|
+
argument_parser = _cli.argument_parser
|
|
55
|
+
git_read_arguments = _cli.git_read_arguments
|
|
56
|
+
git_read_environment = _cli.git_read_environment
|
|
57
|
+
require_complete_git_history = _cli.require_complete_git_history
|
|
58
|
+
require_unambiguous_git_merge_base = _cli.require_unambiguous_git_merge_base
|
|
59
|
+
run_command = _cli.run_command
|
|
43
60
|
|
|
44
61
|
# 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
|
|
62
|
+
# changed paths to local immutable Git objects. Earlier callers use the REST
|
|
63
|
+
# file-list fallback only for backward compatibility.
|
|
47
64
|
FIELDS = (
|
|
48
65
|
"number,title,body,state,isDraft,author,baseRefName,headRefName,"
|
|
49
|
-
"baseRefOid,headRefOid,reviews,statusCheckRollup,
|
|
66
|
+
"baseRefOid,headRefOid,reviewDecision,reviews,statusCheckRollup,"
|
|
67
|
+
"closingIssuesReferences,url"
|
|
50
68
|
)
|
|
51
69
|
ISSUE_FIELDS = "id,number,url,title,body,state"
|
|
52
70
|
READ_CHUNK_SIZE = 64 * 1024
|
|
@@ -68,7 +86,6 @@ MAX_CHANGED_PATH_MANIFEST_BYTES = 2 * 1024 * 1024
|
|
|
68
86
|
MAX_CHANGED_PATHS = 10_000
|
|
69
87
|
MAX_CHANGED_PATH_STDERR_BYTES = 16 * 1024
|
|
70
88
|
CHANGED_PATH_REQUEST_TIMEOUT_SECONDS = 30.0
|
|
71
|
-
MAX_CHECK_RUN_PAGE_BYTES = 256 * 1024
|
|
72
89
|
MAX_CHECK_RUN_BYTES = 2 * 1024 * 1024
|
|
73
90
|
MAX_CHECK_RUN_PAGES = 100
|
|
74
91
|
MAX_CHECK_RUNS = 10_000
|
|
@@ -90,7 +107,7 @@ class CheckEvidenceCoverageGap(RuntimeError):
|
|
|
90
107
|
|
|
91
108
|
@dataclass
|
|
92
109
|
class LinkedRequirementBudget:
|
|
93
|
-
"""
|
|
110
|
+
"""This budget controls both strict evidence reads from the provider."""
|
|
94
111
|
|
|
95
112
|
pages: int = 0
|
|
96
113
|
comments: int = 0
|
|
@@ -105,7 +122,7 @@ class LinkedRequirementBudget:
|
|
|
105
122
|
"""Reserve one bounded provider request before issuing it."""
|
|
106
123
|
if self.requests >= MAX_LINKED_REQUIREMENT_REQUESTS:
|
|
107
124
|
raise LinkedRequirementsCoverageGap(
|
|
108
|
-
"linked issue requirements exceed the safe aggregate request limit"
|
|
125
|
+
"The linked issue requirements exceed the safe aggregate request limit."
|
|
109
126
|
)
|
|
110
127
|
self.requests += 1
|
|
111
128
|
|
|
@@ -113,7 +130,7 @@ class LinkedRequirementBudget:
|
|
|
113
130
|
"""Reserve one aggregate comment page and its provider request."""
|
|
114
131
|
if self.pages >= MAX_LINKED_REQUIREMENT_PAGES:
|
|
115
132
|
raise LinkedRequirementsCoverageGap(
|
|
116
|
-
"linked issue requirements exceed the safe aggregate page limit"
|
|
133
|
+
"The linked issue requirements exceed the safe aggregate page limit."
|
|
117
134
|
)
|
|
118
135
|
self.reserve_request()
|
|
119
136
|
|
|
@@ -121,7 +138,7 @@ class LinkedRequirementBudget:
|
|
|
121
138
|
"""Account for one provider response without crossing the byte budget."""
|
|
122
139
|
if count > self.remaining_bytes():
|
|
123
140
|
raise LinkedRequirementsCoverageGap(
|
|
124
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
141
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
125
142
|
)
|
|
126
143
|
self.bytes_read += count
|
|
127
144
|
|
|
@@ -129,7 +146,7 @@ class LinkedRequirementBudget:
|
|
|
129
146
|
"""Account for one successful provider page and its item count."""
|
|
130
147
|
if self.comments + count > MAX_LINKED_REQUIREMENT_COMMENTS:
|
|
131
148
|
raise LinkedRequirementsCoverageGap(
|
|
132
|
-
"linked issue requirements exceed the safe aggregate comment limit"
|
|
149
|
+
"The linked issue requirements exceed the safe aggregate comment limit."
|
|
133
150
|
)
|
|
134
151
|
self.pages += 1
|
|
135
152
|
self.comments += count
|
|
@@ -137,7 +154,7 @@ class LinkedRequirementBudget:
|
|
|
137
154
|
|
|
138
155
|
@dataclass
|
|
139
156
|
class ProviderStream:
|
|
140
|
-
"""
|
|
157
|
+
"""This record contains one bounded asynchronous output stream."""
|
|
141
158
|
|
|
142
159
|
maximum_bytes: int
|
|
143
160
|
output: bytearray = field(default_factory=bytearray)
|
|
@@ -148,7 +165,7 @@ class ProviderStream:
|
|
|
148
165
|
|
|
149
166
|
@dataclass
|
|
150
167
|
class ChangedPathStream:
|
|
151
|
-
"""Incrementally validate a bounded
|
|
168
|
+
"""Incrementally validate a bounded null-byte-delimited Git path manifest."""
|
|
152
169
|
|
|
153
170
|
maximum_bytes: int
|
|
154
171
|
maximum_paths: int
|
|
@@ -164,7 +181,7 @@ class ChangedPathStream:
|
|
|
164
181
|
|
|
165
182
|
@dataclass(frozen=True)
|
|
166
183
|
class ImmutableIdentity:
|
|
167
|
-
"""
|
|
184
|
+
"""This record binds evidence to a review artifact and its exact revisions."""
|
|
168
185
|
|
|
169
186
|
repository: str
|
|
170
187
|
number: int
|
|
@@ -187,7 +204,7 @@ class ImmutableIdentity:
|
|
|
187
204
|
|
|
188
205
|
@dataclass(frozen=True)
|
|
189
206
|
class ExpectedReviewTarget:
|
|
190
|
-
"""
|
|
207
|
+
"""This record identifies the immutable GitHub target for strict collection."""
|
|
191
208
|
|
|
192
209
|
host: str
|
|
193
210
|
repository: str
|
|
@@ -201,7 +218,7 @@ class ExpectedReviewTarget:
|
|
|
201
218
|
|
|
202
219
|
@dataclass(frozen=True)
|
|
203
220
|
class ChangedPathManifest:
|
|
204
|
-
"""
|
|
221
|
+
"""This record describes a canonical changed-path manifest from Git objects."""
|
|
205
222
|
|
|
206
223
|
paths: tuple[str, ...]
|
|
207
224
|
sha256: str
|
|
@@ -217,7 +234,7 @@ class ChangedPathManifest:
|
|
|
217
234
|
|
|
218
235
|
@dataclass(frozen=True)
|
|
219
236
|
class ReviewScope:
|
|
220
|
-
"""
|
|
237
|
+
"""This record binds mutable review-context fields to an evidence collection."""
|
|
221
238
|
|
|
222
239
|
fields: dict[str, Any]
|
|
223
240
|
sha256: str
|
|
@@ -229,7 +246,7 @@ class ReviewScope:
|
|
|
229
246
|
|
|
230
247
|
@dataclass(frozen=True)
|
|
231
248
|
class LinkedRequirements:
|
|
232
|
-
"""
|
|
249
|
+
"""This record binds the canonical content of each linked issue used as requirements."""
|
|
233
250
|
|
|
234
251
|
items: tuple[LinkedRequirement, ...]
|
|
235
252
|
sha256: str
|
|
@@ -245,7 +262,7 @@ class LinkedRequirements:
|
|
|
245
262
|
|
|
246
263
|
@dataclass(frozen=True)
|
|
247
264
|
class LinkedRequirement:
|
|
248
|
-
"""
|
|
265
|
+
"""This record contains one linked issue identity and its requirements digest."""
|
|
249
266
|
|
|
250
267
|
id: str
|
|
251
268
|
repository: str
|
|
@@ -265,9 +282,9 @@ class LinkedRequirement:
|
|
|
265
282
|
|
|
266
283
|
|
|
267
284
|
def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str | None:
|
|
268
|
-
"""Return a diagnostic when GitHub returns partial
|
|
285
|
+
"""Return a diagnostic when GitHub returns partial pull-request metadata."""
|
|
269
286
|
if not isinstance(metadata, dict):
|
|
270
|
-
return "
|
|
287
|
+
return "The pull-request metadata must be a JSON object."
|
|
271
288
|
required_types = {
|
|
272
289
|
"number": int,
|
|
273
290
|
"title": str,
|
|
@@ -287,11 +304,13 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
287
304
|
if not isinstance(author, dict) or not isinstance(author.get("login"), str):
|
|
288
305
|
invalid.append("author.login")
|
|
289
306
|
if invalid:
|
|
290
|
-
return
|
|
291
|
-
|
|
307
|
+
return (
|
|
308
|
+
"GitHub returned pull-request metadata fields that are incomplete or not valid: "
|
|
309
|
+
+ ", ".join(f"'{field}'" for field in sorted(set(invalid)))
|
|
310
|
+
+ "."
|
|
292
311
|
)
|
|
293
312
|
if metadata["state"] != "OPEN":
|
|
294
|
-
return f"
|
|
313
|
+
return f"Pull request {metadata['number']} is not open."
|
|
295
314
|
identity_fields = ("baseRefOid", "headRefOid")
|
|
296
315
|
identity_values = [metadata.get(field) for field in identity_fields]
|
|
297
316
|
has_identity = any(value is not None for value in identity_values)
|
|
@@ -303,8 +322,10 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
303
322
|
]
|
|
304
323
|
if invalid_identity:
|
|
305
324
|
return (
|
|
306
|
-
"GitHub returned
|
|
307
|
-
|
|
325
|
+
"GitHub returned immutable pull-request identity fields that are "
|
|
326
|
+
"incomplete or not valid: "
|
|
327
|
+
+ ", ".join(f"'{field}'" for field in invalid_identity)
|
|
328
|
+
+ "."
|
|
308
329
|
)
|
|
309
330
|
if require_immutable_identity:
|
|
310
331
|
body = metadata.get("body")
|
|
@@ -320,8 +341,9 @@ def metadata_error(metadata: object, *, require_immutable_identity: bool) -> str
|
|
|
320
341
|
scope_invalid.append("closingIssuesReferences")
|
|
321
342
|
if scope_invalid:
|
|
322
343
|
return (
|
|
323
|
-
"GitHub returned incomplete or
|
|
324
|
-
+ ", ".join(scope_invalid)
|
|
344
|
+
"GitHub returned review-scope fields that are incomplete or not valid: "
|
|
345
|
+
+ ", ".join(f"'{field}'" for field in scope_invalid)
|
|
346
|
+
+ "."
|
|
325
347
|
)
|
|
326
348
|
return None
|
|
327
349
|
|
|
@@ -335,11 +357,11 @@ def immutable_identity(
|
|
|
335
357
|
if base_oid is None and head_oid is None and not require_immutable_identity:
|
|
336
358
|
return None
|
|
337
359
|
if not isinstance(base_oid, str) or not isinstance(head_oid, str):
|
|
338
|
-
raise TypeError("GitHub returned incomplete immutable pull-request identity")
|
|
360
|
+
raise TypeError("GitHub returned incomplete immutable pull-request identity.")
|
|
339
361
|
number = metadata.get("number")
|
|
340
362
|
url = metadata.get("url")
|
|
341
363
|
if not isinstance(number, int) or not isinstance(url, str):
|
|
342
|
-
raise TypeError("GitHub returned incomplete pull-request identity")
|
|
364
|
+
raise TypeError("GitHub returned incomplete pull-request identity.")
|
|
343
365
|
return ImmutableIdentity(
|
|
344
366
|
repository=repository,
|
|
345
367
|
number=number,
|
|
@@ -354,15 +376,19 @@ def expected_identity(
|
|
|
354
376
|
) -> tuple[str, str] | None:
|
|
355
377
|
"""Validate the optional immutable identity supplied by resolve_pr.py."""
|
|
356
378
|
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
|
-
)
|
|
379
|
+
parser.error("Specify --expected-base-oid and --expected-head-oid together.")
|
|
360
380
|
if base_oid is None or head_oid is None:
|
|
361
381
|
return None
|
|
362
382
|
if COMMIT_OID.fullmatch(base_oid) is None:
|
|
363
|
-
parser.error(
|
|
383
|
+
parser.error(
|
|
384
|
+
"--expected-base-oid must be a Git commit object identifier that contains "
|
|
385
|
+
"40 lowercase hexadecimal characters."
|
|
386
|
+
)
|
|
364
387
|
if COMMIT_OID.fullmatch(head_oid) is None:
|
|
365
|
-
parser.error(
|
|
388
|
+
parser.error(
|
|
389
|
+
"--expected-head-oid must be a Git commit object identifier that contains "
|
|
390
|
+
"40 lowercase hexadecimal characters."
|
|
391
|
+
)
|
|
366
392
|
return base_oid, head_oid
|
|
367
393
|
|
|
368
394
|
|
|
@@ -374,7 +400,7 @@ def expected_target(
|
|
|
374
400
|
number: int | None,
|
|
375
401
|
url: str | None,
|
|
376
402
|
) -> ExpectedReviewTarget | None:
|
|
377
|
-
"""Require the resolved GitHub target
|
|
403
|
+
"""Require the resolved GitHub target when immutable object identifiers are supplied."""
|
|
378
404
|
if identity is None:
|
|
379
405
|
if (
|
|
380
406
|
host is not None
|
|
@@ -383,21 +409,22 @@ def expected_target(
|
|
|
383
409
|
or url is not None
|
|
384
410
|
):
|
|
385
411
|
parser.error(
|
|
386
|
-
"
|
|
387
|
-
"--expected-
|
|
412
|
+
"If you specify an expected target, also specify --expected-base-oid "
|
|
413
|
+
"and --expected-head-oid."
|
|
388
414
|
)
|
|
389
415
|
return None
|
|
390
416
|
if host is None or repository is None or number is None or url is None:
|
|
391
417
|
parser.error(
|
|
418
|
+
"With --expected-base-oid and --expected-head-oid, specify "
|
|
392
419
|
"--expected-host, --expected-repository, --expected-pr-number, and "
|
|
393
|
-
"--expected-pr-url
|
|
420
|
+
"--expected-pr-url."
|
|
394
421
|
)
|
|
395
422
|
assert host is not None
|
|
396
423
|
assert repository is not None
|
|
397
424
|
assert number is not None
|
|
398
425
|
assert url is not None
|
|
399
426
|
if host != "github.com":
|
|
400
|
-
parser.error("--expected-host must be github.com")
|
|
427
|
+
parser.error("'--expected-host' must be 'github.com'.")
|
|
401
428
|
try:
|
|
402
429
|
canonical_repository = require_github_repository(
|
|
403
430
|
repository, "--expected-repository"
|
|
@@ -405,7 +432,7 @@ def expected_target(
|
|
|
405
432
|
except RuntimeError as error:
|
|
406
433
|
parser.error(str(error))
|
|
407
434
|
if number < 1:
|
|
408
|
-
parser.error("--expected-pr-number must be a positive pull-request number")
|
|
435
|
+
parser.error("--expected-pr-number must be a positive pull-request number.")
|
|
409
436
|
try:
|
|
410
437
|
canonical_url = require_canonical_pull_request_url(
|
|
411
438
|
url, canonical_repository, number, "--expected-pr-url"
|
|
@@ -423,14 +450,15 @@ def expected_target(
|
|
|
423
450
|
def ensure_expected_identity(
|
|
424
451
|
identity: ImmutableIdentity | None, expected: tuple[str, str] | None
|
|
425
452
|
) -> None:
|
|
426
|
-
"""Fail closed when collected revisions differ from the resolved
|
|
453
|
+
"""Fail closed when collected revisions differ from the resolved pull request."""
|
|
427
454
|
if expected is None:
|
|
428
455
|
return
|
|
429
456
|
if identity is None:
|
|
430
|
-
raise RuntimeError("GitHub
|
|
457
|
+
raise RuntimeError("GitHub did not return an immutable pull-request identity.")
|
|
431
458
|
if (identity.base_oid, identity.head_oid) != expected:
|
|
432
459
|
raise RuntimeError(
|
|
433
|
-
"immutable pull-request identity does not match the expected base
|
|
460
|
+
"The immutable pull-request identity does not match the expected base "
|
|
461
|
+
"object identifier and head object identifier."
|
|
434
462
|
)
|
|
435
463
|
|
|
436
464
|
|
|
@@ -441,20 +469,22 @@ def ensure_expected_target(
|
|
|
441
469
|
if target is None:
|
|
442
470
|
return
|
|
443
471
|
if identity is None:
|
|
444
|
-
raise RuntimeError("GitHub
|
|
472
|
+
raise RuntimeError("GitHub did not return an immutable pull-request identity.")
|
|
445
473
|
if (
|
|
446
474
|
identity.repository.casefold() != target.repository.casefold()
|
|
447
475
|
or identity.number != target.number
|
|
448
476
|
or identity.url != target.url
|
|
449
477
|
):
|
|
450
|
-
raise RuntimeError(
|
|
478
|
+
raise RuntimeError(
|
|
479
|
+
"The pull-request identity does not match the expected target."
|
|
480
|
+
)
|
|
451
481
|
|
|
452
482
|
|
|
453
483
|
def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
454
|
-
"""Bind mutable issue
|
|
484
|
+
"""Bind mutable issue fields and scope fields so they cannot change during review."""
|
|
455
485
|
closing_issues = metadata.get("closingIssuesReferences")
|
|
456
486
|
if not isinstance(closing_issues, list):
|
|
457
|
-
raise TypeError("GitHub returned incomplete review-scope fields")
|
|
487
|
+
raise TypeError("GitHub returned incomplete review-scope fields.")
|
|
458
488
|
try:
|
|
459
489
|
canonical_issues = sorted(
|
|
460
490
|
json.dumps(
|
|
@@ -485,7 +515,9 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
|
485
515
|
sort_keys=True,
|
|
486
516
|
)
|
|
487
517
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
488
|
-
raise RuntimeError(
|
|
518
|
+
raise RuntimeError(
|
|
519
|
+
"GitHub returned review-scope fields that are not valid."
|
|
520
|
+
) from error
|
|
489
521
|
return ReviewScope(
|
|
490
522
|
fields=fields,
|
|
491
523
|
sha256=sha256(canonical_scope.encode("utf-8")).hexdigest(),
|
|
@@ -495,7 +527,7 @@ def review_scope(metadata: dict[str, Any]) -> ReviewScope:
|
|
|
495
527
|
def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
496
528
|
"""Return one validated canonical linked-issue identity."""
|
|
497
529
|
if not isinstance(issue, dict):
|
|
498
|
-
raise TypeError("GitHub returned
|
|
530
|
+
raise TypeError("GitHub returned a linked issue reference that is not valid.")
|
|
499
531
|
issue_id = issue.get("id")
|
|
500
532
|
repository_data = issue.get("repository")
|
|
501
533
|
number = issue.get("number")
|
|
@@ -507,7 +539,7 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
|
507
539
|
or isinstance(number, bool)
|
|
508
540
|
or not isinstance(number, int)
|
|
509
541
|
):
|
|
510
|
-
raise RuntimeError("GitHub returned an incomplete linked issue reference")
|
|
542
|
+
raise RuntimeError("GitHub returned an incomplete linked issue reference.")
|
|
511
543
|
owner_data = repository_data.get("owner")
|
|
512
544
|
name = repository_data.get("name")
|
|
513
545
|
owner = owner_data.get("login") if isinstance(owner_data, dict) else None
|
|
@@ -519,18 +551,18 @@ def linked_issue_reference(issue: object) -> tuple[str, str, int, str]:
|
|
|
519
551
|
or number < 1
|
|
520
552
|
or not isinstance(url, str)
|
|
521
553
|
):
|
|
522
|
-
raise RuntimeError("GitHub returned an incomplete linked issue reference")
|
|
554
|
+
raise RuntimeError("GitHub returned an incomplete linked issue reference.")
|
|
523
555
|
try:
|
|
524
556
|
repository = require_github_repository(
|
|
525
557
|
f"{owner}/{name}", "GitHub linked issue repository"
|
|
526
558
|
)
|
|
527
559
|
except RuntimeError as error:
|
|
528
560
|
raise RuntimeError(
|
|
529
|
-
"GitHub returned
|
|
561
|
+
"GitHub returned a linked issue repository that is not valid."
|
|
530
562
|
) from error
|
|
531
563
|
canonical_url = f"https://github.com/{repository}/issues/{number}"
|
|
532
564
|
if url != canonical_url:
|
|
533
|
-
raise RuntimeError("GitHub returned
|
|
565
|
+
raise RuntimeError("GitHub returned a linked issue URL that is not valid.")
|
|
534
566
|
return issue_id, repository, number, canonical_url
|
|
535
567
|
|
|
536
568
|
|
|
@@ -545,7 +577,7 @@ def canonical_json(value: object, label: str) -> str:
|
|
|
545
577
|
sort_keys=True,
|
|
546
578
|
)
|
|
547
579
|
except (TypeError, ValueError) as error:
|
|
548
|
-
raise RuntimeError(f"
|
|
580
|
+
raise RuntimeError(f"The data for {label} is not valid.") from error
|
|
549
581
|
|
|
550
582
|
|
|
551
583
|
def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
@@ -571,7 +603,7 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
|
571
603
|
try:
|
|
572
604
|
stream.close()
|
|
573
605
|
except OSError:
|
|
574
|
-
#
|
|
606
|
+
# A different cleanup path can close this pipe first. Ignore the error.
|
|
575
607
|
pass
|
|
576
608
|
capture.completed.set()
|
|
577
609
|
|
|
@@ -579,15 +611,15 @@ def drain_provider_stream(stream: IO[bytes], capture: ProviderStream) -> None:
|
|
|
579
611
|
def validate_changed_path(entry: bytes) -> None:
|
|
580
612
|
"""Reject an empty or unsafe Git-relative path entry."""
|
|
581
613
|
if not entry:
|
|
582
|
-
raise RuntimeError("Git returned an empty changed path")
|
|
614
|
+
raise RuntimeError("Git returned an empty changed path.")
|
|
583
615
|
if entry.startswith(b"/") or any(
|
|
584
616
|
component in {b".", b".."} for component in entry.split(b"/")
|
|
585
617
|
):
|
|
586
|
-
raise RuntimeError("Git returned an unsafe changed path")
|
|
618
|
+
raise RuntimeError("Git returned an unsafe changed path.")
|
|
587
619
|
|
|
588
620
|
|
|
589
621
|
def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) -> None:
|
|
590
|
-
"""Incrementally collect one bounded,
|
|
622
|
+
"""Incrementally collect one bounded, null-byte-delimited Git path manifest."""
|
|
591
623
|
try:
|
|
592
624
|
while True:
|
|
593
625
|
read_size = min(
|
|
@@ -595,7 +627,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
595
627
|
)
|
|
596
628
|
if read_size <= 0:
|
|
597
629
|
capture.limit_error = (
|
|
598
|
-
"changed-path manifest exceeds the safe byte limit"
|
|
630
|
+
"The changed-path manifest exceeds the safe byte limit."
|
|
599
631
|
)
|
|
600
632
|
capture.overflowed.set()
|
|
601
633
|
return
|
|
@@ -603,12 +635,13 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
603
635
|
if not chunk:
|
|
604
636
|
if capture.trailing:
|
|
605
637
|
raise RuntimeError(
|
|
606
|
-
"Git returned a
|
|
638
|
+
"Git returned a null-byte-delimited changed-path manifest that "
|
|
639
|
+
"is not valid."
|
|
607
640
|
)
|
|
608
641
|
return
|
|
609
642
|
if capture.bytes_read + len(chunk) > capture.maximum_bytes:
|
|
610
643
|
capture.limit_error = (
|
|
611
|
-
"changed-path manifest exceeds the safe byte limit"
|
|
644
|
+
"The changed-path manifest exceeds the safe byte limit."
|
|
612
645
|
)
|
|
613
646
|
capture.overflowed.set()
|
|
614
647
|
return
|
|
@@ -618,10 +651,10 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
618
651
|
for entry in entries:
|
|
619
652
|
validate_changed_path(entry)
|
|
620
653
|
if entry in capture.seen_paths:
|
|
621
|
-
raise RuntimeError("Git returned duplicate changed paths")
|
|
654
|
+
raise RuntimeError("Git returned duplicate changed paths.")
|
|
622
655
|
if len(capture.paths) >= capture.maximum_paths:
|
|
623
656
|
capture.limit_error = (
|
|
624
|
-
"changed-path manifest exceeds the safe path limit"
|
|
657
|
+
"The changed-path manifest exceeds the safe path limit."
|
|
625
658
|
)
|
|
626
659
|
capture.overflowed.set()
|
|
627
660
|
return
|
|
@@ -633,7 +666,7 @@ def drain_changed_path_stream(stream: IO[bytes], capture: ChangedPathStream) ->
|
|
|
633
666
|
try:
|
|
634
667
|
stream.close()
|
|
635
668
|
except (OSError, ValueError):
|
|
636
|
-
# A
|
|
669
|
+
# A different cleanup path can close this pipe first. Ignore the error.
|
|
637
670
|
pass
|
|
638
671
|
capture.completed.set()
|
|
639
672
|
|
|
@@ -650,7 +683,7 @@ def reap_provider(
|
|
|
650
683
|
try:
|
|
651
684
|
stream.close()
|
|
652
685
|
except (OSError, ValueError):
|
|
653
|
-
#
|
|
686
|
+
# A reader can close this pipe first. Ignore the error.
|
|
654
687
|
pass
|
|
655
688
|
process.wait()
|
|
656
689
|
for reader in readers:
|
|
@@ -704,14 +737,18 @@ def bounded_gh_output(
|
|
|
704
737
|
limit_error: str,
|
|
705
738
|
timeout_seconds: float | None = None,
|
|
706
739
|
stderr_maximum_bytes: int | None = None,
|
|
707
|
-
stderr_limit_error: str =
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
740
|
+
stderr_limit_error: str = (
|
|
741
|
+
"The linked issue response exceeds the safe standard-error limit."
|
|
742
|
+
),
|
|
743
|
+
deadline_error: str = "The linked issue provider exceeded the safe deadline.",
|
|
744
|
+
output_error: str = "The tool cannot read linked issue provider output.",
|
|
745
|
+
unavailable_output_error: str = (
|
|
746
|
+
"The GitHub CLI process did not provide linked issue output."
|
|
747
|
+
),
|
|
748
|
+
operating_system_error: str = "The tool cannot collect linked issue comments",
|
|
712
749
|
coverage_gap: type[RuntimeError] = LinkedRequirementsCoverageGap,
|
|
713
750
|
) -> bytes:
|
|
714
|
-
"""Run one deadline-bound GitHub request with bounded
|
|
751
|
+
"""Run one deadline-bound GitHub request with bounded output streams."""
|
|
715
752
|
command = ["gh", *arguments]
|
|
716
753
|
effective_timeout = (
|
|
717
754
|
LINKED_ISSUE_COMMENT_REQUEST_TIMEOUT_SECONDS
|
|
@@ -732,7 +769,8 @@ def bounded_gh_output(
|
|
|
732
769
|
)
|
|
733
770
|
except FileNotFoundError as error:
|
|
734
771
|
raise RuntimeError(
|
|
735
|
-
|
|
772
|
+
"The required command is not available: "
|
|
773
|
+
f"'{error.filename or command[0]}'."
|
|
736
774
|
) from error
|
|
737
775
|
stdout = process.stdout
|
|
738
776
|
stderr = process.stderr
|
|
@@ -777,10 +815,16 @@ def bounded_gh_output(
|
|
|
777
815
|
message = (
|
|
778
816
|
bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
|
|
779
817
|
)
|
|
780
|
-
raise RuntimeError(
|
|
818
|
+
raise RuntimeError(
|
|
819
|
+
message
|
|
820
|
+
or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
|
|
821
|
+
)
|
|
781
822
|
return bytes(stdout_capture.output)
|
|
782
823
|
except OSError as error:
|
|
783
|
-
raise RuntimeError(
|
|
824
|
+
raise RuntimeError(
|
|
825
|
+
f"{operating_system_error}. "
|
|
826
|
+
f"The operation returned this diagnostic.\n{error}"
|
|
827
|
+
) from error
|
|
784
828
|
|
|
785
829
|
|
|
786
830
|
def paginated_issue_comments(
|
|
@@ -795,11 +839,11 @@ def paginated_issue_comments(
|
|
|
795
839
|
aggregate_remaining_bytes = collection_budget.remaining_bytes()
|
|
796
840
|
if remaining_bytes <= 0:
|
|
797
841
|
raise LinkedRequirementsCoverageGap(
|
|
798
|
-
"linked issue comments exceed the safe byte limit"
|
|
842
|
+
"The linked issue comments exceed the safe byte limit."
|
|
799
843
|
)
|
|
800
844
|
if aggregate_remaining_bytes <= 0:
|
|
801
845
|
raise LinkedRequirementsCoverageGap(
|
|
802
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
846
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
803
847
|
)
|
|
804
848
|
collection_budget.reserve_comment_page()
|
|
805
849
|
response = bounded_gh_output(
|
|
@@ -819,7 +863,7 @@ def paginated_issue_comments(
|
|
|
819
863
|
remaining_bytes,
|
|
820
864
|
aggregate_remaining_bytes,
|
|
821
865
|
),
|
|
822
|
-
limit_error="linked issue comments exceed the safe byte limit",
|
|
866
|
+
limit_error="The linked issue comments exceed the safe byte limit.",
|
|
823
867
|
)
|
|
824
868
|
bytes_read += len(response)
|
|
825
869
|
collection_budget.record_bytes(len(response))
|
|
@@ -827,27 +871,33 @@ def paginated_issue_comments(
|
|
|
827
871
|
page_comments = json.loads(response)
|
|
828
872
|
except json.JSONDecodeError as error:
|
|
829
873
|
raise RuntimeError(
|
|
830
|
-
"GitHub returned
|
|
874
|
+
"GitHub returned linked issue comment pages that are not valid."
|
|
831
875
|
) from error
|
|
832
876
|
if not isinstance(page_comments, list):
|
|
833
|
-
raise TypeError(
|
|
877
|
+
raise TypeError(
|
|
878
|
+
"GitHub returned linked issue comment pages that are not valid."
|
|
879
|
+
)
|
|
834
880
|
if not all(isinstance(comment, dict) for comment in page_comments):
|
|
835
|
-
raise RuntimeError(
|
|
881
|
+
raise RuntimeError(
|
|
882
|
+
"GitHub returned a linked issue comment that is not valid."
|
|
883
|
+
)
|
|
836
884
|
if page > MAX_LINKED_ISSUE_COMMENT_PAGES:
|
|
837
885
|
if page_comments:
|
|
838
886
|
raise LinkedRequirementsCoverageGap(
|
|
839
|
-
"linked issue comments exceed the safe page limit"
|
|
887
|
+
"The linked issue comments exceed the safe page limit."
|
|
840
888
|
)
|
|
841
889
|
return comments
|
|
842
890
|
if len(comments) + len(page_comments) > MAX_LINKED_ISSUE_COMMENTS:
|
|
843
891
|
raise LinkedRequirementsCoverageGap(
|
|
844
|
-
"linked issue comments exceed the safe comment limit"
|
|
892
|
+
"The linked issue comments exceed the safe comment limit."
|
|
845
893
|
)
|
|
846
894
|
collection_budget.record_comment_page(len(page_comments))
|
|
847
895
|
comments.extend(page_comments)
|
|
848
896
|
if len(page_comments) < LINKED_ISSUE_COMMENT_PAGE_SIZE:
|
|
849
897
|
return comments
|
|
850
|
-
raise AssertionError(
|
|
898
|
+
raise AssertionError(
|
|
899
|
+
"The bounded linked issue comment pagination did not terminate."
|
|
900
|
+
)
|
|
851
901
|
|
|
852
902
|
|
|
853
903
|
def structured_error(error: str, details: str) -> None:
|
|
@@ -862,7 +912,7 @@ def linked_issue_metadata(
|
|
|
862
912
|
aggregate_remaining_bytes = budget.remaining_bytes()
|
|
863
913
|
if aggregate_remaining_bytes <= 0:
|
|
864
914
|
raise LinkedRequirementsCoverageGap(
|
|
865
|
-
"linked issue requirements exceed the safe aggregate byte limit"
|
|
915
|
+
"The linked issue requirements exceed the safe aggregate byte limit."
|
|
866
916
|
)
|
|
867
917
|
budget.reserve_request()
|
|
868
918
|
response = bounded_gh_output(
|
|
@@ -878,43 +928,69 @@ def linked_issue_metadata(
|
|
|
878
928
|
maximum_bytes=min(
|
|
879
929
|
MAX_LINKED_REQUIREMENT_METADATA_BYTES, aggregate_remaining_bytes
|
|
880
930
|
),
|
|
881
|
-
limit_error="linked issue metadata exceeds the safe metadata byte limit",
|
|
931
|
+
limit_error="The linked issue metadata exceeds the safe metadata byte limit.",
|
|
882
932
|
)
|
|
883
933
|
budget.record_bytes(len(response))
|
|
884
934
|
try:
|
|
885
935
|
issue_data = json.loads(response)
|
|
886
936
|
except json.JSONDecodeError as error:
|
|
887
|
-
raise RuntimeError(
|
|
937
|
+
raise RuntimeError(
|
|
938
|
+
"GitHub returned a linked issue that is not valid."
|
|
939
|
+
) from error
|
|
888
940
|
if not isinstance(issue_data, dict):
|
|
889
|
-
raise TypeError("GitHub returned
|
|
941
|
+
raise TypeError("GitHub returned a linked issue that is not valid.")
|
|
890
942
|
return issue_data
|
|
891
943
|
|
|
892
944
|
|
|
893
945
|
def linked_requirements(
|
|
894
|
-
metadata: dict[str, Any],
|
|
946
|
+
metadata: dict[str, Any],
|
|
947
|
+
budget: LinkedRequirementBudget | None = None,
|
|
948
|
+
requirement_issues: Sequence[str] = (),
|
|
895
949
|
) -> LinkedRequirements:
|
|
896
950
|
"""Bind every linked issue's requirement content and complete comment history."""
|
|
897
951
|
references = metadata.get("closingIssuesReferences")
|
|
898
952
|
if not isinstance(references, list):
|
|
899
|
-
raise TypeError("GitHub returned incomplete linked issue references")
|
|
953
|
+
raise TypeError("GitHub returned incomplete linked issue references.")
|
|
900
954
|
identities = sorted(linked_issue_reference(issue) for issue in references)
|
|
901
955
|
if len(identities) != len(set(identities)):
|
|
902
|
-
raise RuntimeError("GitHub returned duplicate linked issue references")
|
|
956
|
+
raise RuntimeError("GitHub returned duplicate linked issue references.")
|
|
957
|
+
selected: dict[str, tuple[str | None, str, int, str]] = {
|
|
958
|
+
identity[3]: identity for identity in identities
|
|
959
|
+
}
|
|
960
|
+
if len(selected) != len(identities):
|
|
961
|
+
raise RuntimeError(
|
|
962
|
+
"GitHub returned different identities for the same linked issue URL."
|
|
963
|
+
)
|
|
964
|
+
for url in requirement_issues:
|
|
965
|
+
match = re.fullmatch(
|
|
966
|
+
r"https://github\.com/([^/]+)/([^/]+)/issues/([1-9][0-9]{0,19})", url
|
|
967
|
+
)
|
|
968
|
+
if match is None:
|
|
969
|
+
raise RuntimeError("Use a canonical GitHub issue URL for each requirement.")
|
|
970
|
+
repository = require_github_repository(
|
|
971
|
+
f"{match[1]}/{match[2]}", "requirement issue repository"
|
|
972
|
+
)
|
|
973
|
+
selected.setdefault(url, (None, repository, int(match[3]), url))
|
|
903
974
|
collection_budget = budget if budget is not None else LinkedRequirementBudget()
|
|
904
975
|
items: list[LinkedRequirement] = []
|
|
905
|
-
for expected_id, repository, number, expected_url in
|
|
976
|
+
for expected_id, repository, number, expected_url in sorted(
|
|
977
|
+
selected.values(), key=lambda identity: identity[3]
|
|
978
|
+
):
|
|
906
979
|
issue_data = linked_issue_metadata(repository, number, collection_budget)
|
|
907
980
|
issue_id = issue_data.get("id")
|
|
908
981
|
body = issue_data.get("body")
|
|
909
982
|
if (
|
|
910
|
-
issue_id
|
|
983
|
+
not isinstance(issue_id, str)
|
|
984
|
+
or not issue_id
|
|
985
|
+
or (expected_id is not None and issue_id != expected_id)
|
|
986
|
+
or isinstance(issue_data.get("number"), bool)
|
|
911
987
|
or issue_data.get("number") != number
|
|
912
988
|
or issue_data.get("url") != expected_url
|
|
913
989
|
or not isinstance(issue_data.get("title"), str)
|
|
914
990
|
or (body is not None and not isinstance(body, str))
|
|
915
991
|
or not isinstance(issue_data.get("state"), str)
|
|
916
992
|
):
|
|
917
|
-
raise RuntimeError("GitHub returned incomplete linked issue requirements")
|
|
993
|
+
raise RuntimeError("GitHub returned incomplete linked issue requirements.")
|
|
918
994
|
comments = sorted(
|
|
919
995
|
canonical_json(comment, "linked issue comment")
|
|
920
996
|
for comment in paginated_issue_comments(
|
|
@@ -929,7 +1005,7 @@ def linked_requirements(
|
|
|
929
1005
|
}
|
|
930
1006
|
items.append(
|
|
931
1007
|
LinkedRequirement(
|
|
932
|
-
id=
|
|
1008
|
+
id=issue_id,
|
|
933
1009
|
repository=repository,
|
|
934
1010
|
number=number,
|
|
935
1011
|
url=expected_url,
|
|
@@ -938,6 +1014,7 @@ def linked_requirements(
|
|
|
938
1014
|
).hexdigest(),
|
|
939
1015
|
)
|
|
940
1016
|
)
|
|
1017
|
+
items.sort(key=lambda item: (item.id, item.repository, item.number, item.url))
|
|
941
1018
|
document = canonical_json(
|
|
942
1019
|
[item.as_json() for item in items], "linked issue requirements"
|
|
943
1020
|
)
|
|
@@ -962,14 +1039,17 @@ def bounded_git_path_manifest(
|
|
|
962
1039
|
)
|
|
963
1040
|
except FileNotFoundError as error:
|
|
964
1041
|
raise RuntimeError(
|
|
965
|
-
|
|
1042
|
+
"The required command is not available: "
|
|
1043
|
+
f"'{error.filename or command[0]}'."
|
|
966
1044
|
) from error
|
|
967
1045
|
stdout = process.stdout
|
|
968
1046
|
stderr = process.stderr
|
|
969
1047
|
if stdout is None or stderr is None:
|
|
970
1048
|
process.kill()
|
|
971
1049
|
process.wait()
|
|
972
|
-
raise RuntimeError(
|
|
1050
|
+
raise RuntimeError(
|
|
1051
|
+
"The Git process did not provide the required output streams."
|
|
1052
|
+
)
|
|
973
1053
|
stdout_capture = ChangedPathStream(
|
|
974
1054
|
MAX_CHANGED_PATH_MANIFEST_BYTES, MAX_CHANGED_PATHS
|
|
975
1055
|
)
|
|
@@ -993,11 +1073,15 @@ def bounded_git_path_manifest(
|
|
|
993
1073
|
process,
|
|
994
1074
|
stdout_capture,
|
|
995
1075
|
stderr_capture,
|
|
996
|
-
"changed-path manifest exceeds the safe byte limit",
|
|
1076
|
+
"The changed-path manifest exceeds the safe byte limit.",
|
|
997
1077
|
timeout_seconds=CHANGED_PATH_REQUEST_TIMEOUT_SECONDS,
|
|
998
|
-
stderr_limit_error=
|
|
999
|
-
|
|
1000
|
-
|
|
1078
|
+
stderr_limit_error=(
|
|
1079
|
+
"The changed-path response exceeds the safe standard-error limit."
|
|
1080
|
+
),
|
|
1081
|
+
deadline_error=(
|
|
1082
|
+
"The changed-path provider exceeded the safe deadline."
|
|
1083
|
+
),
|
|
1084
|
+
output_error="The tool cannot read immutable changed-path output.",
|
|
1001
1085
|
coverage_gap=ChangedPathCoverageGap,
|
|
1002
1086
|
)
|
|
1003
1087
|
except BaseException:
|
|
@@ -1009,16 +1093,19 @@ def bounded_git_path_manifest(
|
|
|
1009
1093
|
message = (
|
|
1010
1094
|
bytes(stderr_capture.output).decode("utf-8", errors="replace").strip()
|
|
1011
1095
|
)
|
|
1012
|
-
raise RuntimeError(
|
|
1096
|
+
raise RuntimeError(
|
|
1097
|
+
message or f"The Git command failed. Command: git {' '.join(arguments)}"
|
|
1098
|
+
)
|
|
1013
1099
|
return stdout_capture.paths
|
|
1014
1100
|
except OSError as error:
|
|
1015
1101
|
raise RuntimeError(
|
|
1016
|
-
|
|
1102
|
+
"The tool cannot collect immutable changed paths. "
|
|
1103
|
+
f"The operation returned this diagnostic.\n{error}"
|
|
1017
1104
|
) from error
|
|
1018
1105
|
|
|
1019
1106
|
|
|
1020
1107
|
def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
|
|
1021
|
-
"""Run a read-only Git query and return its byte-exact
|
|
1108
|
+
"""Run a read-only Git query and return its byte-exact standard output."""
|
|
1022
1109
|
result: Any = run_command(
|
|
1023
1110
|
["git", *git_read_arguments(), *arguments],
|
|
1024
1111
|
capture_output=True,
|
|
@@ -1032,17 +1119,19 @@ def git_bytes(*arguments: str, cwd: Path | None = None) -> bytes:
|
|
|
1032
1119
|
message = stderr.decode("utf-8", errors="replace").strip()
|
|
1033
1120
|
else:
|
|
1034
1121
|
message = str(stderr).strip()
|
|
1035
|
-
raise RuntimeError(
|
|
1122
|
+
raise RuntimeError(
|
|
1123
|
+
message or f"The Git command failed. Command: git {' '.join(arguments)}"
|
|
1124
|
+
)
|
|
1036
1125
|
stdout = result.stdout
|
|
1037
1126
|
if not isinstance(stdout, bytes):
|
|
1038
|
-
raise TypeError("
|
|
1127
|
+
raise TypeError("Git returned non-byte output for immutable path evidence.")
|
|
1039
1128
|
return stdout
|
|
1040
1129
|
|
|
1041
1130
|
|
|
1042
1131
|
def immutable_range_paths(
|
|
1043
1132
|
base_oid: str, head_oid: str, *, cwd: Path | None = None
|
|
1044
1133
|
) -> list[bytes]:
|
|
1045
|
-
"""Return validated
|
|
1134
|
+
"""Return validated null-byte-safe paths from one immutable Git diff range."""
|
|
1046
1135
|
return bounded_git_path_manifest(
|
|
1047
1136
|
(
|
|
1048
1137
|
"-c",
|
|
@@ -1081,7 +1170,9 @@ def immutable_changed_paths(
|
|
|
1081
1170
|
try:
|
|
1082
1171
|
paths = tuple(entry.decode("utf-8") for entry in canonical_entries)
|
|
1083
1172
|
except UnicodeDecodeError as error:
|
|
1084
|
-
raise RuntimeError(
|
|
1173
|
+
raise RuntimeError(
|
|
1174
|
+
"Git returned a changed path that is not valid 'UTF-8'."
|
|
1175
|
+
) from error
|
|
1085
1176
|
canonical_bytes = b"".join(entry + b"\0" for entry in canonical_entries)
|
|
1086
1177
|
return ChangedPathManifest(paths=paths, sha256=sha256(canonical_bytes).hexdigest())
|
|
1087
1178
|
|
|
@@ -1101,13 +1192,15 @@ def strict_changed_paths(
|
|
|
1101
1192
|
expected: tuple[str, str],
|
|
1102
1193
|
target: ExpectedReviewTarget,
|
|
1103
1194
|
) -> tuple[ChangedPathManifest, MaterializedSnapshot | None]:
|
|
1104
|
-
"""Derive strict paths locally or from a verified host-owned snapshot."""
|
|
1195
|
+
"""Derive strict paths locally or from a verified host-owned materialized snapshot."""
|
|
1105
1196
|
base_oid, head_oid = expected
|
|
1106
1197
|
if local_immutable_objects_available(base_oid, head_oid):
|
|
1107
1198
|
return immutable_changed_paths(base_oid, head_oid), None
|
|
1108
1199
|
base_ref = metadata.get("baseRefName")
|
|
1109
1200
|
if not isinstance(base_ref, str):
|
|
1110
|
-
raise TypeError(
|
|
1201
|
+
raise TypeError(
|
|
1202
|
+
"GitHub returned a pull-request base reference that is not valid."
|
|
1203
|
+
)
|
|
1111
1204
|
snapshot = materialize_snapshot(
|
|
1112
1205
|
repository=target.repository,
|
|
1113
1206
|
number=target.number,
|
|
@@ -1129,7 +1222,10 @@ def gh(*arguments: str, accepted_codes: tuple[int, ...] = (0,)) -> str:
|
|
|
1129
1222
|
["gh", *arguments], capture_output=True, text=True, check=False
|
|
1130
1223
|
)
|
|
1131
1224
|
if result.returncode not in accepted_codes:
|
|
1132
|
-
raise RuntimeError(
|
|
1225
|
+
raise RuntimeError(
|
|
1226
|
+
result.stderr.strip()
|
|
1227
|
+
or f"The GitHub CLI command failed. Command: gh {' '.join(arguments)}"
|
|
1228
|
+
)
|
|
1133
1229
|
return result.stdout
|
|
1134
1230
|
|
|
1135
1231
|
|
|
@@ -1142,12 +1238,12 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1142
1238
|
for page_number in range(1, MAX_CHECK_RUN_PAGES + 2):
|
|
1143
1239
|
if page_number > MAX_CHECK_RUN_PAGES:
|
|
1144
1240
|
raise CheckEvidenceCoverageGap(
|
|
1145
|
-
"GitHub check runs exceed the safe page limit"
|
|
1241
|
+
"GitHub check runs exceed the safe page limit."
|
|
1146
1242
|
)
|
|
1147
1243
|
remaining_bytes = MAX_CHECK_RUN_BYTES - bytes_read
|
|
1148
1244
|
if remaining_bytes <= 0:
|
|
1149
1245
|
raise CheckEvidenceCoverageGap(
|
|
1150
|
-
"GitHub check runs exceed the safe aggregate byte limit"
|
|
1246
|
+
"GitHub check runs exceed the safe aggregate byte limit."
|
|
1151
1247
|
)
|
|
1152
1248
|
try:
|
|
1153
1249
|
response = bounded_gh_output(
|
|
@@ -1164,32 +1260,40 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1164
1260
|
f"per_page=100&page={page_number}"
|
|
1165
1261
|
),
|
|
1166
1262
|
),
|
|
1167
|
-
maximum_bytes=
|
|
1168
|
-
limit_error="GitHub check-run response exceeds the safe byte limit",
|
|
1263
|
+
maximum_bytes=remaining_bytes,
|
|
1264
|
+
limit_error="The GitHub check-run response exceeds the safe byte limit.",
|
|
1169
1265
|
timeout_seconds=CHECK_RUN_REQUEST_TIMEOUT_SECONDS,
|
|
1170
1266
|
stderr_maximum_bytes=MAX_CHECK_RUN_STDERR_BYTES,
|
|
1171
|
-
stderr_limit_error=
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1267
|
+
stderr_limit_error=(
|
|
1268
|
+
"The GitHub check-run response exceeds the safe standard-error limit."
|
|
1269
|
+
),
|
|
1270
|
+
deadline_error=(
|
|
1271
|
+
"The GitHub check-run provider exceeded the safe deadline."
|
|
1272
|
+
),
|
|
1273
|
+
output_error="The tool cannot read GitHub check-run provider output.",
|
|
1274
|
+
unavailable_output_error=(
|
|
1275
|
+
"The GitHub CLI process did not provide check-run output."
|
|
1276
|
+
),
|
|
1277
|
+
operating_system_error="The tool cannot collect GitHub check runs",
|
|
1176
1278
|
coverage_gap=CheckEvidenceCoverageGap,
|
|
1177
1279
|
)
|
|
1178
1280
|
except RuntimeError as error:
|
|
1179
1281
|
if isinstance(error, CheckEvidenceCoverageGap):
|
|
1180
1282
|
raise
|
|
1181
1283
|
raise CheckEvidenceCoverageGap(
|
|
1182
|
-
"GitHub did not return readable head
|
|
1284
|
+
"GitHub did not return readable check evidence for the head object identifier."
|
|
1183
1285
|
) from error
|
|
1184
1286
|
bytes_read += len(response)
|
|
1185
1287
|
try:
|
|
1186
1288
|
page = json.loads(response)
|
|
1187
1289
|
except json.JSONDecodeError as error:
|
|
1188
1290
|
raise CheckEvidenceCoverageGap(
|
|
1189
|
-
"GitHub did not return readable head
|
|
1291
|
+
"GitHub did not return readable check evidence for the head object identifier."
|
|
1190
1292
|
) from error
|
|
1191
1293
|
if not isinstance(page, dict):
|
|
1192
|
-
raise CheckEvidenceCoverageGap(
|
|
1294
|
+
raise CheckEvidenceCoverageGap(
|
|
1295
|
+
"GitHub returned a check-run page that is not valid."
|
|
1296
|
+
)
|
|
1193
1297
|
page_total = page.get("total_count")
|
|
1194
1298
|
page_runs = page.get("check_runs")
|
|
1195
1299
|
if (
|
|
@@ -1199,21 +1303,23 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1199
1303
|
or not isinstance(page_runs, list)
|
|
1200
1304
|
):
|
|
1201
1305
|
raise CheckEvidenceCoverageGap(
|
|
1202
|
-
"GitHub returned incomplete check-run evidence"
|
|
1306
|
+
"GitHub returned incomplete check-run evidence."
|
|
1203
1307
|
)
|
|
1204
1308
|
if total_count is None:
|
|
1205
1309
|
total_count = page_total
|
|
1206
1310
|
if total_count > MAX_CHECK_RUNS:
|
|
1207
1311
|
raise CheckEvidenceCoverageGap(
|
|
1208
|
-
"GitHub check runs exceed the safe run limit"
|
|
1312
|
+
"GitHub check runs exceed the safe run limit."
|
|
1209
1313
|
)
|
|
1210
1314
|
elif page_total != total_count:
|
|
1211
1315
|
raise CheckEvidenceCoverageGap(
|
|
1212
|
-
"GitHub returned inconsistent check-run totals"
|
|
1316
|
+
"GitHub returned inconsistent check-run totals."
|
|
1213
1317
|
)
|
|
1214
1318
|
for run in page_runs:
|
|
1215
1319
|
if not isinstance(run, dict):
|
|
1216
|
-
raise CheckEvidenceCoverageGap(
|
|
1320
|
+
raise CheckEvidenceCoverageGap(
|
|
1321
|
+
"GitHub returned a check run that is not valid."
|
|
1322
|
+
)
|
|
1217
1323
|
run_id = run.get("id")
|
|
1218
1324
|
run_head_oid = run.get("head_sha")
|
|
1219
1325
|
if (
|
|
@@ -1229,69 +1335,98 @@ def head_bound_check_runs(repository: str, head_oid: str) -> list[dict[str, Any]
|
|
|
1229
1335
|
or COMMIT_OID.fullmatch(run_head_oid) is None
|
|
1230
1336
|
):
|
|
1231
1337
|
raise CheckEvidenceCoverageGap(
|
|
1232
|
-
"GitHub returned incomplete check-run evidence"
|
|
1338
|
+
"GitHub returned incomplete check-run evidence."
|
|
1233
1339
|
)
|
|
1234
1340
|
if run_head_oid != head_oid:
|
|
1235
1341
|
raise CheckEvidenceCoverageGap(
|
|
1236
|
-
"GitHub returned a check run bound to a different head
|
|
1342
|
+
"GitHub returned a check run that is bound to a different head "
|
|
1343
|
+
"object identifier."
|
|
1237
1344
|
)
|
|
1238
1345
|
run_ids.add(run_id)
|
|
1239
1346
|
runs.append(run)
|
|
1240
1347
|
if len(runs) == total_count:
|
|
1241
1348
|
return runs
|
|
1242
1349
|
if not page_runs:
|
|
1243
|
-
raise CheckEvidenceCoverageGap(
|
|
1244
|
-
|
|
1350
|
+
raise CheckEvidenceCoverageGap(
|
|
1351
|
+
"GitHub returned partial check-run evidence."
|
|
1352
|
+
)
|
|
1353
|
+
raise AssertionError("The bounded check-run pagination did not terminate.")
|
|
1245
1354
|
|
|
1246
1355
|
|
|
1247
1356
|
def pr_metadata(
|
|
1248
1357
|
pull_request: str, target: ExpectedReviewTarget | None
|
|
1249
1358
|
) -> dict[str, Any]:
|
|
1250
|
-
"""Read one
|
|
1359
|
+
"""Read one pull request through the retained target when strict evidence is required."""
|
|
1251
1360
|
command = ["pr", "view", pull_request]
|
|
1252
1361
|
if target is not None:
|
|
1253
1362
|
command.extend(("--repo", target.repository_argument()))
|
|
1254
1363
|
command.extend(("--json", FIELDS))
|
|
1255
1364
|
metadata = json.loads(gh(*command))
|
|
1256
1365
|
if not isinstance(metadata, dict):
|
|
1257
|
-
raise TypeError("GitHub returned
|
|
1366
|
+
raise TypeError("GitHub returned a pull-request object that is not valid.")
|
|
1258
1367
|
return metadata
|
|
1259
1368
|
|
|
1260
1369
|
|
|
1370
|
+
def merge_readiness(metadata: dict[str, Any]) -> dict[str, str]:
|
|
1371
|
+
"""Return forge approval state as evidence separate from the review verdict."""
|
|
1372
|
+
decision = metadata.get("reviewDecision")
|
|
1373
|
+
if not isinstance(decision, str) or not decision:
|
|
1374
|
+
decision = "UNAVAILABLE"
|
|
1375
|
+
approval_gate = {
|
|
1376
|
+
"APPROVED": "satisfied",
|
|
1377
|
+
"CHANGES_REQUESTED": "blocked",
|
|
1378
|
+
"REVIEW_REQUIRED": "blocked",
|
|
1379
|
+
}.get(decision, "unknown")
|
|
1380
|
+
return {
|
|
1381
|
+
"auto_merge_approval_gate": approval_gate,
|
|
1382
|
+
"authority": (
|
|
1383
|
+
"Repository-policy evidence excluded from the review verdict and scope digests."
|
|
1384
|
+
),
|
|
1385
|
+
"review_decision": decision,
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
|
|
1261
1389
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
1262
1390
|
parser = argument_parser(description=__doc__)
|
|
1263
1391
|
parser.add_argument(
|
|
1264
1392
|
"--expected-base-oid",
|
|
1265
1393
|
metavar="BASE_OID",
|
|
1266
|
-
help="immutable base revision
|
|
1394
|
+
help="Use the immutable base revision from 'resolve_pr.py'.",
|
|
1267
1395
|
)
|
|
1268
1396
|
parser.add_argument(
|
|
1269
1397
|
"--expected-head-oid",
|
|
1270
1398
|
metavar="HEAD_OID",
|
|
1271
|
-
help="immutable head revision
|
|
1399
|
+
help="Use the immutable head revision from 'resolve_pr.py'.",
|
|
1272
1400
|
)
|
|
1273
1401
|
parser.add_argument(
|
|
1274
1402
|
"--expected-host",
|
|
1275
1403
|
metavar="HOST",
|
|
1276
|
-
help="canonical GitHub host
|
|
1404
|
+
help="Use the canonical GitHub host from 'resolve_pr.py'.",
|
|
1277
1405
|
)
|
|
1278
1406
|
parser.add_argument(
|
|
1279
1407
|
"--expected-repository",
|
|
1280
1408
|
metavar="OWNER/REPOSITORY",
|
|
1281
|
-
help="canonical GitHub repository
|
|
1409
|
+
help="Use the canonical GitHub repository from 'resolve_pr.py'.",
|
|
1282
1410
|
)
|
|
1283
1411
|
parser.add_argument(
|
|
1284
1412
|
"--expected-pr-number",
|
|
1285
1413
|
metavar="NUMBER",
|
|
1286
1414
|
type=int,
|
|
1287
|
-
help="canonical pull-request number
|
|
1415
|
+
help="Use the canonical pull-request number from 'resolve_pr.py'.",
|
|
1288
1416
|
)
|
|
1289
1417
|
parser.add_argument(
|
|
1290
1418
|
"--expected-pr-url",
|
|
1291
1419
|
metavar="URL",
|
|
1292
|
-
help="canonical pull-request URL
|
|
1420
|
+
help="Use the canonical pull-request URL from 'resolve_pr.py'.",
|
|
1293
1421
|
)
|
|
1294
1422
|
parser.add_argument("pull_request", metavar="PR_NUMBER_OR_URL")
|
|
1423
|
+
parser.add_argument(
|
|
1424
|
+
"--requirement-issue",
|
|
1425
|
+
action="append",
|
|
1426
|
+
default=[],
|
|
1427
|
+
metavar="ISSUE_URL",
|
|
1428
|
+
help="Bind another GitHub issue. Use this option for each non-closing requirement.",
|
|
1429
|
+
)
|
|
1295
1430
|
arguments = parser.parse_args(argv)
|
|
1296
1431
|
pull_request = arguments.pull_request
|
|
1297
1432
|
expected = expected_identity(
|
|
@@ -1306,17 +1441,20 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1306
1441
|
arguments.expected_pr_url,
|
|
1307
1442
|
)
|
|
1308
1443
|
require_immutable_identity = expected is not None
|
|
1444
|
+
if arguments.requirement_issue and not require_immutable_identity:
|
|
1445
|
+
parser.error("All strict identity arguments are necessary for this option.")
|
|
1309
1446
|
try:
|
|
1310
1447
|
validate_pr_identifier(pull_request)
|
|
1311
1448
|
requested = pull_request_number(pull_request)
|
|
1312
1449
|
if target is not None:
|
|
1313
1450
|
if requested != target.number:
|
|
1314
1451
|
raise RuntimeError(
|
|
1315
|
-
"requested pull request does not match the expected target
|
|
1452
|
+
"The requested pull request does not match the expected target "
|
|
1453
|
+
"number."
|
|
1316
1454
|
)
|
|
1317
1455
|
if pull_request.startswith("https://") and pull_request != target.url:
|
|
1318
1456
|
raise RuntimeError(
|
|
1319
|
-
"requested pull request does not match the expected target URL"
|
|
1457
|
+
"The requested pull request does not match the expected target URL."
|
|
1320
1458
|
)
|
|
1321
1459
|
metadata = pr_metadata(pull_request, target)
|
|
1322
1460
|
metadata_problem = metadata_error(
|
|
@@ -1337,15 +1475,18 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1337
1475
|
or not isinstance(number, int)
|
|
1338
1476
|
or not isinstance(url, str)
|
|
1339
1477
|
):
|
|
1340
|
-
raise TypeError(
|
|
1478
|
+
raise TypeError(
|
|
1479
|
+
"GitHub returned an incomplete repository or pull-request identity."
|
|
1480
|
+
)
|
|
1341
1481
|
pull_repository = repository_from_pr_url(url, number)
|
|
1342
1482
|
if pull_repository.casefold() != repository.casefold():
|
|
1343
1483
|
raise RuntimeError(
|
|
1344
|
-
f"
|
|
1484
|
+
f"Pull request '{url}' is not in the current repository '{repository}'."
|
|
1345
1485
|
)
|
|
1346
1486
|
if number != requested:
|
|
1347
1487
|
raise RuntimeError(
|
|
1348
|
-
"GitHub returned a pull request different from the requested
|
|
1488
|
+
"GitHub returned a pull request that is different from the requested "
|
|
1489
|
+
"identifier."
|
|
1349
1490
|
)
|
|
1350
1491
|
identity = immutable_identity(
|
|
1351
1492
|
metadata,
|
|
@@ -1359,7 +1500,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1359
1500
|
LinkedRequirementBudget() if expected is not None else None
|
|
1360
1501
|
)
|
|
1361
1502
|
reviewed_linked_requirements = (
|
|
1362
|
-
linked_requirements(
|
|
1503
|
+
linked_requirements(
|
|
1504
|
+
metadata, linked_requirement_budget, arguments.requirement_issue
|
|
1505
|
+
)
|
|
1363
1506
|
if expected is not None
|
|
1364
1507
|
else None
|
|
1365
1508
|
)
|
|
@@ -1410,7 +1553,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1410
1553
|
)
|
|
1411
1554
|
)
|
|
1412
1555
|
if not isinstance(checks, list):
|
|
1413
|
-
raise RuntimeError("GitHub returned
|
|
1556
|
+
raise RuntimeError("GitHub returned check evidence that is not valid.")
|
|
1414
1557
|
final_metadata = pr_metadata(pull_request, target)
|
|
1415
1558
|
final_problem = metadata_error(
|
|
1416
1559
|
final_metadata, require_immutable_identity=require_immutable_identity
|
|
@@ -1425,21 +1568,23 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1425
1568
|
)
|
|
1426
1569
|
if final_identity != identity:
|
|
1427
1570
|
raise RuntimeError(
|
|
1428
|
-
"immutable pull-request identity changed
|
|
1571
|
+
"The immutable pull-request identity changed during evidence collection."
|
|
1429
1572
|
)
|
|
1430
1573
|
ensure_expected_identity(final_identity, expected)
|
|
1431
1574
|
ensure_expected_target(final_identity, target)
|
|
1432
1575
|
final_scope = review_scope(final_metadata) if expected is not None else None
|
|
1433
1576
|
if final_scope != reviewed_scope:
|
|
1434
|
-
raise RuntimeError("review scope changed
|
|
1577
|
+
raise RuntimeError("The review scope changed during evidence collection.")
|
|
1435
1578
|
final_linked_requirements = (
|
|
1436
|
-
linked_requirements(
|
|
1579
|
+
linked_requirements(
|
|
1580
|
+
final_metadata, linked_requirement_budget, arguments.requirement_issue
|
|
1581
|
+
)
|
|
1437
1582
|
if expected is not None
|
|
1438
1583
|
else None
|
|
1439
1584
|
)
|
|
1440
1585
|
if final_linked_requirements != reviewed_linked_requirements:
|
|
1441
1586
|
raise RuntimeError(
|
|
1442
|
-
"linked issue requirements changed
|
|
1587
|
+
"The linked issue requirements changed during evidence collection."
|
|
1443
1588
|
)
|
|
1444
1589
|
except ChangedPathCoverageGap as error:
|
|
1445
1590
|
structured_error("changed path coverage gap", str(error))
|
|
@@ -1454,7 +1599,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
1454
1599
|
"changed_files": changed_files,
|
|
1455
1600
|
"changed_paths": changed_files,
|
|
1456
1601
|
"checks": checks,
|
|
1457
|
-
"pull_request":
|
|
1602
|
+
"pull_request": {
|
|
1603
|
+
key: value
|
|
1604
|
+
for key, value in final_metadata.items()
|
|
1605
|
+
if key != "reviewDecision"
|
|
1606
|
+
},
|
|
1607
|
+
"merge_readiness": merge_readiness(final_metadata),
|
|
1458
1608
|
}
|
|
1459
1609
|
if identity is not None:
|
|
1460
1610
|
evidence["reviewed_identity"] = identity.as_json()
|