zithub 0.1.0__py3-none-any.whl
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.
- zithub/__init__.py +3 -0
- zithub/cli.py +643 -0
- zithub/gh.py +529 -0
- zithub/versioning.py +32 -0
- zithub-0.1.0.dist-info/METADATA +124 -0
- zithub-0.1.0.dist-info/RECORD +8 -0
- zithub-0.1.0.dist-info/WHEEL +4 -0
- zithub-0.1.0.dist-info/entry_points.txt +3 -0
zithub/__init__.py
ADDED
zithub/cli.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""zh: batched `gh` PR/release actions for AI agents — PR merge preflight,
|
|
2
|
+
review-thread reply/resolve, and release preflight/create, each as one call
|
|
3
|
+
instead of several chained `gh`/API calls. Actions `gh` already does in a
|
|
4
|
+
single call (create, close, comment, review, label, reviewer) are left to
|
|
5
|
+
`gh` itself."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from . import gh, versioning
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _color(text: str, code: str) -> str:
|
|
17
|
+
if not sys.stdout.isatty():
|
|
18
|
+
return text
|
|
19
|
+
return f"\033[{code}m{text}\033[0m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _green(text: str) -> str:
|
|
23
|
+
return _color(text, "32")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _red(text: str) -> str:
|
|
27
|
+
return _color(text, "31")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _yellow(text: str) -> str:
|
|
31
|
+
return _color(text, "33")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dim(text: str) -> str:
|
|
35
|
+
return _color(text, "2")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _add_ref_arg(p: argparse.ArgumentParser) -> None:
|
|
39
|
+
p.add_argument(
|
|
40
|
+
"ref", nargs="?", help="PR number, URL, or branch (default: current branch's PR)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _gh_ref_args(args: argparse.Namespace) -> list[str]:
|
|
45
|
+
return [args.ref] if args.ref else []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# review threads — gh has no subcommand for these at all
|
|
50
|
+
|
|
51
|
+
def _print_thread(t: gh.ReviewThread) -> None:
|
|
52
|
+
status = _dim("(resolved)") if t.is_resolved else _red("(unresolved)")
|
|
53
|
+
loc = f"{t.path}:{t.line}" if t.path else ""
|
|
54
|
+
print(f"{t.id} {loc} {status}")
|
|
55
|
+
for c in t.comments:
|
|
56
|
+
print(f" {c.author} {_dim(c.created_at)}")
|
|
57
|
+
for line in c.body.splitlines():
|
|
58
|
+
print(f" {line}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_threads(args: argparse.Namespace) -> int:
|
|
62
|
+
try:
|
|
63
|
+
repo = gh.repo_info()
|
|
64
|
+
pr = gh.resolve_pr(args.ref)
|
|
65
|
+
threads = gh.review_threads(repo.owner, repo.name, pr.number)
|
|
66
|
+
except gh.ZithubError as exc:
|
|
67
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
shown = threads if args.all else [t for t in threads if not t.is_resolved]
|
|
71
|
+
if args.path:
|
|
72
|
+
shown = [t for t in shown if t.path and args.path in t.path]
|
|
73
|
+
|
|
74
|
+
if not shown:
|
|
75
|
+
print("no matching review threads")
|
|
76
|
+
return 0
|
|
77
|
+
for t in shown:
|
|
78
|
+
_print_thread(t)
|
|
79
|
+
print()
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_reply(args: argparse.Namespace) -> int:
|
|
84
|
+
try:
|
|
85
|
+
url = gh.reply_to_thread(args.thread_id, args.body)
|
|
86
|
+
if args.resolve:
|
|
87
|
+
gh.resolve_thread(args.thread_id)
|
|
88
|
+
except gh.ZithubError as exc:
|
|
89
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
print(_green(f"replied: {url}"))
|
|
92
|
+
if args.resolve:
|
|
93
|
+
print(_green("resolved"))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_resolve(args: argparse.Namespace) -> int:
|
|
98
|
+
try:
|
|
99
|
+
ids = list(args.thread_id)
|
|
100
|
+
if args.all:
|
|
101
|
+
repo = gh.repo_info()
|
|
102
|
+
pr = gh.resolve_pr(args.ref)
|
|
103
|
+
threads = gh.review_threads(repo.owner, repo.name, pr.number)
|
|
104
|
+
for t in threads:
|
|
105
|
+
if t.is_resolved:
|
|
106
|
+
continue
|
|
107
|
+
if args.path and not (t.path and args.path in t.path):
|
|
108
|
+
continue
|
|
109
|
+
ids.append(t.id)
|
|
110
|
+
if not ids:
|
|
111
|
+
print("no threads to resolve", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
for thread_id in ids:
|
|
114
|
+
ok = gh.resolve_thread(thread_id)
|
|
115
|
+
print(f"{_green('resolved') if ok else _red('failed')} {thread_id}")
|
|
116
|
+
except gh.ZithubError as exc:
|
|
117
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_unresolve(args: argparse.Namespace) -> int:
|
|
123
|
+
try:
|
|
124
|
+
for thread_id in args.thread_id:
|
|
125
|
+
ok = gh.unresolve_thread(thread_id)
|
|
126
|
+
print(f"{_green('unresolved') if ok else _red('failed')} {thread_id}")
|
|
127
|
+
except gh.ZithubError as exc:
|
|
128
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# merge / ship — the real bundling: preflight checks (draft, review decision,
|
|
135
|
+
# unresolved threads, CI) folded into the merge call instead of an agent
|
|
136
|
+
# chaining `gh pr view` + `gh pr checks` + review-thread queries + `gh pr
|
|
137
|
+
# merge` by hand
|
|
138
|
+
|
|
139
|
+
_MERGE_POLL_INTERVAL_SECONDS = 15
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _wait_for_checks(ref: str | None, timeout: int) -> gh.PullRequest:
|
|
143
|
+
"""Poll the PR until no check is still pending, or `timeout` elapses."""
|
|
144
|
+
deadline = time.monotonic() + timeout
|
|
145
|
+
pr = gh.resolve_pr(ref)
|
|
146
|
+
while any(gh.is_pending(c) for c in pr.checks) and time.monotonic() < deadline:
|
|
147
|
+
pending = [c.name for c in pr.checks if gh.is_pending(c)]
|
|
148
|
+
print(_dim(f" waiting on CI: {', '.join(pending)}"))
|
|
149
|
+
time.sleep(_MERGE_POLL_INTERVAL_SECONDS)
|
|
150
|
+
pr = gh.resolve_pr(ref)
|
|
151
|
+
return pr
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_merge(args: argparse.Namespace) -> int:
|
|
155
|
+
try:
|
|
156
|
+
pr = gh.resolve_pr(args.ref)
|
|
157
|
+
except gh.ZithubError as exc:
|
|
158
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
print(f"pr #{pr.number} {pr.title}")
|
|
162
|
+
print(f" {pr.url}")
|
|
163
|
+
|
|
164
|
+
if not args.force:
|
|
165
|
+
if pr.is_draft:
|
|
166
|
+
print("error: PR is a draft — mark ready or pass --force", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
if pr.review_decision == "CHANGES_REQUESTED":
|
|
169
|
+
print("error: changes requested — address review or pass --force", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
repo = gh.repo_info()
|
|
174
|
+
threads = gh.review_threads(repo.owner, repo.name, pr.number)
|
|
175
|
+
except gh.ZithubError:
|
|
176
|
+
threads = []
|
|
177
|
+
unresolved = [t for t in threads if not t.is_resolved]
|
|
178
|
+
if unresolved:
|
|
179
|
+
print(
|
|
180
|
+
f"error: {len(unresolved)} unresolved review thread(s) — "
|
|
181
|
+
"resolve them, or pass --force",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
if args.wait:
|
|
187
|
+
pr = _wait_for_checks(args.ref, args.timeout)
|
|
188
|
+
failed = [c for c in pr.checks if gh.is_failed(c)]
|
|
189
|
+
pending = [c for c in pr.checks if gh.is_pending(c)]
|
|
190
|
+
if failed:
|
|
191
|
+
names = ", ".join(c.name for c in failed)
|
|
192
|
+
print(f"error: failing check(s): {names}", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
if pending:
|
|
195
|
+
names = ", ".join(c.name for c in pending)
|
|
196
|
+
print(f"error: still pending: {names} (rerun with --wait, or pass --force)", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
cmd = ["gh", "pr", "merge", *_gh_ref_args(args), f"--{args.method}"]
|
|
200
|
+
if args.delete_branch:
|
|
201
|
+
cmd.append("--delete-branch")
|
|
202
|
+
if args.admin:
|
|
203
|
+
cmd.append("--admin")
|
|
204
|
+
if args.auto:
|
|
205
|
+
cmd.append("--auto")
|
|
206
|
+
if args.subject:
|
|
207
|
+
cmd += ["--subject", args.subject]
|
|
208
|
+
if args.body:
|
|
209
|
+
cmd += ["--body", args.body]
|
|
210
|
+
return gh.run_passthrough(cmd)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _add_merge_args(p: argparse.ArgumentParser, default_delete_branch: bool) -> None:
|
|
214
|
+
_add_ref_arg(p)
|
|
215
|
+
p.add_argument(
|
|
216
|
+
"--method",
|
|
217
|
+
choices=["squash", "merge", "rebase"],
|
|
218
|
+
default="squash",
|
|
219
|
+
help="merge strategy (default: squash)",
|
|
220
|
+
)
|
|
221
|
+
p.add_argument(
|
|
222
|
+
"--delete-branch",
|
|
223
|
+
dest="delete_branch",
|
|
224
|
+
action="store_true",
|
|
225
|
+
default=default_delete_branch,
|
|
226
|
+
help="delete the branch after merging",
|
|
227
|
+
)
|
|
228
|
+
p.add_argument(
|
|
229
|
+
"--keep-branch",
|
|
230
|
+
dest="delete_branch",
|
|
231
|
+
action="store_false",
|
|
232
|
+
help="keep the branch after merging",
|
|
233
|
+
)
|
|
234
|
+
p.add_argument("--admin", action="store_true", help="bypass branch protection as an admin")
|
|
235
|
+
p.add_argument("--auto", action="store_true", help="enable auto-merge instead of merging now")
|
|
236
|
+
p.add_argument("--subject", "-t", help="merge commit subject")
|
|
237
|
+
p.add_argument("--body", "-b", help="merge commit body")
|
|
238
|
+
p.add_argument(
|
|
239
|
+
"--force",
|
|
240
|
+
action="store_true",
|
|
241
|
+
help="skip the draft/review/unresolved-thread/CI preflight and merge immediately",
|
|
242
|
+
)
|
|
243
|
+
p.add_argument(
|
|
244
|
+
"--no-wait",
|
|
245
|
+
dest="wait",
|
|
246
|
+
action="store_false",
|
|
247
|
+
default=True,
|
|
248
|
+
help="don't poll pending checks to completion — fail immediately if any are still running",
|
|
249
|
+
)
|
|
250
|
+
p.add_argument(
|
|
251
|
+
"--timeout",
|
|
252
|
+
type=int,
|
|
253
|
+
default=1200,
|
|
254
|
+
help="max seconds to wait on pending checks (default: 1200)",
|
|
255
|
+
)
|
|
256
|
+
p.set_defaults(func=cmd_merge)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# release preflight / create — ports the github-release skill's preflight.py
|
|
261
|
+
# (gh auth, target branch, remote sync, CI) so `release create` can gate on
|
|
262
|
+
# it directly: one call that checks readiness and creates the release,
|
|
263
|
+
# instead of a separate preflight script plus a hand-typed `gh release
|
|
264
|
+
# create`.
|
|
265
|
+
|
|
266
|
+
_CI_QUICK_ATTEMPTS = 5
|
|
267
|
+
_CI_QUICK_INTERVAL_SECONDS = 1
|
|
268
|
+
_CI_POLL_INTERVAL_SECONDS = 20
|
|
269
|
+
_CI_MAX_ATTEMPTS = 30
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _print_run_failures(runs: list[gh.WorkflowRun]) -> None:
|
|
273
|
+
for r in runs:
|
|
274
|
+
print(f" {_red(r.name)}={r.conclusion} {r.url}")
|
|
275
|
+
tail = gh.run_failure_log(r.database_id)
|
|
276
|
+
if tail:
|
|
277
|
+
print(_dim(f" ── {r.name} (log tail) ──"))
|
|
278
|
+
for line in tail.splitlines():
|
|
279
|
+
print(f" {_dim('|')} {line}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _wait_for_release_ci(sha: str, branch: str) -> bool:
|
|
283
|
+
"""True if CI passed (or no CI exists at all — proceed on judgement),
|
|
284
|
+
False if it failed or never completed. Mirrors the github-release
|
|
285
|
+
skill's preflight polling: a short grace period for the run to appear,
|
|
286
|
+
then a longer poll while anything is still in progress."""
|
|
287
|
+
runs: list[gh.WorkflowRun] = []
|
|
288
|
+
for attempt in range(_CI_MAX_ATTEMPTS):
|
|
289
|
+
runs = gh.runs_for_commit(sha)
|
|
290
|
+
if not runs:
|
|
291
|
+
if attempt >= _CI_QUICK_ATTEMPTS:
|
|
292
|
+
break
|
|
293
|
+
time.sleep(_CI_QUICK_INTERVAL_SECONDS)
|
|
294
|
+
continue
|
|
295
|
+
pending = [r for r in runs if r.status != "completed"]
|
|
296
|
+
if not pending:
|
|
297
|
+
break
|
|
298
|
+
names = ", ".join(f"{r.name}={r.status}" for r in runs)
|
|
299
|
+
print(_dim(f" waiting on CI: {names}"))
|
|
300
|
+
time.sleep(_CI_POLL_INTERVAL_SECONDS)
|
|
301
|
+
|
|
302
|
+
if not runs:
|
|
303
|
+
latest = gh.latest_runs_for_branch(branch, limit=5)
|
|
304
|
+
failed = [r for r in latest if r.status == "completed" and r.conclusion != "success"]
|
|
305
|
+
if failed:
|
|
306
|
+
print(f"ci {_red('no run for this commit; latest ' + branch + ' runs failed')}")
|
|
307
|
+
_print_run_failures(failed)
|
|
308
|
+
return False
|
|
309
|
+
print(_dim("ci no run found for this commit — proceed with judgement"))
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
pending = [r for r in runs if r.status != "completed"]
|
|
313
|
+
if pending:
|
|
314
|
+
names = ", ".join(f"{r.name}={r.status}" for r in pending)
|
|
315
|
+
print(f"error: CI did not complete on {sha[:12]}: {names}", file=sys.stderr)
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
failed = [r for r in runs if r.conclusion != "success"]
|
|
319
|
+
if failed:
|
|
320
|
+
print(f"ci {_red('failed')}", file=sys.stderr)
|
|
321
|
+
_print_run_failures(failed)
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
names = ", ".join(r.name for r in runs)
|
|
325
|
+
print(f"ci {_green('success')} ({names})")
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _release_preflight(target_arg: str | None) -> tuple[str, str, list[gh.ReleaseInfo]] | None:
|
|
330
|
+
"""Runs every preflight check, printing progress as it goes. Returns
|
|
331
|
+
(target_branch, head_sha, recent_releases) on pass, None on failure (the
|
|
332
|
+
specific check that failed has already printed its own error)."""
|
|
333
|
+
try:
|
|
334
|
+
branch = gh.current_branch()
|
|
335
|
+
except gh.ZithubError as exc:
|
|
336
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
account = gh.ensure_gh_account_for_repo()
|
|
341
|
+
print(f"auth {account}")
|
|
342
|
+
except gh.ZithubError as exc:
|
|
343
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
repo = gh.repo_info()
|
|
348
|
+
except gh.ZithubError as exc:
|
|
349
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
target = target_arg or repo.default_branch
|
|
353
|
+
if branch != target:
|
|
354
|
+
override = "" if target_arg else f" or rerun with `--target {branch}`"
|
|
355
|
+
print(
|
|
356
|
+
f"error: current branch is '{branch}', but release target is "
|
|
357
|
+
f"'{target}'; check out '{target}'{override}",
|
|
358
|
+
file=sys.stderr,
|
|
359
|
+
)
|
|
360
|
+
return None
|
|
361
|
+
print(f"branch {branch} (release target)")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
releases = gh.recent_releases()
|
|
365
|
+
except gh.ZithubError:
|
|
366
|
+
releases = []
|
|
367
|
+
if releases:
|
|
368
|
+
print("releases")
|
|
369
|
+
for r in releases:
|
|
370
|
+
kind = " (draft)" if r.is_draft else " (prerelease)" if r.is_prerelease else ""
|
|
371
|
+
print(f" {r.tag_name}{kind} {r.published_at}")
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
dirty = gh.dirty_files()
|
|
375
|
+
except gh.ZithubError:
|
|
376
|
+
dirty = []
|
|
377
|
+
if dirty:
|
|
378
|
+
print(_yellow("local dirty — do not release until everything is committed:"))
|
|
379
|
+
for line in dirty:
|
|
380
|
+
print(f" {line}")
|
|
381
|
+
else:
|
|
382
|
+
print(f"local {_green('clean')}")
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
gh.fetch_all()
|
|
386
|
+
sha = gh.current_commit_sha()
|
|
387
|
+
remote_sha = gh.remote_branch_sha(target)
|
|
388
|
+
except gh.ZithubError as exc:
|
|
389
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
390
|
+
return None
|
|
391
|
+
if remote_sha is None:
|
|
392
|
+
print(
|
|
393
|
+
f"error: origin/{target} not found — push with `git push -u origin {target}` first",
|
|
394
|
+
file=sys.stderr,
|
|
395
|
+
)
|
|
396
|
+
return None
|
|
397
|
+
if sha != remote_sha:
|
|
398
|
+
print(
|
|
399
|
+
f"error: local HEAD ({sha[:12]}) != origin/{target} ({remote_sha[:12]}) — "
|
|
400
|
+
f"push with `git push origin {target}` or reconcile, then retry",
|
|
401
|
+
file=sys.stderr,
|
|
402
|
+
)
|
|
403
|
+
return None
|
|
404
|
+
print(f"sync ok ({sha[:12]})")
|
|
405
|
+
|
|
406
|
+
if not _wait_for_release_ci(sha, target):
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
return target, sha, releases
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _print_commits(previous_tag: str | None) -> None:
|
|
413
|
+
try:
|
|
414
|
+
commits = gh.commits_since(previous_tag)
|
|
415
|
+
except gh.ZithubError:
|
|
416
|
+
commits = []
|
|
417
|
+
if not commits:
|
|
418
|
+
return
|
|
419
|
+
label = f"commits since {previous_tag}" if previous_tag else "commits (first release)"
|
|
420
|
+
print(f"\n{label}:")
|
|
421
|
+
for c in commits:
|
|
422
|
+
print(f" {c}")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _gh_release_create_hint(tag: str, target_arg: str | None) -> str:
|
|
426
|
+
"""The literal `gh release create` command to run once notes are
|
|
427
|
+
written. Points at plain `gh`, not `zh release create` — the preflight
|
|
428
|
+
that command would redo (including the CI wait) was just run right
|
|
429
|
+
here, so re-running it a moment later just to create is wasted work."""
|
|
430
|
+
target_flag = f" --target {target_arg}" if target_arg else ""
|
|
431
|
+
return f'gh release create {tag} --notes "..." --title {tag}{target_flag}'
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_release(args: argparse.Namespace) -> int:
|
|
435
|
+
"""Bare `zh release`: full preflight, then — instead of just pass/fail —
|
|
436
|
+
the commit log a release's notes get written from, plus the exact next
|
|
437
|
+
command (with the version already computed) for each bump size."""
|
|
438
|
+
result = _release_preflight(args.target)
|
|
439
|
+
if result is None:
|
|
440
|
+
return 1
|
|
441
|
+
target, _sha, releases = result
|
|
442
|
+
print(_green("PREFLIGHT PASS"))
|
|
443
|
+
|
|
444
|
+
previous_tag = releases[0].tag_name if releases else None
|
|
445
|
+
_print_commits(previous_tag)
|
|
446
|
+
print()
|
|
447
|
+
|
|
448
|
+
if previous_tag is None:
|
|
449
|
+
print("next no previous release — write notes, pick a starting version, then:")
|
|
450
|
+
print(f" {_gh_release_create_hint('<version>', args.target)}")
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
print("next write release notes from the commits above, then:")
|
|
454
|
+
for part in ("patch", "minor", "major"):
|
|
455
|
+
try:
|
|
456
|
+
candidate = versioning.bump(previous_tag, part)
|
|
457
|
+
except versioning.VersionError:
|
|
458
|
+
print(
|
|
459
|
+
f" can't auto-bump '{previous_tag}' (not a plain vX.Y.Z tag) — "
|
|
460
|
+
"pick the next version yourself"
|
|
461
|
+
)
|
|
462
|
+
break
|
|
463
|
+
print(f" {_gh_release_create_hint(candidate, args.target)} ({part})")
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _finish_release(tag: str, target: str | None, args: argparse.Namespace) -> int:
|
|
468
|
+
notes = args.notes
|
|
469
|
+
if args.notes_file:
|
|
470
|
+
with open(args.notes_file, encoding="utf-8") as f:
|
|
471
|
+
notes = f.read()
|
|
472
|
+
if not notes:
|
|
473
|
+
print("error: pass --notes or --notes-file", file=sys.stderr)
|
|
474
|
+
return 1
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
url = gh.create_release(
|
|
478
|
+
tag=tag,
|
|
479
|
+
notes=notes,
|
|
480
|
+
title=args.title or tag,
|
|
481
|
+
target=target,
|
|
482
|
+
draft=args.draft,
|
|
483
|
+
prerelease=args.prerelease,
|
|
484
|
+
)
|
|
485
|
+
except gh.ZithubError as exc:
|
|
486
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
487
|
+
return 1
|
|
488
|
+
print(_green(f"released: {url}"))
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def cmd_release_create(args: argparse.Namespace) -> int:
|
|
493
|
+
target = args.target
|
|
494
|
+
if not args.force:
|
|
495
|
+
result = _release_preflight(args.target)
|
|
496
|
+
if result is None:
|
|
497
|
+
return 1
|
|
498
|
+
target, _sha, _releases = result
|
|
499
|
+
return _finish_release(args.tag, target, args)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_release_bump(args: argparse.Namespace) -> int:
|
|
503
|
+
"""`zh release patch/minor/major`: preflight + compute the next version
|
|
504
|
+
from the latest release tag, then print it and the exact `release
|
|
505
|
+
create` command to run. Never creates the release itself — bumping the
|
|
506
|
+
version and choosing to release it are kept as two explicit steps, so
|
|
507
|
+
nothing gets published just because a version number was requested."""
|
|
508
|
+
target = args.target
|
|
509
|
+
releases: list[gh.ReleaseInfo] = []
|
|
510
|
+
if not args.force:
|
|
511
|
+
result = _release_preflight(args.target)
|
|
512
|
+
if result is None:
|
|
513
|
+
return 1
|
|
514
|
+
target, _sha, releases = result
|
|
515
|
+
else:
|
|
516
|
+
try:
|
|
517
|
+
releases = gh.recent_releases(limit=1)
|
|
518
|
+
except gh.ZithubError:
|
|
519
|
+
releases = []
|
|
520
|
+
|
|
521
|
+
if not releases:
|
|
522
|
+
print(
|
|
523
|
+
"error: no previous release to bump from — "
|
|
524
|
+
'use `zh release create <version> --notes "..."` for the first release',
|
|
525
|
+
file=sys.stderr,
|
|
526
|
+
)
|
|
527
|
+
return 1
|
|
528
|
+
|
|
529
|
+
previous_tag = releases[0].tag_name
|
|
530
|
+
try:
|
|
531
|
+
tag = versioning.bump(previous_tag, args.part)
|
|
532
|
+
except versioning.VersionError as exc:
|
|
533
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
534
|
+
return 1
|
|
535
|
+
|
|
536
|
+
print(f"version {previous_tag} -> {tag}")
|
|
537
|
+
_print_commits(previous_tag)
|
|
538
|
+
print()
|
|
539
|
+
print(f"next {_gh_release_create_hint(tag, args.target)}")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _add_release_create_args(p: argparse.ArgumentParser) -> None:
|
|
544
|
+
p.add_argument("-n", "--notes", help="release notes")
|
|
545
|
+
p.add_argument("-F", "--notes-file", dest="notes_file", help="read release notes from file")
|
|
546
|
+
p.add_argument("-t", "--title", help="release title (default: the tag)")
|
|
547
|
+
p.add_argument(
|
|
548
|
+
"--target", metavar="BRANCH", help="release branch (default: repo's default branch)"
|
|
549
|
+
)
|
|
550
|
+
p.add_argument("-d", "--draft", action="store_true")
|
|
551
|
+
p.add_argument("-p", "--prerelease", action="store_true")
|
|
552
|
+
p.add_argument(
|
|
553
|
+
"--force", action="store_true", help="skip the preflight and create immediately"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
558
|
+
parser = argparse.ArgumentParser(
|
|
559
|
+
prog="zh", description="batched gh PR actions for AI agents"
|
|
560
|
+
)
|
|
561
|
+
sub = parser.add_subparsers(dest="command")
|
|
562
|
+
pr = sub.add_parser("pr", help="PR write actions")
|
|
563
|
+
pr_sub = pr.add_subparsers(dest="pr_command", required=True)
|
|
564
|
+
|
|
565
|
+
p_threads = pr_sub.add_parser(
|
|
566
|
+
"threads", help="list review-comment threads (with the ids reply/resolve need)"
|
|
567
|
+
)
|
|
568
|
+
_add_ref_arg(p_threads)
|
|
569
|
+
p_threads.add_argument("--all", action="store_true", help="also show resolved threads")
|
|
570
|
+
p_threads.add_argument("--path", help="only threads on a path containing this substring")
|
|
571
|
+
p_threads.set_defaults(func=cmd_threads)
|
|
572
|
+
|
|
573
|
+
p_reply = pr_sub.add_parser("reply", help="reply to a review-comment thread")
|
|
574
|
+
p_reply.add_argument("thread_id", help="thread id, from `zh pr threads`")
|
|
575
|
+
p_reply.add_argument("-b", "--body", required=True)
|
|
576
|
+
p_reply.add_argument("--resolve", action="store_true", help="also resolve the thread")
|
|
577
|
+
p_reply.set_defaults(func=cmd_reply)
|
|
578
|
+
|
|
579
|
+
p_resolve = pr_sub.add_parser("resolve", help="mark review-comment thread(s) resolved")
|
|
580
|
+
p_resolve.add_argument("thread_id", nargs="*", help="thread id(s), from `zh pr threads`")
|
|
581
|
+
p_resolve.add_argument("--all", action="store_true", help="resolve every unresolved thread")
|
|
582
|
+
p_resolve.add_argument("--path", help="with --all, only threads on a matching path")
|
|
583
|
+
p_resolve.add_argument(
|
|
584
|
+
"--pr", dest="ref", help="PR number, URL, or branch to use with --all (default: current branch's PR)"
|
|
585
|
+
)
|
|
586
|
+
p_resolve.set_defaults(func=cmd_resolve)
|
|
587
|
+
|
|
588
|
+
p_unresolve = pr_sub.add_parser("unresolve", help="reopen review-comment thread(s)")
|
|
589
|
+
p_unresolve.add_argument("thread_id", nargs="+", help="thread id(s), from `zh pr threads`")
|
|
590
|
+
p_unresolve.set_defaults(func=cmd_unresolve)
|
|
591
|
+
|
|
592
|
+
p_merge = pr_sub.add_parser(
|
|
593
|
+
"merge",
|
|
594
|
+
help="merge a PR — preflights draft/review/unresolved-threads/CI first",
|
|
595
|
+
)
|
|
596
|
+
_add_merge_args(p_merge, default_delete_branch=False)
|
|
597
|
+
|
|
598
|
+
p_ship = pr_sub.add_parser(
|
|
599
|
+
"ship",
|
|
600
|
+
help="merge + delete branch: `merge` with cleanup defaults on",
|
|
601
|
+
)
|
|
602
|
+
_add_merge_args(p_ship, default_delete_branch=True)
|
|
603
|
+
|
|
604
|
+
release = sub.add_parser(
|
|
605
|
+
"release",
|
|
606
|
+
help="preflight + what to release next (bare, or patch/minor/major); create to cut one",
|
|
607
|
+
)
|
|
608
|
+
release.add_argument(
|
|
609
|
+
"--target", metavar="BRANCH", help="release branch (default: repo's default branch)"
|
|
610
|
+
)
|
|
611
|
+
release.set_defaults(func=cmd_release)
|
|
612
|
+
release_sub = release.add_subparsers(dest="release_command")
|
|
613
|
+
|
|
614
|
+
p_release_create = release_sub.add_parser(
|
|
615
|
+
"create", help="preflight, then create the release at an explicit version"
|
|
616
|
+
)
|
|
617
|
+
p_release_create.add_argument("tag", help="release tag/version, e.g. v1.3.0")
|
|
618
|
+
_add_release_create_args(p_release_create)
|
|
619
|
+
p_release_create.set_defaults(func=cmd_release_create)
|
|
620
|
+
|
|
621
|
+
for part in ("patch", "minor", "major"):
|
|
622
|
+
p_bump = release_sub.add_parser(
|
|
623
|
+
part,
|
|
624
|
+
help=f"preflight, then show the {part}-bumped version and the gh command to create it",
|
|
625
|
+
)
|
|
626
|
+
p_bump.add_argument(
|
|
627
|
+
"--target", metavar="BRANCH", help="release branch (default: repo's default branch)"
|
|
628
|
+
)
|
|
629
|
+
p_bump.add_argument(
|
|
630
|
+
"--force", action="store_true", help="skip the preflight"
|
|
631
|
+
)
|
|
632
|
+
p_bump.set_defaults(func=cmd_release_bump, part=part)
|
|
633
|
+
|
|
634
|
+
return parser
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def main() -> None:
|
|
638
|
+
parser = build_parser()
|
|
639
|
+
args = parser.parse_args()
|
|
640
|
+
if not getattr(args, "func", None):
|
|
641
|
+
parser.print_help()
|
|
642
|
+
sys.exit(1)
|
|
643
|
+
sys.exit(args.func(args))
|
zithub/gh.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Thin wrappers around the `gh` CLI and its GraphQL API for PR write actions.
|
|
2
|
+
|
|
3
|
+
Unlike a status tool, most of these actions delegate straight to `gh`'s own
|
|
4
|
+
subprocess (inheriting stdio) so `gh`'s own flags, prompts, and error
|
|
5
|
+
messages come through unchanged. The GraphQL calls (review-thread reply/
|
|
6
|
+
resolve/unresolve) exist because `gh` has no first-class subcommand for
|
|
7
|
+
them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
_FAILED_CONCLUSIONS = {"failure", "timed_out", "cancelled", "startup_failure", "action_required"}
|
|
17
|
+
_PENDING_STATUSES = {"in_progress", "queued", "pending", "waiting", "requested"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ZithubError(Exception):
|
|
21
|
+
"""Raised when a required CLI is missing or a command fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run_result(args: list[str]) -> subprocess.CompletedProcess:
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
27
|
+
except FileNotFoundError as exc:
|
|
28
|
+
raise ZithubError(f"`{args[0]}` not found on PATH") from exc
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
raise ZithubError(result.stderr.strip() or f"`{' '.join(args)}` failed")
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run(args: list[str]) -> str:
|
|
35
|
+
return _run_result(args).stdout.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_raw(args: list[str]) -> str:
|
|
39
|
+
"""Like _run, but only trims the trailing newline — for output (e.g.
|
|
40
|
+
`git status --short`) whose leading whitespace on the first line is
|
|
41
|
+
meaningful and would otherwise be eaten by _run's full .strip()."""
|
|
42
|
+
return _run_result(args).stdout.rstrip("\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_json(args: list[str]):
|
|
46
|
+
return json.loads(_run(args))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_ok(args: list[str]) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return False
|
|
54
|
+
return result.returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_passthrough(args: list[str]) -> int:
|
|
58
|
+
"""Run a `gh` command with stdio inherited, returning its exit code.
|
|
59
|
+
|
|
60
|
+
Used for actions where `gh`'s own output/prompts/exit code are exactly
|
|
61
|
+
what should reach the terminal (create, close, comment, review, edit) —
|
|
62
|
+
no reason to re-render what `gh` already prints well.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
return subprocess.run(args).returncode
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
raise ZithubError(f"`{args[0]}` not found on PATH") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class RepoInfo:
|
|
72
|
+
owner: str
|
|
73
|
+
name: str
|
|
74
|
+
name_with_owner: str
|
|
75
|
+
url: str
|
|
76
|
+
default_branch: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def repo_info() -> RepoInfo:
|
|
80
|
+
data = _run_json(["gh", "repo", "view", "--json", "owner,name,nameWithOwner,url,defaultBranchRef"])
|
|
81
|
+
return RepoInfo(
|
|
82
|
+
owner=data["owner"]["login"],
|
|
83
|
+
name=data["name"],
|
|
84
|
+
name_with_owner=data["nameWithOwner"],
|
|
85
|
+
url=data["url"],
|
|
86
|
+
default_branch=(data.get("defaultBranchRef") or {}).get("name", "?"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class CheckRun:
|
|
92
|
+
name: str
|
|
93
|
+
status: str
|
|
94
|
+
conclusion: str | None
|
|
95
|
+
details_url: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_failed(c: CheckRun) -> bool:
|
|
99
|
+
return (c.conclusion or "").lower() in _FAILED_CONCLUSIONS or (c.status or "").lower() == "failure"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_pending(c: CheckRun) -> bool:
|
|
103
|
+
return not c.conclusion and (c.status or "").lower() in _PENDING_STATUSES
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_success(c: CheckRun) -> bool:
|
|
107
|
+
return (c.conclusion or "").lower() == "success" or (c.status or "").lower() == "success"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class PullRequest:
|
|
112
|
+
number: int
|
|
113
|
+
title: str
|
|
114
|
+
url: str
|
|
115
|
+
state: str
|
|
116
|
+
is_draft: bool
|
|
117
|
+
review_decision: str
|
|
118
|
+
head_ref_name: str
|
|
119
|
+
base_ref_name: str
|
|
120
|
+
checks: list[CheckRun]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_PR_VIEW_FIELDS = (
|
|
124
|
+
"number,title,url,state,isDraft,reviewDecision,statusCheckRollup,headRefName,baseRefName"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_pr(ref: str | None = None) -> PullRequest:
|
|
129
|
+
"""The PR for `ref` (number, URL, or branch), or the current branch's PR
|
|
130
|
+
if `ref` is None — same resolution `gh pr view` itself does. Raises
|
|
131
|
+
ZithubError if there's no such PR."""
|
|
132
|
+
args = ["gh", "pr", "view", "--json", _PR_VIEW_FIELDS]
|
|
133
|
+
if ref:
|
|
134
|
+
args.insert(3, ref)
|
|
135
|
+
data = _run_json(args)
|
|
136
|
+
|
|
137
|
+
checks = [
|
|
138
|
+
CheckRun(
|
|
139
|
+
name=c.get("name") or c.get("context", "?"),
|
|
140
|
+
status=c.get("status", c.get("state", "?")),
|
|
141
|
+
conclusion=c.get("conclusion"),
|
|
142
|
+
details_url=c.get("detailsUrl") or c.get("targetUrl"),
|
|
143
|
+
)
|
|
144
|
+
for c in data.get("statusCheckRollup") or []
|
|
145
|
+
]
|
|
146
|
+
return PullRequest(
|
|
147
|
+
number=data["number"],
|
|
148
|
+
title=data["title"],
|
|
149
|
+
url=data["url"],
|
|
150
|
+
state=data["state"],
|
|
151
|
+
is_draft=data["isDraft"],
|
|
152
|
+
review_decision=data.get("reviewDecision") or "",
|
|
153
|
+
head_ref_name=data["headRefName"],
|
|
154
|
+
base_ref_name=data["baseRefName"],
|
|
155
|
+
checks=checks,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class ReviewComment:
|
|
161
|
+
id: str
|
|
162
|
+
author: str
|
|
163
|
+
created_at: str
|
|
164
|
+
body: str
|
|
165
|
+
path: str | None = None
|
|
166
|
+
line: int | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class ReviewThread:
|
|
171
|
+
id: str
|
|
172
|
+
is_resolved: bool
|
|
173
|
+
comments: list[ReviewComment] = field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def path(self) -> str | None:
|
|
177
|
+
return self.comments[0].path if self.comments else None
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def line(self) -> int | None:
|
|
181
|
+
return self.comments[0].line if self.comments else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_REVIEW_THREADS_QUERY = """
|
|
185
|
+
query($owner: String!, $repo: String!, $pr: Int!, $after: String) {
|
|
186
|
+
repository(owner: $owner, name: $repo) {
|
|
187
|
+
pullRequest(number: $pr) {
|
|
188
|
+
reviewThreads(first: 50, after: $after) {
|
|
189
|
+
pageInfo { hasNextPage endCursor }
|
|
190
|
+
nodes {
|
|
191
|
+
id
|
|
192
|
+
isResolved
|
|
193
|
+
comments(first: 50) {
|
|
194
|
+
nodes { id body createdAt path line author { login } }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def review_threads(owner: str, repo: str, number: int) -> list[ReviewThread]:
|
|
205
|
+
"""Every review-comment thread on a PR (resolved and unresolved), each
|
|
206
|
+
with its comments and GraphQL node id — the id is what `resolve`,
|
|
207
|
+
`unresolve`, and `reply` take to act on a specific thread."""
|
|
208
|
+
threads: list[ReviewThread] = []
|
|
209
|
+
after: str | None = None
|
|
210
|
+
while True:
|
|
211
|
+
args = [
|
|
212
|
+
"gh",
|
|
213
|
+
"api",
|
|
214
|
+
"graphql",
|
|
215
|
+
"-f",
|
|
216
|
+
f"query={_REVIEW_THREADS_QUERY}",
|
|
217
|
+
"-F",
|
|
218
|
+
f"owner={owner}",
|
|
219
|
+
"-F",
|
|
220
|
+
f"repo={repo}",
|
|
221
|
+
"-F",
|
|
222
|
+
f"pr={number}",
|
|
223
|
+
]
|
|
224
|
+
if after is not None:
|
|
225
|
+
args += ["-F", f"after={after}"]
|
|
226
|
+
data = _run_json(args)
|
|
227
|
+
pr = ((data.get("data") or {}).get("repository") or {}).get("pullRequest") or {}
|
|
228
|
+
rt = pr.get("reviewThreads") or {}
|
|
229
|
+
for t in rt.get("nodes") or []:
|
|
230
|
+
comments = [
|
|
231
|
+
ReviewComment(
|
|
232
|
+
id=c["id"],
|
|
233
|
+
author=(c.get("author") or {}).get("login") or "?",
|
|
234
|
+
created_at=c.get("createdAt", ""),
|
|
235
|
+
body=c.get("body", ""),
|
|
236
|
+
path=c.get("path"),
|
|
237
|
+
line=c.get("line"),
|
|
238
|
+
)
|
|
239
|
+
for c in (t.get("comments") or {}).get("nodes") or []
|
|
240
|
+
]
|
|
241
|
+
threads.append(
|
|
242
|
+
ReviewThread(id=t["id"], is_resolved=bool(t.get("isResolved")), comments=comments)
|
|
243
|
+
)
|
|
244
|
+
page_info = rt.get("pageInfo") or {}
|
|
245
|
+
if not page_info.get("hasNextPage"):
|
|
246
|
+
break
|
|
247
|
+
after = page_info.get("endCursor")
|
|
248
|
+
return threads
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def resolve_thread(thread_id: str) -> bool:
|
|
252
|
+
"""Marks a review thread resolved. Returns the thread's resolved state
|
|
253
|
+
after the call (should always be True on success)."""
|
|
254
|
+
data = _run_json(
|
|
255
|
+
[
|
|
256
|
+
"gh",
|
|
257
|
+
"api",
|
|
258
|
+
"graphql",
|
|
259
|
+
"-f",
|
|
260
|
+
"query=mutation($id: ID!) { resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } }",
|
|
261
|
+
"-F",
|
|
262
|
+
f"id={thread_id}",
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
return bool((((data.get("data") or {}).get("resolveReviewThread") or {}).get("thread") or {}).get("isResolved"))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def unresolve_thread(thread_id: str) -> bool:
|
|
269
|
+
data = _run_json(
|
|
270
|
+
[
|
|
271
|
+
"gh",
|
|
272
|
+
"api",
|
|
273
|
+
"graphql",
|
|
274
|
+
"-f",
|
|
275
|
+
"query=mutation($id: ID!) { unresolveReviewThread(input: {threadId: $id}) { thread { isResolved } } }",
|
|
276
|
+
"-F",
|
|
277
|
+
f"id={thread_id}",
|
|
278
|
+
]
|
|
279
|
+
)
|
|
280
|
+
return bool((((data.get("data") or {}).get("unresolveReviewThread") or {}).get("thread") or {}).get("isResolved"))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def reply_to_thread(thread_id: str, body: str) -> str:
|
|
284
|
+
"""Posts `body` as a reply in an existing review thread. Returns the new
|
|
285
|
+
comment's URL."""
|
|
286
|
+
data = _run_json(
|
|
287
|
+
[
|
|
288
|
+
"gh",
|
|
289
|
+
"api",
|
|
290
|
+
"graphql",
|
|
291
|
+
"-f",
|
|
292
|
+
"query=mutation($id: ID!, $body: String!) { addPullRequestReviewThreadReply(input: {pullRequestReviewThreadId: $id, body: $body}) { comment { url } } }",
|
|
293
|
+
"-F",
|
|
294
|
+
f"id={thread_id}",
|
|
295
|
+
"-f",
|
|
296
|
+
f"body={body}",
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
comment = (((data.get("data") or {}).get("addPullRequestReviewThreadReply") or {}).get("comment") or {})
|
|
300
|
+
return comment.get("url", "")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# release preflight + create — ports the github-release skill's preflight.py
|
|
305
|
+
# checks (gh auth, target branch, remote sync, CI) into zh so `release
|
|
306
|
+
# create` can gate on them directly instead of a separate script + a
|
|
307
|
+
# hand-typed `gh release create`.
|
|
308
|
+
|
|
309
|
+
_DIRTY_IGNORE = {"uv.lock"}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def current_branch() -> str:
|
|
313
|
+
return _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def current_commit_sha() -> str:
|
|
317
|
+
return _run(["git", "rev-parse", "HEAD"])
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def fetch_all() -> None:
|
|
321
|
+
_run(["git", "fetch", "origin", "--prune", "--tags"])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def remote_branch_sha(branch: str) -> str | None:
|
|
325
|
+
try:
|
|
326
|
+
return _run(["git", "rev-parse", f"origin/{branch}"])
|
|
327
|
+
except ZithubError:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def dirty_files() -> list[str]:
|
|
332
|
+
"""Uncommitted changes (tracked or untracked) that won't be part of the
|
|
333
|
+
release since the release tag points at HEAD. `uv.lock` is excluded —
|
|
334
|
+
it's routinely rewritten by tooling and not worth flagging every time."""
|
|
335
|
+
lines = _run_raw(["git", "status", "--short"]).splitlines()
|
|
336
|
+
return [line for line in lines if line[3:].strip() not in _DIRTY_IGNORE]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def commits_since(tag: str | None, limit: int = 50) -> list[str]:
|
|
340
|
+
"""One-line log of commits since `tag` (or the last `limit` commits if
|
|
341
|
+
there's no previous release yet) — the input a release's notes get
|
|
342
|
+
written from. Best-effort: empty (never raises) if git has no history
|
|
343
|
+
or `tag` doesn't exist locally."""
|
|
344
|
+
args = (
|
|
345
|
+
["git", "log", f"{tag}..HEAD", "--oneline", "--no-decorate"]
|
|
346
|
+
if tag
|
|
347
|
+
else ["git", "log", f"-{limit}", "--oneline", "--no-decorate"]
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
output = _run(args)
|
|
351
|
+
except ZithubError:
|
|
352
|
+
return []
|
|
353
|
+
return output.splitlines() if output else []
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass
|
|
357
|
+
class GhAccount:
|
|
358
|
+
login: str
|
|
359
|
+
active: bool
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def list_gh_accounts() -> list[GhAccount]:
|
|
363
|
+
try:
|
|
364
|
+
data = _run_json(["gh", "auth", "status", "--json", "hosts"])
|
|
365
|
+
except ZithubError:
|
|
366
|
+
return []
|
|
367
|
+
return [
|
|
368
|
+
GhAccount(login=a.get("login", ""), active=bool(a.get("active")))
|
|
369
|
+
for a in data.get("hosts", {}).get("github.com", [])
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _repo_visible() -> bool:
|
|
374
|
+
return _run_ok(["gh", "repo", "view", "--json", "id"])
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def ensure_gh_account_for_repo() -> str:
|
|
378
|
+
"""The logged-in gh account that can actually see this repo, switching
|
|
379
|
+
to it if the currently active one can't. Tests real access via `gh repo
|
|
380
|
+
view` rather than guessing from a login/owner naming convention, so it
|
|
381
|
+
works for orgs whose name doesn't happen to embed the account's login.
|
|
382
|
+
Raises ZithubError if no logged-in account can view the repo."""
|
|
383
|
+
accounts = list_gh_accounts()
|
|
384
|
+
if not accounts:
|
|
385
|
+
raise ZithubError("no gh accounts logged in — run `gh auth login`")
|
|
386
|
+
|
|
387
|
+
if _repo_visible():
|
|
388
|
+
active = next((a for a in accounts if a.active), None)
|
|
389
|
+
return active.login if active else accounts[0].login
|
|
390
|
+
|
|
391
|
+
for account in accounts:
|
|
392
|
+
if account.active:
|
|
393
|
+
continue
|
|
394
|
+
_run(["gh", "auth", "switch", "--hostname", "github.com", "--user", account.login])
|
|
395
|
+
if _repo_visible():
|
|
396
|
+
return account.login
|
|
397
|
+
|
|
398
|
+
tried = ", ".join(a.login for a in accounts)
|
|
399
|
+
raise ZithubError(f"no logged-in gh account can view this repository (tried: {tried})")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class ReleaseInfo:
|
|
404
|
+
tag_name: str
|
|
405
|
+
name: str
|
|
406
|
+
published_at: str | None
|
|
407
|
+
is_draft: bool
|
|
408
|
+
is_prerelease: bool
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def recent_releases(limit: int = 5) -> list[ReleaseInfo]:
|
|
412
|
+
data = _run_json(
|
|
413
|
+
[
|
|
414
|
+
"gh",
|
|
415
|
+
"release",
|
|
416
|
+
"list",
|
|
417
|
+
"--limit",
|
|
418
|
+
str(limit),
|
|
419
|
+
"--json",
|
|
420
|
+
"tagName,name,publishedAt,isDraft,isPrerelease",
|
|
421
|
+
]
|
|
422
|
+
)
|
|
423
|
+
return [
|
|
424
|
+
ReleaseInfo(
|
|
425
|
+
tag_name=r["tagName"],
|
|
426
|
+
name=r.get("name") or r["tagName"],
|
|
427
|
+
published_at=r.get("publishedAt"),
|
|
428
|
+
is_draft=r["isDraft"],
|
|
429
|
+
is_prerelease=r["isPrerelease"],
|
|
430
|
+
)
|
|
431
|
+
for r in data
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass
|
|
436
|
+
class WorkflowRun:
|
|
437
|
+
database_id: int
|
|
438
|
+
name: str
|
|
439
|
+
status: str
|
|
440
|
+
conclusion: str | None
|
|
441
|
+
url: str
|
|
442
|
+
head_sha: str | None = None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
_RUN_LIST_FIELDS = "databaseId,name,status,conclusion,url,headSha"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def runs_for_commit(sha: str, limit: int = 100) -> list[WorkflowRun]:
|
|
449
|
+
"""Workflow runs whose head commit is exactly `sha` — gates a release on
|
|
450
|
+
CI for the precise commit being tagged, not just "the latest run on this
|
|
451
|
+
branch" (which could predate a since-amended push). Filtered again
|
|
452
|
+
locally since `gh run list --commit` has been seen returning runs from
|
|
453
|
+
other commits on some repos/tokens."""
|
|
454
|
+
data = _run_json(
|
|
455
|
+
["gh", "run", "list", "--commit", sha, "--limit", str(limit), "--json", _RUN_LIST_FIELDS]
|
|
456
|
+
)
|
|
457
|
+
return [
|
|
458
|
+
WorkflowRun(
|
|
459
|
+
database_id=r["databaseId"],
|
|
460
|
+
name=r["name"],
|
|
461
|
+
status=r["status"],
|
|
462
|
+
conclusion=r.get("conclusion"),
|
|
463
|
+
url=r["url"],
|
|
464
|
+
head_sha=r.get("headSha"),
|
|
465
|
+
)
|
|
466
|
+
for r in data
|
|
467
|
+
if r.get("headSha") == sha
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def latest_runs_for_branch(branch: str, limit: int = 5) -> list[WorkflowRun]:
|
|
472
|
+
data = _run_json(
|
|
473
|
+
[
|
|
474
|
+
"gh",
|
|
475
|
+
"run",
|
|
476
|
+
"list",
|
|
477
|
+
"--branch",
|
|
478
|
+
branch,
|
|
479
|
+
"--limit",
|
|
480
|
+
str(limit),
|
|
481
|
+
"--json",
|
|
482
|
+
_RUN_LIST_FIELDS,
|
|
483
|
+
]
|
|
484
|
+
)
|
|
485
|
+
return [
|
|
486
|
+
WorkflowRun(
|
|
487
|
+
database_id=r["databaseId"],
|
|
488
|
+
name=r["name"],
|
|
489
|
+
status=r["status"],
|
|
490
|
+
conclusion=r.get("conclusion"),
|
|
491
|
+
url=r["url"],
|
|
492
|
+
head_sha=r.get("headSha"),
|
|
493
|
+
)
|
|
494
|
+
for r in data
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def run_failure_log(database_id: int, lines: int = 40) -> str | None:
|
|
499
|
+
"""Best-effort tail of a failed run's log — best-effort since a run can
|
|
500
|
+
fail in ways that leave no per-step log (infra failure, cancellation)."""
|
|
501
|
+
try:
|
|
502
|
+
text = _run(["gh", "run", "view", str(database_id), "--log-failed"])
|
|
503
|
+
except ZithubError:
|
|
504
|
+
return None
|
|
505
|
+
if not text:
|
|
506
|
+
return None
|
|
507
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def create_release(
|
|
511
|
+
tag: str,
|
|
512
|
+
notes: str,
|
|
513
|
+
title: str | None = None,
|
|
514
|
+
target: str | None = None,
|
|
515
|
+
draft: bool = False,
|
|
516
|
+
prerelease: bool = False,
|
|
517
|
+
) -> str:
|
|
518
|
+
"""Creates the GitHub release (and its tag) via `gh release create`.
|
|
519
|
+
Returns the release URL."""
|
|
520
|
+
cmd = ["gh", "release", "create", tag, "--notes", notes]
|
|
521
|
+
if title:
|
|
522
|
+
cmd += ["--title", title]
|
|
523
|
+
if target:
|
|
524
|
+
cmd += ["--target", target]
|
|
525
|
+
if draft:
|
|
526
|
+
cmd.append("--draft")
|
|
527
|
+
if prerelease:
|
|
528
|
+
cmd.append("--prerelease")
|
|
529
|
+
return _run(cmd)
|
zithub/versioning.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Semver tag bumping — pure logic, no subprocess calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_SEMVER_RE = re.compile(r"^(?P<prefix>[vV])?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VersionError(Exception):
|
|
11
|
+
"""Raised when a tag doesn't parse as a plain vX.Y.Z / X.Y.Z semver tag."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bump(tag: str, part: str) -> str:
|
|
15
|
+
"""`tag` with its major/minor/patch component incremented and the
|
|
16
|
+
components after it reset to 0, preserving a leading "v" if present.
|
|
17
|
+
`part` is one of "major", "minor", "patch"."""
|
|
18
|
+
match = _SEMVER_RE.match(tag)
|
|
19
|
+
if not match:
|
|
20
|
+
raise VersionError(
|
|
21
|
+
f"'{tag}' doesn't look like a plain semver tag (vX.Y.Z) — pass the next version explicitly"
|
|
22
|
+
)
|
|
23
|
+
major, minor, patch = int(match["major"]), int(match["minor"]), int(match["patch"])
|
|
24
|
+
if part == "major":
|
|
25
|
+
major, minor, patch = major + 1, 0, 0
|
|
26
|
+
elif part == "minor":
|
|
27
|
+
minor, patch = minor + 1, 0
|
|
28
|
+
elif part == "patch":
|
|
29
|
+
patch += 1
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(f"unknown part: {part!r}")
|
|
32
|
+
return f"{match['prefix'] or ''}{major}.{minor}.{patch}"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zithub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Batched gh CLI actions for PR workflows: merge preflight, review-thread reply/resolve, and more in one call
|
|
5
|
+
Keywords: git,github,cli,pull-request,ci,gh
|
|
6
|
+
Author: Ville Vainio
|
|
7
|
+
Author-email: Ville Vainio <vivainio@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/vivainio/zithub
|
|
22
|
+
Project-URL: Repository, https://github.com/vivainio/zithub
|
|
23
|
+
Project-URL: Issues, https://github.com/vivainio/zithub/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# zithub
|
|
27
|
+
|
|
28
|
+
`zh` — batched `gh` PR actions for AI agents, for the two things plain `gh`
|
|
29
|
+
can't already do in a single call: acting on PR review-comment threads (no
|
|
30
|
+
`gh` subcommand exists for these at all), and merging with a real preflight
|
|
31
|
+
(draft/review-decision/unresolved-threads/CI, checked and merged in one
|
|
32
|
+
call instead of chained separately). Where
|
|
33
|
+
[wazup](https://github.com/vivainio/wazup) is the read-only "what's up with
|
|
34
|
+
this repo" status tool, `zh` is this narrower write-side complement — it
|
|
35
|
+
deliberately does *not* wrap `gh pr create/close/comment/review/edit`,
|
|
36
|
+
since those are already one `gh` call each and an agent that already knows
|
|
37
|
+
`gh` gains nothing from a second name for the same thing.
|
|
38
|
+
|
|
39
|
+
Requires the [GitHub CLI](https://cli.github.com/) (`gh`) installed and
|
|
40
|
+
authenticated (`gh auth login`).
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
zh pr threads # list review-comment threads (with the ids below)
|
|
46
|
+
zh pr reply # reply to a review-comment thread, optionally --resolve
|
|
47
|
+
zh pr resolve # mark thread(s) resolved, by id or --all
|
|
48
|
+
zh pr unresolve # reopen thread(s)
|
|
49
|
+
|
|
50
|
+
zh pr merge # merge a PR
|
|
51
|
+
zh pr ship # merge + delete branch (same as `merge --delete-branch`)
|
|
52
|
+
|
|
53
|
+
zh release # preflight, then commits + the gh command for each next version
|
|
54
|
+
zh release patch/minor/major # preflight, then just that one bumped version + gh command
|
|
55
|
+
zh release create # preflight, then actually create at an explicit version
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`threads`/`reply`/`resolve`/`unresolve` exist because `gh` has no
|
|
59
|
+
subcommand for review threads at all — they go through `gh api graphql`
|
|
60
|
+
directly (`reviewThreads`, `resolveReviewThread`, `unresolveReviewThread`,
|
|
61
|
+
`addPullRequestReviewThreadReply`). Run `zh pr threads` to see each
|
|
62
|
+
thread's id, then act on it:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
zh pr threads # unresolved threads on the current branch's PR
|
|
66
|
+
zh pr reply T_kwDOA... -b "fixed in a1b2c3d" --resolve
|
|
67
|
+
zh pr resolve --all --path src/ # resolve every unresolved thread under src/
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`zh pr merge` (and `zh pr ship`, which is the same thing with
|
|
71
|
+
`--delete-branch` on by default) bundles what would otherwise be a
|
|
72
|
+
draft/review-decision/unresolved-thread/CI check spread across several
|
|
73
|
+
calls into one preflight, then merges only if everything's clean:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
zh pr merge # preflight, waiting on any pending checks, then squash-merge
|
|
77
|
+
zh pr ship # same, plus delete the branch afterward
|
|
78
|
+
zh pr merge --no-wait # fail fast instead of polling pending checks
|
|
79
|
+
zh pr merge --force # skip the preflight and merge immediately
|
|
80
|
+
zh pr merge --method rebase --keep-branch
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`zh release` (bare, or `patch`/`minor`/`major`) ports the checks from the
|
|
84
|
+
`github-release` skill's `preflight.py` — the right gh account is active
|
|
85
|
+
(tested by actually trying to view the repo, not by guessing from a
|
|
86
|
+
login/owner naming convention), the current branch matches the release
|
|
87
|
+
target, local HEAD matches `origin/<target>` exactly, and CI is green on
|
|
88
|
+
that exact commit (falling back to the branch's latest runs if no run
|
|
89
|
+
exists yet for the commit, and proceeding on judgement if none exist at
|
|
90
|
+
all). A dirty worktree only warns — it isn't part of the release either
|
|
91
|
+
way, since the tag points at HEAD.
|
|
92
|
+
|
|
93
|
+
Past the preflight it's read-only: it never creates anything itself. It
|
|
94
|
+
shows the commit log a release's notes get written from, and prints the
|
|
95
|
+
exact `gh release create ...` command to run once notes are ready — not
|
|
96
|
+
`zh release create`, since the preflight (including the CI wait) was just
|
|
97
|
+
run right here and redoing it a moment later would be wasted work.
|
|
98
|
+
`patch`/`minor`/`major` fetch the latest release tag and compute one
|
|
99
|
+
specific bump; bare `zh release` shows all three:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
zh release # -> gh release create <next patch/minor/major> ...
|
|
103
|
+
zh release patch # -> just the patch bump
|
|
104
|
+
zh release patch --target release-2.0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`zh release create` is the one command that actually publishes — it runs
|
|
108
|
+
the same preflight itself first (unless `--force`), so it's also safe to
|
|
109
|
+
call directly without a prior `zh release`:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
zh release create v1.3.0 -n "$(cat notes.md)"
|
|
113
|
+
zh release create v2.0.0-rc1 --prerelease --force # skip the preflight
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
uv tool install zithub
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
zithub/__init__.py,sha256=oKsCpoiQSSXx57DGs2VfYzeO1ukJQxdGnV-2hC3BI0M,42
|
|
2
|
+
zithub/cli.py,sha256=DK-l1Lsh4xdi4rjh0OPX4mqYmeDx4m14P7OA3EE0q8w,22630
|
|
3
|
+
zithub/gh.py,sha256=GXm_jGLQPjbjLzGDLWvUi0s3lMLp5g8sbrcv94SxPgY,15897
|
|
4
|
+
zithub/versioning.py,sha256=7a_pZLRWNVGDJ3ZdcXxKGysFULvhoPiFyc25mEMnfKU,1135
|
|
5
|
+
zithub-0.1.0.dist-info/WHEEL,sha256=-i9oRNYVXXZJUIYl5zclLIg6onEb0NLibTX34uln84w,81
|
|
6
|
+
zithub-0.1.0.dist-info/entry_points.txt,sha256=M3-unQLWLE8cNBsh2cObkQpM1kB9Tzd5Htopq6lMQg0,36
|
|
7
|
+
zithub-0.1.0.dist-info/METADATA,sha256=VbP-dKqci6uXVIPmILCnrzN9EqQvKPdXGK-FRfzNSLI,5334
|
|
8
|
+
zithub-0.1.0.dist-info/RECORD,,
|