@homericintelligence/athena-opencode 0.5.1 → 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/package.json +1 -1
- package/skills/_cli.py +7 -4
- package/skills/_plugin.json +1 -0
- package/skills/_support/docs/dependency-resolution.md +49 -38
- package/skills/_support/docs/policies/development.md +16 -2
- package/skills/_support/docs/principles/README.md +191 -168
- package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +7 -5
- package/skills/_support/docs/review/README.md +5 -1
- package/skills/_support/docs/review/behavior-first-testing.md +5 -0
- package/skills/_support/docs/review/common.md +44 -9
- package/skills/_support/docs/review/issue-planning.md +36 -9
- package/skills/advise/SKILL.md +82 -74
- package/skills/advise/scripts/list_retrievable_skills.py +17 -5
- package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
- package/skills/brainstorm/SKILL.md +3 -0
- package/skills/change-review/scripts/resolve_scope.py +25 -11
- package/skills/finalize-plan/SKILL.md +10 -3
- package/skills/git-worktrees/SKILL.md +1 -1
- package/skills/git-worktrees/scripts/prepare_worktree.py +18 -5
- package/skills/learn/SKILL.md +136 -59
- package/skills/pr-review/SKILL.md +33 -15
- package/skills/pr-review/references/criteria.md +3 -0
- package/skills/pr-review/references/delivery.md +136 -18
- package/skills/pr-review/references/evidence.md +92 -12
- package/skills/pr-review/scripts/collect_evidence.py +101 -22
- package/skills/pr-review/scripts/deliver_go.py +701 -0
- package/skills/pr-review/scripts/diff_context.py +28 -11
- package/skills/pr-review/scripts/materialize_snapshot.py +29 -10
- package/skills/pr-review/scripts/resolve_pr.py +24 -10
- 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/simplify/SKILL.md +174 -0
- package/skills/systematic-debugging/SKILL.md +2 -0
- package/skills/systematic-debugging/scripts/repository_evidence.py +17 -4
- package/skills/tidy/SKILL.md +13 -1
- package/skills/tidy/scripts/run_tidy.py +51 -3
|
@@ -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())
|