@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.
Files changed (146) hide show
  1. package/README.md +30 -17
  2. package/package.json +2 -2
  3. package/plugin.js +20 -5
  4. package/skills/TECHNICAL_ENGLISH.md +78 -0
  5. package/skills/_cli.py +33 -22
  6. package/skills/_plugin.json +1 -0
  7. package/skills/_support/docs/dependency-resolution.md +203 -0
  8. package/skills/_support/docs/policies/development.md +131 -0
  9. package/skills/_support/docs/policies/evidence-integrity.md +40 -0
  10. package/skills/_support/docs/principles/README.md +565 -0
  11. package/skills/_support/docs/principles/details/p001-kiss.md +114 -0
  12. package/skills/_support/docs/principles/details/p002-yagni.md +109 -0
  13. package/skills/_support/docs/principles/details/p003-dry.md +126 -0
  14. package/skills/_support/docs/principles/details/p004-solid.md +165 -0
  15. package/skills/_support/docs/principles/details/p005-modularity.md +123 -0
  16. package/skills/_support/docs/principles/details/p006-principle-of-least-astonishment.md +115 -0
  17. package/skills/_support/docs/principles/details/p007-subtraction-over-addition.md +113 -0
  18. package/skills/_support/docs/principles/details/p008-understand-before-subtracting.md +115 -0
  19. package/skills/_support/docs/principles/details/p009-general-mechanisms-over-special-cases.md +108 -0
  20. package/skills/_support/docs/principles/details/p010-scope-fidelity.md +117 -0
  21. package/skills/_support/docs/principles/details/p011-minimal-coherent-change.md +128 -0
  22. package/skills/_support/docs/principles/details/p012-evidence-before-modification.md +115 -0
  23. package/skills/_support/docs/principles/details/p013-avoid-hasty-abstractions.md +112 -0
  24. package/skills/_support/docs/principles/details/p014-preserve-unrequested-behavior.md +110 -0
  25. package/skills/_support/docs/principles/details/p015-architecture-conformance.md +124 -0
  26. package/skills/_support/docs/principles/details/p016-separation-of-concerns.md +125 -0
  27. package/skills/_support/docs/principles/details/p017-high-cohesion-low-coupling.md +130 -0
  28. package/skills/_support/docs/principles/details/p018-information-hiding.md +128 -0
  29. package/skills/_support/docs/principles/details/p019-explicit-contracts.md +134 -0
  30. package/skills/_support/docs/principles/details/p020-executable-architecture.md +130 -0
  31. package/skills/_support/docs/principles/details/p021-evolutionary-and-reversible-design.md +126 -0
  32. package/skills/_support/docs/principles/details/p022-test-behavior-not-implementation.md +129 -0
  33. package/skills/_support/docs/principles/details/p023-parameterized-table-driven-testing.md +129 -0
  34. package/skills/_support/docs/principles/details/p024-boundary-value-testing.md +128 -0
  35. package/skills/_support/docs/principles/details/p025-property-based-testing-for-invariants.md +133 -0
  36. package/skills/_support/docs/principles/details/p026-regression-before-repair.md +134 -0
  37. package/skills/_support/docs/principles/details/p027-deterministic-and-hermetic-tests.md +128 -0
  38. package/skills/_support/docs/principles/details/p028-test-failure-paths.md +133 -0
  39. package/skills/_support/docs/principles/details/p029-generalize-error-policy-preserve-specific-cause.md +131 -0
  40. package/skills/_support/docs/principles/details/p030-nearest-responsible-error-boundary.md +133 -0
  41. package/skills/_support/docs/principles/details/p031-propagate-rather-than-swallow.md +131 -0
  42. package/skills/_support/docs/principles/details/p032-handle-once-preserve-causality.md +137 -0
  43. package/skills/_support/docs/principles/details/p033-state-safe-failure-semantics.md +140 -0
  44. package/skills/_support/docs/principles/details/p034-fail-fast.md +132 -0
  45. package/skills/_support/docs/principles/details/p035-fail-secure-fail-closed.md +137 -0
  46. package/skills/_support/docs/principles/details/p036-graceful-degradation.md +137 -0
  47. package/skills/_support/docs/principles/details/p037-idempotency-before-retry.md +139 -0
  48. package/skills/_support/docs/principles/details/p038-bounded-retry.md +145 -0
  49. package/skills/_support/docs/principles/details/p039-bounded-waiting.md +151 -0
  50. package/skills/_support/docs/principles/details/p040-bounded-resources.md +138 -0
  51. package/skills/_support/docs/principles/details/p041-backpressure-and-load-shedding.md +141 -0
  52. package/skills/_support/docs/principles/details/p042-fault-isolation-bulkheads.md +140 -0
  53. package/skills/_support/docs/principles/details/p043-circuit-breakers.md +148 -0
  54. package/skills/_support/docs/principles/details/p044-atomicity-where-possible.md +141 -0
  55. package/skills/_support/docs/principles/details/p045-compensation-where-atomicity-is-impossible.md +163 -0
  56. package/skills/_support/docs/principles/details/p046-resumability.md +138 -0
  57. package/skills/_support/docs/principles/details/p047-observability-is-part-of-correctness.md +134 -0
  58. package/skills/_support/docs/principles/details/p048-secure-by-design.md +132 -0
  59. package/skills/_support/docs/principles/details/p049-secure-by-default.md +126 -0
  60. package/skills/_support/docs/principles/details/p050-least-privilege.md +123 -0
  61. package/skills/_support/docs/principles/details/p051-complete-mediation.md +124 -0
  62. package/skills/_support/docs/principles/details/p052-separation-of-duties.md +129 -0
  63. package/skills/_support/docs/principles/details/p053-validate-at-trust-boundaries.md +126 -0
  64. package/skills/_support/docs/principles/details/p054-defense-in-depth.md +135 -0
  65. package/skills/_support/docs/principles/details/p055-minimize-attack-surface.md +133 -0
  66. package/skills/_support/docs/principles/details/p056-secrets-stay-out-of-code-and-context.md +137 -0
  67. package/skills/_support/docs/principles/details/p057-supply-chain-integrity.md +135 -0
  68. package/skills/_support/docs/principles/details/p058-bounded-agent-authority.md +137 -0
  69. package/skills/_support/docs/principles/details/p059-data-is-not-instruction.md +130 -0
  70. package/skills/_support/docs/principles/details/p060-constrain-sub-agents.md +136 -0
  71. package/skills/_support/docs/principles/details/p061-separate-decision-from-high-impact-execution.md +131 -0
  72. package/skills/_support/docs/principles/details/p062-human-approval-for-irreversible-or-high-risk-actions.md +129 -0
  73. package/skills/_support/docs/principles/details/p063-requirement-to-code-traceability.md +116 -0
  74. package/skills/_support/docs/principles/details/p064-requirement-to-test-traceability.md +116 -0
  75. package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +131 -0
  76. package/skills/_support/docs/principles/details/p066-preserve-existing-work.md +114 -0
  77. package/skills/_support/docs/principles/details/p067-no-test-cheating.md +126 -0
  78. package/skills/_support/docs/principles/details/p068-no-validation-bypass.md +119 -0
  79. package/skills/_support/docs/principles/details/p069-independent-review-for-high-risk-changes.md +129 -0
  80. package/skills/_support/docs/principles/details/p070-code-health-must-not-regress.md +116 -0
  81. package/skills/_support/docs/principles/details/p071-consistency-over-personal-preference.md +117 -0
  82. package/skills/_support/docs/principles/details/p072-technical-evidence-over-preference.md +122 -0
  83. package/skills/_support/docs/principles/details/p073-optimize-only-with-evidence.md +123 -0
  84. package/skills/_support/docs/principles/details/p074-prefer-existing-mechanisms.md +121 -0
  85. package/skills/_support/docs/principles/details/p075-make-invalid-states-hard-to-represent.md +128 -0
  86. package/skills/_support/docs/principles/details/p076-parse-then-validate-then-operate.md +133 -0
  87. package/skills/_support/docs/principles/details/p077-separate-policy-from-mechanism.md +112 -0
  88. package/skills/_support/docs/principles/details/p078-single-source-of-truth.md +131 -0
  89. package/skills/_support/docs/principles/details/p079-explicit-ownership-and-lifetimes.md +124 -0
  90. package/skills/_support/docs/principles/details/p080-make-concurrency-deliberate.md +122 -0
  91. package/skills/_support/docs/principles/details/p081-forward-progress-with-safety.md +125 -0
  92. package/skills/_support/docs/principles/details/p082-design-for-cancellation.md +133 -0
  93. package/skills/_support/docs/principles/details/p083-irreversible-actions-last.md +124 -0
  94. package/skills/_support/docs/principles/details/p084-prefer-local-reasoning.md +131 -0
  95. package/skills/_support/docs/principles/details/p085-explicit-is-better-than-implicit.md +123 -0
  96. package/skills/_support/docs/principles/details/p086-readability-counts.md +113 -0
  97. package/skills/_support/docs/principles/details/p087-comments-explain-why-code-explains-what.md +120 -0
  98. package/skills/_support/docs/principles/details/p088-delete-dead-code.md +125 -0
  99. package/skills/_support/docs/principles/details/p089-delete-obsolete-configuration-and-dependencies.md +125 -0
  100. package/skills/_support/docs/principles/details/p090-prefer-negative-code.md +134 -0
  101. package/skills/_support/docs/principles/details/p091-test-driven-development.md +149 -0
  102. package/skills/_support/docs/review/README.md +63 -0
  103. package/skills/_support/docs/review/behavior-first-testing.md +78 -0
  104. package/skills/_support/docs/review/common.md +400 -0
  105. package/skills/_support/docs/review/design-docs.md +74 -0
  106. package/skills/_support/docs/review/issue-planning.md +247 -0
  107. package/skills/_support/docs/review/language-routing.md +141 -0
  108. package/skills/_support/docs/review/repository-scorecard.md +170 -0
  109. package/skills/advise/SKILL.md +108 -74
  110. package/skills/advise/scripts/list_retrievable_skills.py +20 -8
  111. package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
  112. package/skills/brainstorm/SKILL.md +163 -127
  113. package/skills/change-review/SKILL.md +91 -71
  114. package/skills/change-review/references/scope-resolution.md +69 -46
  115. package/skills/change-review/scripts/resolve_scope.py +144 -84
  116. package/skills/finalize-plan/SKILL.md +205 -139
  117. package/skills/git-worktrees/SKILL.md +100 -80
  118. package/skills/git-worktrees/scripts/prepare_worktree.py +34 -15
  119. package/skills/issue-review/SKILL.md +136 -79
  120. package/skills/learn/SKILL.md +359 -192
  121. package/skills/myrmidon-swarm/SKILL.md +126 -86
  122. package/skills/plan-issue/SKILL.md +122 -81
  123. package/skills/pr-review/SKILL.md +184 -116
  124. package/skills/pr-review/references/criteria.md +61 -37
  125. package/skills/pr-review/references/delivery.md +277 -101
  126. package/skills/pr-review/references/evidence.md +346 -149
  127. package/skills/pr-review/references/prevalidated.md +93 -61
  128. package/skills/pr-review/scripts/collect_evidence.py +312 -162
  129. package/skills/pr-review/scripts/deliver_go.py +701 -0
  130. package/skills/pr-review/scripts/diff_context.py +34 -15
  131. package/skills/pr-review/scripts/materialize_snapshot.py +141 -130
  132. package/skills/pr-review/scripts/pr_identity.py +27 -15
  133. package/skills/pr-review/scripts/resolve_pr.py +77 -41
  134. package/skills/realign/SKILL.md +516 -0
  135. package/skills/realign/references/aislop-integration.md +215 -0
  136. package/skills/realign/references/architecture-and-structure.md +271 -0
  137. package/skills/realign/references/control-flow-and-errors.md +344 -0
  138. package/skills/realign/references/tests-dependencies-and-security.md +261 -0
  139. package/skills/realign/scripts/resolve_assessment.py +1525 -0
  140. package/skills/repo-review/SKILL.md +202 -131
  141. package/skills/simplify/SKILL.md +174 -0
  142. package/skills/systematic-debugging/SKILL.md +212 -192
  143. package/skills/systematic-debugging/scripts/repository_evidence.py +26 -8
  144. package/skills/test-driven-development/SKILL.md +126 -96
  145. package/skills/tidy/SKILL.md +104 -59
  146. package/skills/tidy/scripts/run_tidy.py +54 -6
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """Deliver an exact pull-request GO decision through a bound forge adapter."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import importlib.util
8
+ import json
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from hashlib import sha256
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any, Protocol, cast
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+
18
+ from pr_identity import (
19
+ pull_request_number,
20
+ repository_from_pr_url,
21
+ require_canonical_pull_request_url,
22
+ require_commit_oid,
23
+ require_github_host,
24
+ require_github_repository,
25
+ )
26
+
27
+ if TYPE_CHECKING or __package__ not in {None, ""}:
28
+ from skills._cli import argument_parser, run_command
29
+ else:
30
+ _cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
31
+ _cli_spec = importlib.util.spec_from_file_location(
32
+ "athena_installed_cli", _cli_path
33
+ )
34
+ if _cli_spec is None or _cli_spec.loader is None:
35
+ raise RuntimeError(
36
+ f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
37
+ )
38
+ _cli = importlib.util.module_from_spec(_cli_spec)
39
+ _cli_spec.loader.exec_module(_cli)
40
+ argument_parser = _cli.argument_parser
41
+ run_command = _cli.run_command
42
+
43
+ GO_LABEL = "state:implementation-go"
44
+ NO_GO_LABEL = "state:implementation-no-go"
45
+ GITHUB_HOST = "github.com"
46
+
47
+
48
+ class DeliveryError(RuntimeError):
49
+ """A GO delivery precondition, write, or postcondition failed."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ReviewComment:
54
+ """One comment in one complete review-thread history."""
55
+
56
+ id: str
57
+ body: str
58
+ author: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ReviewThread:
63
+ """One review thread and its complete comment history."""
64
+
65
+ id: str
66
+ is_resolved: bool
67
+ comments: tuple[ReviewComment, ...]
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PullRequestSnapshot:
72
+ """The forge state required to bind one delivery operation."""
73
+
74
+ repository: str
75
+ number: int
76
+ url: str
77
+ state: str
78
+ is_draft: bool
79
+ base_oid: str
80
+ head_oid: str
81
+ labels: frozenset[str]
82
+ threads: tuple[ReviewThread, ...]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ReviewBinding:
87
+ """The immutable pull-request identity retained by the review."""
88
+
89
+ repository: str
90
+ number: int
91
+ url: str
92
+ base_oid: str
93
+ head_oid: str
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ThreadResponse:
98
+ """One precomputed response bound to one thread conversation digest."""
99
+
100
+ thread_id: str
101
+ conversation_sha256: str
102
+ body: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class DeliveryResult:
107
+ """The verified result of a GO delivery."""
108
+
109
+ status: str
110
+ resolved_thread_ids: tuple[str, ...]
111
+ label: str = GO_LABEL
112
+
113
+
114
+ class Forge(Protocol):
115
+ """The minimum bound forge capability used by the delivery state machine."""
116
+
117
+ def snapshot(self) -> PullRequestSnapshot:
118
+ """Read one complete pull-request snapshot."""
119
+
120
+ def reply(self, thread_id: str, body: str) -> None:
121
+ """Post one deterministic reply to one retained review thread."""
122
+
123
+ def resolve(self, thread_id: str) -> None:
124
+ """Resolve one retained review thread."""
125
+
126
+ def set_implementation_go(self) -> None:
127
+ """Apply the exclusive implementation GO state label."""
128
+
129
+
130
+ def conversation_sha256(thread: ReviewThread) -> str:
131
+ """Hash a complete conversation with a stable, length-delimited encoding."""
132
+ payload = {
133
+ "comments": [
134
+ {"author": comment.author, "body": comment.body, "id": comment.id}
135
+ for comment in thread.comments
136
+ ],
137
+ "id": thread.id,
138
+ "is_resolved": thread.is_resolved,
139
+ }
140
+ encoded = json.dumps(
141
+ payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
142
+ )
143
+ return sha256(encoded.encode("utf-8")).hexdigest()
144
+
145
+
146
+ def _same_binding(snapshot: PullRequestSnapshot, binding: ReviewBinding) -> bool:
147
+ return (
148
+ snapshot.repository.casefold() == binding.repository.casefold()
149
+ and snapshot.number == binding.number
150
+ and snapshot.url == binding.url
151
+ and snapshot.state == "OPEN"
152
+ and not snapshot.is_draft
153
+ and snapshot.base_oid == binding.base_oid
154
+ and snapshot.head_oid == binding.head_oid
155
+ )
156
+
157
+
158
+ def _require_binding(snapshot: PullRequestSnapshot, binding: ReviewBinding) -> None:
159
+ if not _same_binding(snapshot, binding):
160
+ raise DeliveryError(
161
+ "The pull-request identity changed or is not open; withhold GO delivery."
162
+ )
163
+
164
+
165
+ def _snapshot(forge: Forge, binding: ReviewBinding) -> PullRequestSnapshot:
166
+ try:
167
+ snapshot = forge.snapshot()
168
+ except Exception as error:
169
+ raise DeliveryError(f"The forge snapshot failed: {error}") from error
170
+ _require_binding(snapshot, binding)
171
+ return snapshot
172
+
173
+
174
+ def _response_map(
175
+ snapshot: PullRequestSnapshot, responses: Sequence[ThreadResponse]
176
+ ) -> dict[str, ThreadResponse]:
177
+ thread_ids = [thread.id for thread in snapshot.threads]
178
+ if len(thread_ids) != len(set(thread_ids)):
179
+ raise DeliveryError("The forge returned duplicate review-thread identifiers.")
180
+ unresolved = {
181
+ thread.id: thread for thread in snapshot.threads if not thread.is_resolved
182
+ }
183
+ by_id: dict[str, ThreadResponse] = {}
184
+ for response in responses:
185
+ if (
186
+ not isinstance(response.thread_id, str)
187
+ or not isinstance(response.conversation_sha256, str)
188
+ or not isinstance(response.body, str)
189
+ ):
190
+ raise DeliveryError("The response manifest contains an invalid response.")
191
+ if response.thread_id in by_id:
192
+ raise DeliveryError("The response manifest contains a duplicate thread.")
193
+ if response.thread_id not in unresolved:
194
+ raise DeliveryError(
195
+ "The response manifest does not match the current threads."
196
+ )
197
+ if not response.body.strip():
198
+ raise DeliveryError("The response manifest contains an empty response.")
199
+ if response.conversation_sha256 != conversation_sha256(
200
+ unresolved[response.thread_id]
201
+ ):
202
+ raise DeliveryError(
203
+ "The response manifest is not bound to the current conversation."
204
+ )
205
+ by_id[response.thread_id] = response
206
+ missing = sorted(set(unresolved).difference(by_id))
207
+ if missing:
208
+ raise DeliveryError(
209
+ "The response manifest does not cover every unresolved review thread."
210
+ )
211
+ return by_id
212
+
213
+
214
+ def _has_response(thread: ReviewThread, body: str) -> bool:
215
+ return any(comment.body == body for comment in thread.comments)
216
+
217
+
218
+ def _delivery_body(binding: ReviewBinding, response: ThreadResponse) -> str:
219
+ """Add an exact-target marker to one reviewer response."""
220
+ seed = json.dumps(
221
+ {
222
+ "body": response.body,
223
+ "conversation_sha256": response.conversation_sha256,
224
+ "head_oid": binding.head_oid,
225
+ "number": binding.number,
226
+ "repository": binding.repository,
227
+ "thread_id": response.thread_id,
228
+ },
229
+ ensure_ascii=False,
230
+ sort_keys=True,
231
+ separators=(",", ":"),
232
+ )
233
+ marker = sha256(seed.encode("utf-8")).hexdigest()
234
+ return f"{response.body.rstrip()}\n\n<!-- athena-pr-review-go-response:{marker} -->"
235
+
236
+
237
+ def _response_is_only_conversation_change(
238
+ before: ReviewThread, after: ReviewThread, body: str
239
+ ) -> bool:
240
+ """Return whether one exact response is the only new comment."""
241
+ return (
242
+ not after.is_resolved
243
+ and after.comments[:-1] == before.comments
244
+ and len(after.comments) == len(before.comments) + 1
245
+ and after.comments[-1].body == body
246
+ )
247
+
248
+
249
+ def _recovered_response_body(
250
+ thread: ReviewThread, binding: ReviewBinding
251
+ ) -> str | None:
252
+ """Return the body of one exact prior delivery response at the thread tip."""
253
+ if not thread.comments:
254
+ return None
255
+ delivered = thread.comments[-1].body
256
+ separator = "\n\n<!-- athena-pr-review-go-response:"
257
+ if separator not in delivered or not delivered.endswith(" -->"):
258
+ return None
259
+ body, _ = delivered.rsplit(separator, maxsplit=1)
260
+ prior = ReviewThread(thread.id, False, thread.comments[:-1])
261
+ response = ThreadResponse(thread.id, conversation_sha256(prior), body)
262
+ return body if _delivery_body(binding, response) == delivered else None
263
+
264
+
265
+ def _thread(snapshot: PullRequestSnapshot, thread_id: str) -> ReviewThread:
266
+ matches = [thread for thread in snapshot.threads if thread.id == thread_id]
267
+ if len(matches) != 1:
268
+ raise DeliveryError("The review thread set changed during GO delivery.")
269
+ return matches[0]
270
+
271
+
272
+ def _call_write(action: str, callback: Any, *arguments: str) -> None:
273
+ try:
274
+ callback(*arguments)
275
+ except Exception as error:
276
+ raise DeliveryError(
277
+ f"The forge {action} failed. The result is not retried or compensated: {error}"
278
+ ) from error
279
+
280
+
281
+ def deliver_go(
282
+ forge: Forge, binding: ReviewBinding, responses: Sequence[ThreadResponse]
283
+ ) -> DeliveryResult:
284
+ """Reply to and resolve every open thread, then apply and verify GO.
285
+
286
+ Every write is preceded by an exact-head read. A failed or indeterminate write
287
+ stops the operation. The caller must inspect the forge before any later action.
288
+ """
289
+ initial = _snapshot(forge, binding)
290
+ if GO_LABEL in initial.labels:
291
+ raise DeliveryError(
292
+ "A pre-existing GO label cannot prove this delivery. "
293
+ "Inspect the bound thread and label history before another action."
294
+ )
295
+
296
+ response_by_id = _response_map(initial, responses)
297
+ resolved: list[str] = []
298
+ for thread_id in sorted(response_by_id):
299
+ response = response_by_id[thread_id]
300
+ body = _delivery_body(binding, response)
301
+ current = _snapshot(forge, binding)
302
+ thread = _thread(current, thread_id)
303
+ if thread.is_resolved:
304
+ raise DeliveryError("The review thread changed before its response.")
305
+ if conversation_sha256(thread) != response.conversation_sha256:
306
+ raise DeliveryError("The review conversation changed before its response.")
307
+ recovered_body = _recovered_response_body(thread, binding)
308
+ if recovered_body is not None and recovered_body == response.body:
309
+ after_reply = thread
310
+ else:
311
+ if _has_response(thread, body):
312
+ raise DeliveryError(
313
+ "The response manifest contains an ambiguous prior response."
314
+ )
315
+ before_reply = thread
316
+ _call_write("reply", forge.reply, thread_id, body)
317
+ current = _snapshot(forge, binding)
318
+ after_reply = _thread(current, thread_id)
319
+ if not _response_is_only_conversation_change(
320
+ before_reply, after_reply, body
321
+ ):
322
+ raise DeliveryError(
323
+ "The forge did not verify the exact posted review response."
324
+ )
325
+ current = _snapshot(forge, binding)
326
+ before_resolution = _thread(current, thread_id)
327
+ if before_resolution != after_reply:
328
+ raise DeliveryError(
329
+ "The review conversation changed before thread resolution."
330
+ )
331
+ _call_write("resolve", forge.resolve, thread_id)
332
+ after_resolution_snapshot = _snapshot(forge, binding)
333
+ after_resolution = _thread(after_resolution_snapshot, thread_id)
334
+ if (
335
+ not after_resolution.is_resolved
336
+ or after_resolution.comments != before_resolution.comments
337
+ ):
338
+ raise DeliveryError(
339
+ "The forge did not verify exact review-thread resolution."
340
+ )
341
+ resolved.append(thread_id)
342
+
343
+ current = _snapshot(forge, binding)
344
+ if any(not thread.is_resolved for thread in current.threads):
345
+ raise DeliveryError(
346
+ "An unresolved review thread remains; withhold GO delivery."
347
+ )
348
+
349
+ _snapshot(forge, binding)
350
+ _call_write("implementation label", forge.set_implementation_go)
351
+ final = _snapshot(forge, binding)
352
+ if GO_LABEL not in final.labels:
353
+ raise DeliveryError("The implementation GO label was not verified.")
354
+ if NO_GO_LABEL in final.labels:
355
+ raise DeliveryError("The implementation state labels are not exclusive.")
356
+ if any(not thread.is_resolved for thread in final.threads):
357
+ raise DeliveryError("An unresolved review thread remains after label delivery.")
358
+ return DeliveryResult("delivered", tuple(resolved))
359
+
360
+
361
+ def _gh(*arguments: str) -> str:
362
+ result = run_command(
363
+ ("gh", *arguments), capture_output=True, text=True, check=False
364
+ )
365
+ if result.returncode != 0:
366
+ message = result.stderr.strip() or "The GitHub CLI command failed."
367
+ raise DeliveryError(message)
368
+ return result.stdout
369
+
370
+
371
+ def _json_object(output: str, description: str) -> dict[str, Any]:
372
+ try:
373
+ value = json.loads(output)
374
+ except json.JSONDecodeError as error:
375
+ raise DeliveryError(f"GitHub returned invalid {description}.") from error
376
+ if not isinstance(value, dict):
377
+ raise DeliveryError(f"GitHub returned invalid {description}.")
378
+ errors = value.get("errors")
379
+ if errors:
380
+ raise DeliveryError(f"GitHub returned errors for {description}: {errors}")
381
+ return value
382
+
383
+
384
+ class GitHubForge:
385
+ """GitHub GraphQL adapter bound to one explicit repository and pull request."""
386
+
387
+ def __init__(self, binding: ReviewBinding, host: str = GITHUB_HOST) -> None:
388
+ self.binding = binding
389
+ self.host = require_github_host(host, "target host")
390
+ self.owner, self.name = require_github_repository(
391
+ binding.repository, "target repository"
392
+ ).split("/", maxsplit=1)
393
+
394
+ def _graphql(self, query: str, **variables: object) -> dict[str, Any]:
395
+ arguments = [
396
+ "api",
397
+ "graphql",
398
+ "--hostname",
399
+ self.host,
400
+ "-f",
401
+ f"query={query}",
402
+ ]
403
+ for key, value in variables.items():
404
+ option = "-F" if isinstance(value, int) else "-f"
405
+ arguments.extend((option, f"{key}={value}"))
406
+ return _json_object(_gh(*arguments), "GraphQL response")
407
+
408
+ def snapshot(self) -> PullRequestSnapshot:
409
+ query = """
410
+ query($owner:String!, $name:String!, $number:Int!) {
411
+ repository(owner:$owner, name:$name) { pullRequest(number:$number) {
412
+ number url state isDraft baseRefOid headRefOid
413
+ labels(first:100) { pageInfo { hasNextPage } nodes { name } }
414
+ reviewThreads(first:100) { pageInfo { hasNextPage } nodes {
415
+ id isResolved comments(first:100) { pageInfo { hasNextPage } nodes {
416
+ id body author { login }
417
+ } }
418
+ } }
419
+ } }
420
+ }
421
+ """
422
+ data = self._graphql(
423
+ query, owner=self.owner, name=self.name, number=self.binding.number
424
+ )
425
+ pull_request = cast(dict[str, Any], data.get("data", {})).get("repository", {})
426
+ pull_request = cast(dict[str, Any], pull_request).get("pullRequest")
427
+ if not isinstance(pull_request, dict):
428
+ raise DeliveryError(
429
+ "GitHub returned no pull request for the retained target."
430
+ )
431
+ if (
432
+ pull_request.get("number") != self.binding.number
433
+ or pull_request.get("url") != self.binding.url
434
+ ):
435
+ raise DeliveryError(
436
+ "GitHub returned a pull request that differs from the retained target."
437
+ )
438
+ if pull_request.get("reviewThreads", {}).get("pageInfo", {}).get("hasNextPage"):
439
+ raise DeliveryError("GitHub review-thread coverage is incomplete.")
440
+ raw_threads = pull_request.get("reviewThreads", {}).get("nodes")
441
+ if not isinstance(raw_threads, list):
442
+ raise DeliveryError("GitHub returned invalid review-thread data.")
443
+ threads: list[ReviewThread] = []
444
+ for raw_thread in raw_threads:
445
+ if not isinstance(raw_thread, dict):
446
+ raise DeliveryError("GitHub returned invalid review-thread data.")
447
+ comments_data = raw_thread.get("comments")
448
+ if not isinstance(comments_data, dict) or comments_data.get(
449
+ "pageInfo", {}
450
+ ).get("hasNextPage"):
451
+ raise DeliveryError("GitHub review-comment coverage is incomplete.")
452
+ raw_comments = comments_data.get("nodes")
453
+ if not isinstance(raw_comments, list):
454
+ raise DeliveryError("GitHub returned invalid review-comment data.")
455
+ comments: list[ReviewComment] = []
456
+ for raw_comment in raw_comments:
457
+ if (
458
+ not isinstance(raw_comment, dict)
459
+ or not isinstance(raw_comment.get("id"), str)
460
+ or not isinstance(raw_comment.get("body"), str)
461
+ ):
462
+ raise DeliveryError("GitHub returned invalid review-comment data.")
463
+ author = raw_comment.get("author") or {}
464
+ comments.append(
465
+ ReviewComment(
466
+ raw_comment["id"],
467
+ raw_comment["body"],
468
+ str(author.get("login", "")),
469
+ )
470
+ )
471
+ thread_id = raw_thread.get("id")
472
+ if not isinstance(thread_id, str) or not isinstance(
473
+ raw_thread.get("isResolved"), bool
474
+ ):
475
+ raise DeliveryError("GitHub returned invalid review-thread data.")
476
+ threads.append(
477
+ ReviewThread(thread_id, raw_thread["isResolved"], tuple(comments))
478
+ )
479
+ raw_labels = pull_request.get("labels")
480
+ if not isinstance(raw_labels, dict) or raw_labels.get("pageInfo", {}).get(
481
+ "hasNextPage"
482
+ ):
483
+ raise DeliveryError("GitHub label coverage is incomplete.")
484
+ labels_data = raw_labels.get("nodes")
485
+ if not isinstance(labels_data, list) or any(
486
+ not isinstance(label, dict) or not isinstance(label.get("name"), str)
487
+ for label in labels_data
488
+ ):
489
+ raise DeliveryError("GitHub returned invalid label data.")
490
+ labels = frozenset(
491
+ str(label["name"])
492
+ for label in labels_data
493
+ if isinstance(label, dict) and isinstance(label.get("name"), str)
494
+ )
495
+ return PullRequestSnapshot(
496
+ repository=self.binding.repository,
497
+ number=self.binding.number,
498
+ url=self.binding.url,
499
+ state=str(pull_request.get("state")),
500
+ is_draft=bool(pull_request.get("isDraft")),
501
+ base_oid=require_commit_oid(pull_request.get("baseRefOid"), "baseRefOid"),
502
+ head_oid=require_commit_oid(pull_request.get("headRefOid"), "headRefOid"),
503
+ labels=labels,
504
+ threads=tuple(threads),
505
+ )
506
+
507
+ def reply(self, thread_id: str, body: str) -> None:
508
+ query = """
509
+ mutation($threadId:ID!, $body:String!) {
510
+ addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId, body:$body}) {
511
+ comment { id body }
512
+ }
513
+ }
514
+ """
515
+ self._graphql(query, threadId=thread_id, body=body)
516
+
517
+ def resolve(self, thread_id: str) -> None:
518
+ query = """
519
+ mutation($threadId:ID!) {
520
+ resolveReviewThread(input:{threadId:$threadId}) { thread { id isResolved } }
521
+ }
522
+ """
523
+ self._graphql(query, threadId=thread_id)
524
+
525
+ def set_implementation_go(self) -> None:
526
+ snapshot = self.snapshot()
527
+ arguments = [
528
+ "issue",
529
+ "edit",
530
+ str(self.binding.number),
531
+ "--repo",
532
+ f"{self.host}/{self.binding.repository}",
533
+ "--add-label",
534
+ GO_LABEL,
535
+ ]
536
+ if NO_GO_LABEL in snapshot.labels:
537
+ arguments.extend(("--remove-label", NO_GO_LABEL))
538
+ _gh(*arguments)
539
+
540
+
541
+ def _manifest_value(value: object, description: str) -> str:
542
+ if not isinstance(value, str) or not value:
543
+ raise DeliveryError(f"The response manifest contains an invalid {description}.")
544
+ return value
545
+
546
+
547
+ def load_response_manifest(
548
+ path: Path, binding: ReviewBinding
549
+ ) -> tuple[ThreadResponse, ...]:
550
+ """Load and validate a JSON response manifest for one exact binding."""
551
+ try:
552
+ document = json.loads(path.read_text(encoding="utf-8"))
553
+ except (OSError, json.JSONDecodeError) as error:
554
+ raise DeliveryError(f"The response manifest cannot be read: {error}") from error
555
+ if isinstance(document, dict):
556
+ raw_responses = document.get("responses")
557
+ raw_binding = document.get("binding")
558
+ if not isinstance(raw_binding, dict) or any(
559
+ raw_binding.get(key) != value
560
+ for key, value in {
561
+ "repository": binding.repository,
562
+ "number": binding.number,
563
+ "url": binding.url,
564
+ "base_oid": binding.base_oid,
565
+ "head_oid": binding.head_oid,
566
+ }.items()
567
+ ):
568
+ raise DeliveryError(
569
+ "The response manifest is not bound to the requested pull request."
570
+ )
571
+ else:
572
+ raw_responses = None
573
+ if not isinstance(raw_responses, list):
574
+ raise DeliveryError("The response manifest must contain a response list.")
575
+ responses: list[ThreadResponse] = []
576
+ for raw in raw_responses:
577
+ if not isinstance(raw, dict):
578
+ raise DeliveryError("The response manifest contains an invalid response.")
579
+ responses.append(
580
+ ThreadResponse(
581
+ _manifest_value(raw.get("thread_id"), "thread identifier"),
582
+ _manifest_value(raw.get("conversation_sha256"), "conversation digest"),
583
+ _manifest_value(raw.get("body"), "response body"),
584
+ )
585
+ )
586
+ return tuple(responses)
587
+
588
+
589
+ def prepare_response_manifest(
590
+ forge: Forge, binding: ReviewBinding
591
+ ) -> dict[str, object]:
592
+ """Return a read-only response template for every current open thread."""
593
+ snapshot = _snapshot(forge, binding)
594
+ responses: list[dict[str, object]] = []
595
+ for thread in snapshot.threads:
596
+ if thread.is_resolved:
597
+ continue
598
+ responses.append(
599
+ {
600
+ "body": _recovered_response_body(thread, binding) or "",
601
+ "comments": [
602
+ {
603
+ "author": comment.author,
604
+ "body": comment.body,
605
+ "id": comment.id,
606
+ }
607
+ for comment in thread.comments
608
+ ],
609
+ "conversation_sha256": conversation_sha256(thread),
610
+ "thread_id": thread.id,
611
+ }
612
+ )
613
+ return {
614
+ "binding": {
615
+ "base_oid": binding.base_oid,
616
+ "head_oid": binding.head_oid,
617
+ "number": binding.number,
618
+ "repository": binding.repository,
619
+ "url": binding.url,
620
+ },
621
+ "responses": responses,
622
+ }
623
+
624
+
625
+ def _binding_from_args(args: argparse.Namespace) -> ReviewBinding:
626
+ repository = require_github_repository(
627
+ args.target_repository, "--target-repository"
628
+ )
629
+ number = pull_request_number(args.pull_request)
630
+ if (
631
+ args.pull_request.startswith("https://")
632
+ and repository_from_pr_url(args.pull_request, number).casefold()
633
+ != repository.casefold()
634
+ ):
635
+ raise DeliveryError(
636
+ "The pull-request URL does not match the target repository."
637
+ )
638
+ url = require_canonical_pull_request_url(
639
+ args.expected_pr_url, repository, number, "--expected-pr-url"
640
+ )
641
+ if args.pull_request.startswith("https://") and args.pull_request != url:
642
+ raise DeliveryError("The pull-request URL does not match the expected URL.")
643
+ return ReviewBinding(
644
+ repository=repository,
645
+ number=number,
646
+ url=url,
647
+ base_oid=require_commit_oid(args.base_oid, "--expected-base-oid"),
648
+ head_oid=require_commit_oid(args.head_oid, "--expected-head-oid"),
649
+ )
650
+
651
+
652
+ def main(argv: Sequence[str] | None = None) -> int:
653
+ parser = argument_parser(description=__doc__)
654
+ parser.add_argument("--target-repository", required=True)
655
+ parser.add_argument("--target-host", default=GITHUB_HOST)
656
+ parser.add_argument("pull_request")
657
+ parser.add_argument("--expected-pr-url", required=True)
658
+ parser.add_argument(
659
+ "--base-oid", "--expected-base-oid", dest="base_oid", required=True
660
+ )
661
+ parser.add_argument(
662
+ "--head-oid", "--expected-head-oid", dest="head_oid", required=True
663
+ )
664
+ delivery_input = parser.add_mutually_exclusive_group(required=True)
665
+ delivery_input.add_argument(
666
+ "--response-manifest",
667
+ "--manifest",
668
+ "--responses-file",
669
+ dest="response_manifest",
670
+ type=Path,
671
+ )
672
+ delivery_input.add_argument("--prepare-manifest", action="store_true")
673
+ args = parser.parse_args(argv)
674
+ try:
675
+ binding = _binding_from_args(args)
676
+ forge = GitHubForge(binding, args.target_host)
677
+ if args.prepare_manifest:
678
+ print(json.dumps(prepare_response_manifest(forge, binding), sort_keys=True))
679
+ return 0
680
+ if args.response_manifest is None:
681
+ raise DeliveryError("The response manifest path is missing.")
682
+ responses = load_response_manifest(args.response_manifest, binding)
683
+ result = deliver_go(forge, binding, responses)
684
+ except (DeliveryError, RuntimeError, TypeError, ValueError) as error:
685
+ print(error, file=sys.stderr)
686
+ return 1
687
+ print(
688
+ json.dumps(
689
+ {
690
+ "label": result.label,
691
+ "resolved_thread_ids": result.resolved_thread_ids,
692
+ "status": result.status,
693
+ },
694
+ sort_keys=True,
695
+ )
696
+ )
697
+ return 0
698
+
699
+
700
+ if __name__ == "__main__":
701
+ raise SystemExit(main())