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.
Files changed (35) hide show
  1. package/README.md +43 -0
  2. package/adapters/cursor/agents/executor.md +1 -1
  3. package/adapters/cursor/agents/implementer.md +1 -1
  4. package/adapters/cursor/agents/reviewer.md +1 -0
  5. package/adapters/opencode/agents.json +3 -3
  6. package/adapters/opencode/plugin.js +131 -29
  7. package/config/models.json +379 -33
  8. package/hooks/session-start.py +27 -0
  9. package/package.json +18 -4
  10. package/roles/executor.md +1 -1
  11. package/roles/implementer.md +1 -1
  12. package/roles/reviewer.md +1 -0
  13. package/scripts/doctor.py +284 -0
  14. package/scripts/ghreview.py +554 -0
  15. package/scripts/memory.py +705 -0
  16. package/scripts/render_adapters.py +244 -97
  17. package/scripts/resolve_attach_target.py +357 -0
  18. package/scripts/setup.py +161 -0
  19. package/skills/delegation/SKILL.md +1 -1
  20. package/skills/doctor/SKILL.md +105 -0
  21. package/skills/freshness/SKILL.md +118 -0
  22. package/skills/memory/SKILL.md +144 -0
  23. package/skills/resolve-ticket/SKILL.md +269 -0
  24. package/skills/review-pr/SKILL.md +317 -0
  25. package/skills/setup/SKILL.md +85 -0
  26. package/skills/using-leo/SKILL.md +8 -1
  27. package/skills/using-leo/references/claude-mapping.md +22 -1
  28. package/skills/using-leo/references/codex-mapping.md +17 -7
  29. package/skills/using-leo/references/cursor-mapping.md +18 -6
  30. package/skills/using-leo/references/hermes-mapping.md +17 -7
  31. package/skills/using-leo/references/opencode-mapping.md +16 -8
  32. package/skills/verification/SKILL.md +7 -0
  33. package/skills/visual-verification/SKILL.md +114 -0
  34. package/skills/watch-review/SKILL.md +125 -0
  35. 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()