leos-agent 6.1.0 → 6.3.0
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 +43 -0
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +1 -1
- package/adapters/cursor/agents/reviewer.md +1 -0
- package/adapters/opencode/agents.json +3 -3
- package/adapters/opencode/plugin.js +131 -29
- package/config/models.json +379 -33
- package/hooks/session-start.py +27 -0
- package/package.json +18 -4
- package/roles/executor.md +1 -1
- package/roles/implementer.md +1 -1
- package/roles/reviewer.md +1 -0
- package/scripts/doctor.py +284 -0
- package/scripts/ghreview.py +554 -0
- package/scripts/memory.py +705 -0
- package/scripts/render_adapters.py +244 -97
- package/scripts/resolve_attach_target.py +357 -0
- package/scripts/setup.py +161 -0
- package/skills/delegation/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +105 -0
- package/skills/freshness/SKILL.md +118 -0
- package/skills/memory/SKILL.md +144 -0
- package/skills/resolve-ticket/SKILL.md +269 -0
- package/skills/review-pr/SKILL.md +317 -0
- package/skills/setup/SKILL.md +85 -0
- package/skills/using-leo/SKILL.md +8 -1
- package/skills/using-leo/references/claude-mapping.md +22 -1
- package/skills/using-leo/references/codex-mapping.md +17 -7
- package/skills/using-leo/references/cursor-mapping.md +18 -6
- package/skills/using-leo/references/hermes-mapping.md +17 -7
- package/skills/using-leo/references/opencode-mapping.md +16 -8
- package/skills/verification/SKILL.md +7 -0
- package/skills/visual-verification/SKILL.md +114 -0
- package/skills/watch-review/SKILL.md +125 -0
- package/skills/writing-skills/SKILL.md +134 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ghreview: helpers for staging PENDING GitHub PR reviews via gh.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
map -R OWNER/REPO -n PR JSON: per-file addressable-line ranges + flags
|
|
6
|
+
extract -R OWNER/REPO -n PR [PATH ...] unified patches for the given files (all if none)
|
|
7
|
+
pending -R OWNER/REPO -n PR current user's PENDING review {id, node_id}, if any
|
|
8
|
+
clear-pending -R OWNER/REPO -n PR DELETE the current user's PENDING review, if any
|
|
9
|
+
threads -R OWNER/REPO -n PR [--all] JSON: unresolved review threads rooted by the
|
|
10
|
+
current user (--all includes resolved ones)
|
|
11
|
+
resolve-thread -R OWNER/REPO -n PR --thread-id PRRT_… [--dry-run]
|
|
12
|
+
mark a thread resolved (immediate, not staged;
|
|
13
|
+
needs PR authorship or write access)
|
|
14
|
+
reply -R OWNER/REPO -n PR --thread-id PRRT_… --body-file FILE [--dry-run]
|
|
15
|
+
STAGE a reply into the current user's pending
|
|
16
|
+
review (created as an empty shell if absent)
|
|
17
|
+
stage -R OWNER/REPO -n PR --commit SHA --input FILE [--replace-pending] [--dry-run]
|
|
18
|
+
validate comments against the diff, then create ONE pending review
|
|
19
|
+
(payload deliberately has NO "event" field -> review stays PENDING)
|
|
20
|
+
|
|
21
|
+
stage --input file: {"comments": [{"path", "line", "side", "body",
|
|
22
|
+
"start_line"?, "start_side"?}, ...]}
|
|
23
|
+
line = absolute line number in the new file for side RIGHT (old file for LEFT).
|
|
24
|
+
Off-diff lines are snapped to the nearest addressable line in the same hunk,
|
|
25
|
+
or dropped (reported on stderr) — one bad line would 422 the entire review.
|
|
26
|
+
|
|
27
|
+
Exit codes: 0 success; 1 API failure after retry; 2 usage/input error;
|
|
28
|
+
3 refused — a pending review being cleared (clear-pending, or stage
|
|
29
|
+
--replace-pending) contains comments not staged by this script; pass --force
|
|
30
|
+
to discard them anyway.
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
GENERATED_PATTERNS = [
|
|
39
|
+
r"(^|/)package-lock\.json$", r"(^|/)yarn\.lock$", r"(^|/)pnpm-lock\.yaml$",
|
|
40
|
+
r"(^|/)Cargo\.lock$", r"(^|/)Gemfile\.lock$", r"(^|/)poetry\.lock$",
|
|
41
|
+
r"(^|/)uv\.lock$", r"(^|/)go\.sum$", r"(^|/)composer\.lock$",
|
|
42
|
+
r"\.min\.(js|css)$", r"\.(map|snap)$", r"\.pb\.(go|py|rb|java)$", r"_pb2\.py$",
|
|
43
|
+
r"(^|/)(dist|build|vendor|node_modules|__snapshots__)/", r"\.generated\.",
|
|
44
|
+
]
|
|
45
|
+
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
46
|
+
SNAP_TOLERANCE = 3 # lines outside a hunk boundary still snapped into it
|
|
47
|
+
SNAP_MAX_DISTANCE = 10 # beyond this from the requested line, drop instead of snapping
|
|
48
|
+
|
|
49
|
+
MARKER = "<!-- leos-agent:review-pr -->"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _mark(body):
|
|
53
|
+
"""Tag a comment body as tool-created, so clear-pending can tell it apart
|
|
54
|
+
from anything Leo hand-drafted into the same pending review."""
|
|
55
|
+
body = (body or "").rstrip()
|
|
56
|
+
return body if MARKER in body else f"{body}\n\n{MARKER}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def gh(args, payload=None):
|
|
60
|
+
"""Run gh, return stdout. Raises CalledProcessError with stderr attached."""
|
|
61
|
+
proc = subprocess.run(
|
|
62
|
+
["gh"] + args,
|
|
63
|
+
input=payload,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
if proc.returncode != 0:
|
|
68
|
+
raise subprocess.CalledProcessError(proc.returncode, proc.args, proc.stdout, proc.stderr)
|
|
69
|
+
return proc.stdout
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_files(repo, pr):
|
|
73
|
+
"""List PR files as dicts. --paginate + --jq '.[]' yields NDJSON."""
|
|
74
|
+
out = gh(["api", f"repos/{repo}/pulls/{pr}/files", "--paginate", "--jq", ".[]"])
|
|
75
|
+
return [json.loads(line) for line in out.splitlines() if line.strip()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_generated(path):
|
|
79
|
+
return any(re.search(p, path) for p in GENERATED_PATTERNS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_patch(patch):
|
|
83
|
+
"""Walk unified-diff hunks -> addressable lines per side + hunk ranges.
|
|
84
|
+
|
|
85
|
+
RIGHT (new file) is addressable on added and context lines; LEFT (old
|
|
86
|
+
file) only on deleted lines — matching what the GitHub review UI accepts.
|
|
87
|
+
"""
|
|
88
|
+
right, left = set(), set()
|
|
89
|
+
hunks = [] # {"r": (start, end), "l": (start, end)}
|
|
90
|
+
old_ln = new_ln = 0
|
|
91
|
+
r_start = l_start = None
|
|
92
|
+
|
|
93
|
+
def close_hunk():
|
|
94
|
+
if r_start is not None:
|
|
95
|
+
hunks.append({"r": (r_start, new_ln - 1), "l": (l_start, old_ln - 1)})
|
|
96
|
+
|
|
97
|
+
for line in patch.splitlines():
|
|
98
|
+
m = HUNK_RE.match(line)
|
|
99
|
+
if m:
|
|
100
|
+
close_hunk()
|
|
101
|
+
old_ln, new_ln = int(m.group(1)), int(m.group(3))
|
|
102
|
+
r_start, l_start = new_ln, old_ln
|
|
103
|
+
elif line.startswith("+"):
|
|
104
|
+
right.add(new_ln)
|
|
105
|
+
new_ln += 1
|
|
106
|
+
elif line.startswith("-"):
|
|
107
|
+
left.add(old_ln)
|
|
108
|
+
old_ln += 1
|
|
109
|
+
elif line.startswith("\\"):
|
|
110
|
+
continue # ""
|
|
111
|
+
elif r_start is not None:
|
|
112
|
+
right.add(new_ln)
|
|
113
|
+
new_ln += 1
|
|
114
|
+
old_ln += 1
|
|
115
|
+
close_hunk()
|
|
116
|
+
return {"right": right, "left": left, "hunks": hunks}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_maps(files):
|
|
120
|
+
return {
|
|
121
|
+
f["filename"]: parse_patch(f["patch"]) if f.get("patch") else None
|
|
122
|
+
for f in files
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ranges(nums):
|
|
127
|
+
"""Compress a set of ints into [start, end] ranges for compact output."""
|
|
128
|
+
out = []
|
|
129
|
+
for n in sorted(nums):
|
|
130
|
+
if out and n == out[-1][1] + 1:
|
|
131
|
+
out[-1][1] = n
|
|
132
|
+
else:
|
|
133
|
+
out.append([n, n])
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def snap_line(diffmap, side, line):
|
|
138
|
+
"""Return an addressable line for (side, line), or None to drop.
|
|
139
|
+
|
|
140
|
+
Candidates are collected across ALL matching hunks (not just the first),
|
|
141
|
+
and the nearest one wins; if even the nearest is more than
|
|
142
|
+
SNAP_MAX_DISTANCE away from the requested line, drop rather than snap —
|
|
143
|
+
a distant snap silently attaches a comment to the wrong code.
|
|
144
|
+
"""
|
|
145
|
+
lines = diffmap["right" if side == "RIGHT" else "left"]
|
|
146
|
+
if line in lines:
|
|
147
|
+
return line
|
|
148
|
+
key = "r" if side == "RIGHT" else "l"
|
|
149
|
+
candidates = []
|
|
150
|
+
for hunk in diffmap["hunks"]:
|
|
151
|
+
start, end = hunk[key]
|
|
152
|
+
if start - SNAP_TOLERANCE <= line <= end + SNAP_TOLERANCE:
|
|
153
|
+
candidates += [n for n in lines if start <= n <= end]
|
|
154
|
+
if not candidates:
|
|
155
|
+
return None
|
|
156
|
+
best = min(candidates, key=lambda n: abs(n - line))
|
|
157
|
+
if abs(best - line) > SNAP_MAX_DISTANCE:
|
|
158
|
+
return None
|
|
159
|
+
return best
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_comments(comments, maps):
|
|
163
|
+
staged, snapped, dropped = [], [], []
|
|
164
|
+
for c in comments:
|
|
165
|
+
path, body = c.get("path"), c.get("body", "").strip()
|
|
166
|
+
side = c.get("side", "RIGHT")
|
|
167
|
+
line = c.get("line")
|
|
168
|
+
if not path or not body or not isinstance(line, int):
|
|
169
|
+
dropped.append({**c, "reason": "missing path/line/body"})
|
|
170
|
+
continue
|
|
171
|
+
diffmap = maps.get(path)
|
|
172
|
+
if diffmap is None:
|
|
173
|
+
dropped.append({**c, "reason": "file not in diff (or binary/no patch)"})
|
|
174
|
+
continue
|
|
175
|
+
new_line = snap_line(diffmap, side, line)
|
|
176
|
+
if new_line is None:
|
|
177
|
+
dropped.append({**c, "reason": f"line {line} ({side}) not addressable in any hunk"})
|
|
178
|
+
continue
|
|
179
|
+
entry = {"path": path, "line": new_line, "side": side, "body": _mark(body)}
|
|
180
|
+
# Multi-line ranges: keep only if the start anchors cleanly before the
|
|
181
|
+
# end on the same side; otherwise degrade to a single-line comment.
|
|
182
|
+
start = c.get("start_line")
|
|
183
|
+
if isinstance(start, int):
|
|
184
|
+
start_side = c.get("start_side", side)
|
|
185
|
+
snapped_start = snap_line(maps[path], start_side, start)
|
|
186
|
+
if snapped_start is not None and snapped_start < new_line and start_side == side:
|
|
187
|
+
entry["start_line"] = snapped_start
|
|
188
|
+
entry["start_side"] = start_side
|
|
189
|
+
if new_line != line:
|
|
190
|
+
snapped.append({"path": path, "from": line, "to": new_line})
|
|
191
|
+
staged.append(entry)
|
|
192
|
+
return staged, snapped, dropped
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def current_login():
|
|
196
|
+
return gh(["api", "user", "-q", ".login"]).strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def graphql(query, variables):
|
|
200
|
+
"""Run a GraphQL query/mutation via gh. Int/bool variables go through -F (typed)."""
|
|
201
|
+
args = ["api", "graphql", "-f", f"query={query}"]
|
|
202
|
+
for key, value in variables.items():
|
|
203
|
+
# bool before int: bool is a subclass of int, so this order matters.
|
|
204
|
+
if isinstance(value, bool):
|
|
205
|
+
args += ["-F", f"{key}={str(value).lower()}"]
|
|
206
|
+
elif isinstance(value, int):
|
|
207
|
+
args += ["-F", f"{key}={value}"]
|
|
208
|
+
else:
|
|
209
|
+
args += ["-f", f"{key}={value}"]
|
|
210
|
+
return json.loads(gh(args))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def pending_review(repo, pr):
|
|
214
|
+
"""The current user's PENDING review as {"id", "node_id"}, or None.
|
|
215
|
+
|
|
216
|
+
REST node_id is the GraphQL PullRequestReview id (verified identical) —
|
|
217
|
+
usable directly in mutations.
|
|
218
|
+
"""
|
|
219
|
+
out = gh(["api", f"repos/{repo}/pulls/{pr}/reviews", "--paginate", "--jq", ".[]"])
|
|
220
|
+
login = current_login()
|
|
221
|
+
for line in out.splitlines():
|
|
222
|
+
if not line.strip():
|
|
223
|
+
continue
|
|
224
|
+
review = json.loads(line)
|
|
225
|
+
if review.get("state") == "PENDING" and review.get("user", {}).get("login") == login:
|
|
226
|
+
return {"id": review["id"], "node_id": review["node_id"]}
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def review_comments(repo, pr, review_id):
|
|
231
|
+
"""All comments on a (pending) review, oldest first."""
|
|
232
|
+
out = gh(["api", f"repos/{repo}/pulls/{pr}/reviews/{review_id}/comments",
|
|
233
|
+
"--paginate", "--jq", ".[]"])
|
|
234
|
+
return [json.loads(l) for l in out.splitlines() if l.strip()]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def clear_pending_guarded(repo, pr, force):
|
|
238
|
+
"""Delete the current user's pending review, but only if every comment on
|
|
239
|
+
it carries MARKER (i.e. this script staged it) — otherwise refuse so we
|
|
240
|
+
never silently discard something Leo hand-drafted, unless --force.
|
|
241
|
+
|
|
242
|
+
Returns (result, refusal): exactly one is not None.
|
|
243
|
+
"""
|
|
244
|
+
review = pending_review(repo, pr)
|
|
245
|
+
if not review:
|
|
246
|
+
return {"deleted": None}, None
|
|
247
|
+
comments = review_comments(repo, pr, review["id"])
|
|
248
|
+
unmarked = [c for c in comments if MARKER not in (c.get("body") or "")]
|
|
249
|
+
if unmarked and not force:
|
|
250
|
+
return None, {
|
|
251
|
+
"refused": True,
|
|
252
|
+
"reason": "pending review contains comments not staged by review-pr",
|
|
253
|
+
"unmarked_count": len(unmarked),
|
|
254
|
+
"total_count": len(comments),
|
|
255
|
+
"samples": [((c.get("body") or "").strip().splitlines() or [""])[0][:120] for c in unmarked[:5]],
|
|
256
|
+
}
|
|
257
|
+
gh(["api", f"repos/{repo}/pulls/{pr}/reviews/{review['id']}", "--method", "DELETE"])
|
|
258
|
+
return {"deleted": review["id"], "forced": bool(unmarked)}, None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
THREADS_QUERY = """
|
|
262
|
+
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
|
|
263
|
+
repository(owner: $owner, name: $name) {
|
|
264
|
+
pullRequest(number: $number) {
|
|
265
|
+
reviewThreads(first: 50, after: $cursor) {
|
|
266
|
+
pageInfo { hasNextPage endCursor }
|
|
267
|
+
nodes {
|
|
268
|
+
id isResolved isOutdated path line
|
|
269
|
+
comments(first: 100) {
|
|
270
|
+
# 100 covers the overwhelming majority of threads without inner
|
|
271
|
+
# pagination; a thread with >100 comments truncates here, so
|
|
272
|
+
# comments[-1] / replies_after_mine may be stale for it — accepted
|
|
273
|
+
# tradeoff, full inner pagination isn't worth the complexity.
|
|
274
|
+
nodes {
|
|
275
|
+
id author { login } body createdAt
|
|
276
|
+
pullRequestReview { id state }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}"""
|
|
284
|
+
|
|
285
|
+
RESOLVE_MUTATION = """
|
|
286
|
+
mutation($thread: ID!) {
|
|
287
|
+
resolveReviewThread(input: {threadId: $thread}) { thread { id isResolved } }
|
|
288
|
+
}"""
|
|
289
|
+
|
|
290
|
+
REPLY_MUTATION = """
|
|
291
|
+
mutation($thread: ID!, $review: ID!, $body: String!) {
|
|
292
|
+
addPullRequestReviewThreadReply(
|
|
293
|
+
input: {pullRequestReviewThreadId: $thread, pullRequestReviewId: $review, body: $body}
|
|
294
|
+
) { comment { id } }
|
|
295
|
+
}"""
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def fetch_threads(repo, pr):
|
|
299
|
+
owner, name = repo.split("/", 1)
|
|
300
|
+
nodes, cursor = [], None
|
|
301
|
+
while True:
|
|
302
|
+
variables = {"owner": owner, "name": name, "number": int(pr)}
|
|
303
|
+
if cursor:
|
|
304
|
+
variables["cursor"] = cursor
|
|
305
|
+
conn = graphql(THREADS_QUERY, variables)["data"]["repository"]["pullRequest"]["reviewThreads"]
|
|
306
|
+
nodes += conn["nodes"]
|
|
307
|
+
if not conn["pageInfo"]["hasNextPage"]:
|
|
308
|
+
return nodes
|
|
309
|
+
cursor = conn["pageInfo"]["endCursor"]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def post_review(repo, pr, commit, staged):
|
|
313
|
+
payload = json.dumps({"commit_id": commit, "comments": staged}) # no "event" -> PENDING
|
|
314
|
+
out = gh(["api", f"repos/{repo}/pulls/{pr}/reviews", "--method", "POST", "--input", "-"], payload)
|
|
315
|
+
return json.loads(out)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cmd_map(a):
|
|
319
|
+
files = fetch_files(a.repo, a.pr)
|
|
320
|
+
report = []
|
|
321
|
+
for f in files:
|
|
322
|
+
diffmap = parse_patch(f["patch"]) if f.get("patch") else None
|
|
323
|
+
report.append({
|
|
324
|
+
"path": f["filename"],
|
|
325
|
+
"status": f["status"],
|
|
326
|
+
"additions": f["additions"],
|
|
327
|
+
"deletions": f["deletions"],
|
|
328
|
+
"generated": is_generated(f["filename"]),
|
|
329
|
+
"has_patch": diffmap is not None,
|
|
330
|
+
"right_ranges": ranges(diffmap["right"]) if diffmap else [],
|
|
331
|
+
"left_ranges": ranges(diffmap["left"]) if diffmap else [],
|
|
332
|
+
})
|
|
333
|
+
reviewable = [f for f in report if f["has_patch"] and not f["generated"]]
|
|
334
|
+
print(json.dumps({
|
|
335
|
+
"files": report,
|
|
336
|
+
"reviewable_files": len(reviewable),
|
|
337
|
+
"reviewable_lines": sum(f["additions"] + f["deletions"] for f in reviewable),
|
|
338
|
+
}, indent=1))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def cmd_extract(a):
|
|
342
|
+
wanted = set(a.paths)
|
|
343
|
+
for f in fetch_files(a.repo, a.pr):
|
|
344
|
+
if wanted and f["filename"] not in wanted:
|
|
345
|
+
continue
|
|
346
|
+
if f.get("patch"):
|
|
347
|
+
print(f"--- {f['filename']} ({f['status']}, +{f['additions']} -{f['deletions']})")
|
|
348
|
+
print(f["patch"])
|
|
349
|
+
print()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_pending(a):
|
|
353
|
+
review = pending_review(a.repo, a.pr)
|
|
354
|
+
if review:
|
|
355
|
+
print(json.dumps(review))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_clear_pending(a):
|
|
359
|
+
result, refusal = clear_pending_guarded(a.repo, a.pr, a.force)
|
|
360
|
+
if refusal:
|
|
361
|
+
print(json.dumps(refusal, indent=1))
|
|
362
|
+
sys.exit(3)
|
|
363
|
+
print(json.dumps(result))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_threads(a):
|
|
367
|
+
"""Unresolved threads whose root comment is the current user's.
|
|
368
|
+
|
|
369
|
+
Threads rooted in a PENDING review are excluded — those are staged
|
|
370
|
+
drafts, not posted conversation. line is null for file-level threads.
|
|
371
|
+
"""
|
|
372
|
+
login = current_login()
|
|
373
|
+
threads = []
|
|
374
|
+
for node in fetch_threads(a.repo, a.pr):
|
|
375
|
+
comments = node["comments"]["nodes"]
|
|
376
|
+
if not comments:
|
|
377
|
+
continue
|
|
378
|
+
root = comments[0]
|
|
379
|
+
if (root.get("author") or {}).get("login") != login:
|
|
380
|
+
continue
|
|
381
|
+
if (root.get("pullRequestReview") or {}).get("state") == "PENDING":
|
|
382
|
+
continue
|
|
383
|
+
if node["isResolved"] and not a.all:
|
|
384
|
+
continue
|
|
385
|
+
last = comments[-1]
|
|
386
|
+
threads.append({
|
|
387
|
+
"thread_id": node["id"],
|
|
388
|
+
"path": node["path"],
|
|
389
|
+
"line": node["line"],
|
|
390
|
+
"is_resolved": node["isResolved"],
|
|
391
|
+
"is_outdated": node["isOutdated"],
|
|
392
|
+
"replies_after_mine": (last.get("author") or {}).get("login") != login,
|
|
393
|
+
"comments": [{
|
|
394
|
+
"author": (c.get("author") or {}).get("login"),
|
|
395
|
+
"body": c["body"],
|
|
396
|
+
"created_at": c["createdAt"],
|
|
397
|
+
} for c in comments],
|
|
398
|
+
})
|
|
399
|
+
print(json.dumps({"my_login": login, "threads": threads}, indent=1))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def cmd_resolve_thread(a):
|
|
403
|
+
if a.dry_run:
|
|
404
|
+
print(json.dumps({"would_resolve": a.thread_id}))
|
|
405
|
+
return
|
|
406
|
+
try:
|
|
407
|
+
result = graphql(RESOLVE_MUTATION, {"thread": a.thread_id})
|
|
408
|
+
except subprocess.CalledProcessError as e:
|
|
409
|
+
# Resolving needs PR authorship or repo write access.
|
|
410
|
+
print(f"could not resolve thread (no write access to this repo?): {e.stderr.strip()}", file=sys.stderr)
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
print(json.dumps({"resolved": result["data"]["resolveReviewThread"]["thread"]}))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cmd_reply(a):
|
|
416
|
+
with open(a.body_file) as fh:
|
|
417
|
+
body = fh.read().strip()
|
|
418
|
+
if not body:
|
|
419
|
+
print("input error: empty reply body", file=sys.stderr)
|
|
420
|
+
sys.exit(2)
|
|
421
|
+
body = _mark(body)
|
|
422
|
+
review = pending_review(a.repo, a.pr)
|
|
423
|
+
if a.dry_run:
|
|
424
|
+
print(json.dumps({"would_reply_to": a.thread_id, "pending_review": review, "body": body}))
|
|
425
|
+
return
|
|
426
|
+
if review is None:
|
|
427
|
+
# Empty pending shell: POST with no event and no comments stays PENDING.
|
|
428
|
+
created = json.loads(gh(
|
|
429
|
+
["api", f"repos/{a.repo}/pulls/{a.pr}/reviews", "--method", "POST", "--input", "-"],
|
|
430
|
+
json.dumps({}),
|
|
431
|
+
))
|
|
432
|
+
review = {"id": created["id"], "node_id": created["node_id"]}
|
|
433
|
+
result = graphql(REPLY_MUTATION, {
|
|
434
|
+
"thread": a.thread_id, "review": review["node_id"], "body": body,
|
|
435
|
+
})
|
|
436
|
+
print(json.dumps({
|
|
437
|
+
"staged_reply": result["data"]["addPullRequestReviewThreadReply"]["comment"]["id"],
|
|
438
|
+
"thread": a.thread_id,
|
|
439
|
+
"pending_review": review["id"],
|
|
440
|
+
}))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def cmd_stage(a):
|
|
444
|
+
with open(a.input) as fh:
|
|
445
|
+
data = json.load(fh)
|
|
446
|
+
comments = data["comments"] if isinstance(data, dict) else data
|
|
447
|
+
if not comments:
|
|
448
|
+
print(json.dumps({"staged": 0, "note": "no comments provided; nothing created"}))
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
files = fetch_files(a.repo, a.pr)
|
|
452
|
+
staged, snapped, dropped = validate_comments(comments, build_maps(files))
|
|
453
|
+
report = {"staged": len(staged), "snapped": snapped, "dropped": dropped}
|
|
454
|
+
for d in dropped:
|
|
455
|
+
print(f"unstageable: {d.get('path')}:{d.get('line')} — {d['reason']}", file=sys.stderr)
|
|
456
|
+
|
|
457
|
+
if a.dry_run:
|
|
458
|
+
report["comments"] = staged
|
|
459
|
+
print(json.dumps(report, indent=1))
|
|
460
|
+
return
|
|
461
|
+
if not staged:
|
|
462
|
+
print(json.dumps({**report, "note": "all comments were unstageable; no review created"}))
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
if a.replace_pending:
|
|
466
|
+
# Refuse (and print the report) BEFORE posting anything new — never
|
|
467
|
+
# discard a pending review that isn't fully ours to begin with.
|
|
468
|
+
cleared, refusal = clear_pending_guarded(a.repo, a.pr, a.force)
|
|
469
|
+
if refusal:
|
|
470
|
+
print(json.dumps(refusal, indent=1))
|
|
471
|
+
sys.exit(3)
|
|
472
|
+
if cleared.get("deleted"):
|
|
473
|
+
report["deleted_pending"] = cleared["deleted"]
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
review = post_review(a.repo, a.pr, a.commit, staged)
|
|
477
|
+
except subprocess.CalledProcessError as e:
|
|
478
|
+
# Most likely a head moved under us or a line drifted: refresh and retry once.
|
|
479
|
+
print(f"first attempt failed, revalidating against current head: {e.stderr.strip()}", file=sys.stderr)
|
|
480
|
+
head = gh(["api", f"repos/{a.repo}/pulls/{a.pr}", "-q", ".head.sha"]).strip()
|
|
481
|
+
files = fetch_files(a.repo, a.pr)
|
|
482
|
+
# Revalidate the ORIGINAL comments (not `staged`, which already
|
|
483
|
+
# reflects the first pass's snapped output) so the reported snap is
|
|
484
|
+
# the real one-hop mapping, not a fictitious second hop.
|
|
485
|
+
staged, snapped2, dropped2 = validate_comments(comments, build_maps(files))
|
|
486
|
+
report["snapped"] += snapped2
|
|
487
|
+
report["dropped"] += dropped2
|
|
488
|
+
if not staged:
|
|
489
|
+
print(json.dumps({**report, "note": "nothing left to stage after revalidation"}))
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
try:
|
|
492
|
+
review = post_review(a.repo, a.pr, head, staged)
|
|
493
|
+
except subprocess.CalledProcessError as e2:
|
|
494
|
+
print(f"GitHub rejected the review again: {e2.stderr.strip()}", file=sys.stderr)
|
|
495
|
+
sys.exit(1)
|
|
496
|
+
|
|
497
|
+
report.update({"review_id": review["id"], "state": review["state"], "staged": len(staged)})
|
|
498
|
+
print(json.dumps(report, indent=1))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
COMMANDS = {
|
|
502
|
+
"map": cmd_map,
|
|
503
|
+
"extract": cmd_extract,
|
|
504
|
+
"pending": cmd_pending,
|
|
505
|
+
"clear-pending": cmd_clear_pending,
|
|
506
|
+
"threads": cmd_threads,
|
|
507
|
+
"resolve-thread": cmd_resolve_thread,
|
|
508
|
+
"reply": cmd_reply,
|
|
509
|
+
"stage": cmd_stage,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def main():
|
|
514
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
515
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
516
|
+
for name in COMMANDS:
|
|
517
|
+
sp = sub.add_parser(name)
|
|
518
|
+
sp.add_argument("-R", "--repo", required=True, help="OWNER/REPO of the PR's base repo")
|
|
519
|
+
sp.add_argument("-n", "--pr", required=True, type=int)
|
|
520
|
+
if name == "clear-pending":
|
|
521
|
+
sp.add_argument("--force", action="store_true",
|
|
522
|
+
help="delete even if it holds comments not staged by this script")
|
|
523
|
+
if name == "extract":
|
|
524
|
+
sp.add_argument("paths", nargs="*")
|
|
525
|
+
if name == "threads":
|
|
526
|
+
sp.add_argument("--all", action="store_true", help="include resolved threads")
|
|
527
|
+
if name == "resolve-thread":
|
|
528
|
+
sp.add_argument("--thread-id", required=True, help="PRRT_… thread node id")
|
|
529
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
530
|
+
if name == "reply":
|
|
531
|
+
sp.add_argument("--thread-id", required=True, help="PRRT_… thread node id")
|
|
532
|
+
sp.add_argument("--body-file", required=True, help="file holding the reply body")
|
|
533
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
534
|
+
if name == "stage":
|
|
535
|
+
sp.add_argument("--commit", required=True, help="head SHA (headRefOid) to anchor comments to")
|
|
536
|
+
sp.add_argument("--input", required=True, help="JSON file with the comments array")
|
|
537
|
+
sp.add_argument("--replace-pending", action="store_true")
|
|
538
|
+
sp.add_argument("--force", action="store_true",
|
|
539
|
+
help="with --replace-pending, delete even if it holds "
|
|
540
|
+
"comments not staged by this script")
|
|
541
|
+
sp.add_argument("--dry-run", action="store_true")
|
|
542
|
+
a = p.parse_args()
|
|
543
|
+
try:
|
|
544
|
+
COMMANDS[a.cmd](a)
|
|
545
|
+
except subprocess.CalledProcessError as e:
|
|
546
|
+
print(f"gh failed: {e.stderr.strip() if e.stderr else e}", file=sys.stderr)
|
|
547
|
+
sys.exit(1)
|
|
548
|
+
except (KeyError, ValueError, OSError) as e:
|
|
549
|
+
print(f"input error: {e}", file=sys.stderr)
|
|
550
|
+
sys.exit(2)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
main()
|