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 ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.13
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ zh = zithub:main
3
+