zithub 0.1.0__tar.gz → 0.3.0__tar.gz
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-0.3.0/PKG-INFO +204 -0
- zithub-0.3.0/README.md +179 -0
- {zithub-0.1.0 → zithub-0.3.0}/pyproject.toml +6 -2
- {zithub-0.1.0 → zithub-0.3.0}/pyproject.toml.orig +6 -2
- zithub-0.3.0/src/zithub/board.py +241 -0
- zithub-0.3.0/src/zithub/cli.py +1968 -0
- zithub-0.3.0/src/zithub/gh.py +1659 -0
- zithub-0.3.0/src/zithub/plan.py +168 -0
- zithub-0.3.0/src/zithub/registry.py +133 -0
- zithub-0.3.0/src/zithub/skills/zithub/SKILL.md +121 -0
- zithub-0.3.0/src/zithub/skills.py +96 -0
- zithub-0.3.0/src/zithub/ticket.py +51 -0
- zithub-0.1.0/PKG-INFO +0 -124
- zithub-0.1.0/README.md +0 -99
- zithub-0.1.0/src/zithub/cli.py +0 -643
- zithub-0.1.0/src/zithub/gh.py +0 -529
- {zithub-0.1.0 → zithub-0.3.0}/src/zithub/__init__.py +0 -0
- {zithub-0.1.0 → zithub-0.3.0}/src/zithub/versioning.py +0 -0
zithub-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zithub
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: What's up with this repo, right now, plus batched gh PR/release actions: status, CI, merge preflight, review-thread reply/resolve, and more
|
|
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` — what's up with this repo, right now, plus batched `gh` PR and release
|
|
29
|
+
actions for AI agents. A standalone `gh`/`git` CLI: bare `zh` gives repo,
|
|
30
|
+
branch, local, PR, and CI status in one shot (no separate status tool
|
|
31
|
+
required), and the rest covers what plain `gh` can't already do in a single
|
|
32
|
+
call — acting on PR review-comment threads (no `gh` subcommand exists for
|
|
33
|
+
these at all), merging with a real preflight
|
|
34
|
+
(draft/review-decision/unresolved-threads/CI, checked and merged in one
|
|
35
|
+
call instead of chained separately), and release preflight/creation. The
|
|
36
|
+
status/CI/PR/my/review/issues reporting is ported from
|
|
37
|
+
[wazup](https://github.com/vivainio/wazup), so `zh` doesn't need wazup
|
|
38
|
+
installed alongside it. It deliberately does *not* wrap `gh pr
|
|
39
|
+
create/close/comment/review/edit`, since those are already one `gh` call
|
|
40
|
+
each and an agent that already knows `gh` gains nothing from a second name
|
|
41
|
+
for the same thing.
|
|
42
|
+
|
|
43
|
+
Requires the [GitHub CLI](https://cli.github.com/) (`gh`) installed and
|
|
44
|
+
authenticated (`gh auth login`).
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
zh # prints help (bare zh does nothing)
|
|
50
|
+
zh status # repo, branch, local, PR, and CI status in one shot
|
|
51
|
+
# (also recent branches/worktrees on the default branch)
|
|
52
|
+
zh ci # just the CI status for the current branch/PR
|
|
53
|
+
zh pr # current branch's PR: status, checks, and review comment
|
|
54
|
+
# threads (unresolved by default; --all also shows resolved)
|
|
55
|
+
zh my # your open PRs across all repos, plus recent closed/merged
|
|
56
|
+
zh my --this # ...just for the current repo
|
|
57
|
+
zh stats # most active repos by recent PR+issue activity (--owner, --days, --limit)
|
|
58
|
+
zh review # PRs awaiting your review, updated in the last 7 days
|
|
59
|
+
zh issues # your open issues in this repo, or recent activity outside one
|
|
60
|
+
zh gh <args...> # gh <args...> as this repo owner's gh account (GH_TOKEN; no global switch)
|
|
61
|
+
zh git <args...> # git <args...>; over https, as this repo owner's gh account (same)
|
|
62
|
+
|
|
63
|
+
zh board sync # fetch your open PRs + activity (CI, review, comments) into a local sqlite db
|
|
64
|
+
# (incremental: only new/updated PRs or unsettled CI; --full refetches all)
|
|
65
|
+
zh board # list synced PRs per repo (biggest first), oldest activity first (--stale-days N)
|
|
66
|
+
zh board focus # grouped: fix CI, needs your reply, ready to merge, waiting on others
|
|
67
|
+
# (each grouped per repo; bot comments never count as "needs your reply")
|
|
68
|
+
zh board bots [add|rm X] # extra logins treated as bots on this host, e.g. a CI service account
|
|
69
|
+
zh board query "<SQL>" # ad hoc read-only SQL against the synced db
|
|
70
|
+
# (one board per GitHub site: $GH_HOST, else origin's host, else github.com)
|
|
71
|
+
|
|
72
|
+
zh pr threads # list review-comment threads (with the ids below)
|
|
73
|
+
zh pr reply # reply to a review-comment thread, optionally --resolve
|
|
74
|
+
zh pr resolve # mark thread(s) resolved, by id or --all
|
|
75
|
+
zh pr unresolve # reopen thread(s)
|
|
76
|
+
|
|
77
|
+
zh plan # draft reply/resolve/merge actions into ONE reviewable file
|
|
78
|
+
# (context as comments, all actions commented out): zh plan > plan.md
|
|
79
|
+
zh plan --apply plan.md [--dry-run] # validate everything, then run it in order
|
|
80
|
+
|
|
81
|
+
zh pr check # target branch, and whether the PR references a ticket
|
|
82
|
+
|
|
83
|
+
zh pr merge # merge a PR
|
|
84
|
+
zh pr ship # merge + delete branch (same as `merge --delete-branch`)
|
|
85
|
+
|
|
86
|
+
zh release # preflight, then commits + the gh command for each next version
|
|
87
|
+
zh release patch/minor/major # preflight, then just that one bumped version + gh command
|
|
88
|
+
zh release create # preflight, then actually create at an explicit version
|
|
89
|
+
|
|
90
|
+
zh repos # local checkouts zh has seen, most-recently-seen first
|
|
91
|
+
zh install-skills # install the zh Claude Code skill
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Add `-w`/`--why` to `zh`, `zh ci`, or `zh pr` to drill into a failing check
|
|
95
|
+
— prints the tail of the failing job's log, right up to the error, instead
|
|
96
|
+
of just a pass/fail icon.
|
|
97
|
+
|
|
98
|
+
`threads`/`reply`/`resolve`/`unresolve` exist because `gh` has no
|
|
99
|
+
subcommand for review threads at all — they go through `gh api graphql`
|
|
100
|
+
directly (`reviewThreads`, `resolveReviewThread`, `unresolveReviewThread`,
|
|
101
|
+
`addPullRequestReviewThreadReply`). Run `zh pr threads` to see each
|
|
102
|
+
thread's id, then act on it:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
zh pr threads # unresolved threads on the current branch's PR
|
|
106
|
+
zh pr reply T_kwDOA... -b "fixed in a1b2c3d" --resolve
|
|
107
|
+
zh pr resolve --all --path src/ # resolve every unresolved thread under src/
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`zh pr check` surfaces what you'd otherwise have to open the PR on the web
|
|
111
|
+
(or find a local clone) to see: the base branch it's targeting (no
|
|
112
|
+
judgment — just shown, since only you know whether it's the wrong one),
|
|
113
|
+
its local checkout if `zh repos` has seen one for that branch — warning if
|
|
114
|
+
that checkout is dirty, since switching branches or pulling there would be
|
|
115
|
+
risky — and whether the title/body references a ticket at all: a GitHub
|
|
116
|
+
issue ref (`#123`, `Fixes #123`), any URL (covers a Jira/Linear/etc. link),
|
|
117
|
+
or a bare Jira-style key (`ABC-123`, flagged with a suggestion to link it
|
|
118
|
+
directly). Exits non-zero if no ticket reference is found.
|
|
119
|
+
|
|
120
|
+
Typically called with one or more PR URLs directly (an owner/repo parsed
|
|
121
|
+
right out of the URL, so it works from anywhere — not just a checkout of
|
|
122
|
+
that repo), rather than a bare number, which only resolves against the
|
|
123
|
+
current directory's repo:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
zh pr check # current branch's PR
|
|
127
|
+
zh pr check https://github.com/acme/widgets/pull/42
|
|
128
|
+
zh pr check https://github.com/.../pull/42 https://github.com/.../pull/43 # a summary block per PR
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`zh pr merge` (and `zh pr ship`, which is the same thing with
|
|
132
|
+
`--delete-branch` on by default) bundles what would otherwise be a
|
|
133
|
+
draft/review-decision/unresolved-thread/CI check spread across several
|
|
134
|
+
calls into one preflight, then merges only if everything's clean:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
zh pr merge # preflight, waiting on any pending checks, then squash-merge
|
|
138
|
+
zh pr ship # same, plus delete the branch afterward
|
|
139
|
+
zh pr merge --no-wait # fail fast instead of polling pending checks
|
|
140
|
+
zh pr merge --force # skip the preflight and merge immediately
|
|
141
|
+
zh pr merge --method rebase --keep-branch
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`zh release` (bare, or `patch`/`minor`/`major`) checks: the active gh
|
|
145
|
+
account matches the repo's owner (same convention `wazup` uses — a
|
|
146
|
+
logged-in account whose login, or the org half of a `name_OrgName`
|
|
147
|
+
corporate-SSO login, matches origin's owner; best-effort and never blocks
|
|
148
|
+
the release if nothing matches), the current branch matches the release
|
|
149
|
+
target, local HEAD matches `origin/<target>` exactly, and CI is green on
|
|
150
|
+
that exact commit (falling back to the branch's latest runs if no run
|
|
151
|
+
exists yet for the commit, and proceeding on judgement if none exist at
|
|
152
|
+
all). A dirty worktree only warns — it isn't part of the release either
|
|
153
|
+
way, since the tag points at HEAD.
|
|
154
|
+
|
|
155
|
+
Past the preflight it's read-only: it never creates anything itself. It
|
|
156
|
+
shows the commit log a release's notes get written from, and prints the
|
|
157
|
+
exact `zh release create ...` command to run once notes are ready. That
|
|
158
|
+
command skips its own preflight by default, so running it right after
|
|
159
|
+
`zh release` doesn't redo the CI wait that was just run here.
|
|
160
|
+
`patch`/`minor`/`major` fetch the latest release tag and compute one
|
|
161
|
+
specific bump; bare `zh release` shows all three:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
zh release # -> zh release create <next patch/minor/major> ...
|
|
165
|
+
zh release patch # -> just the patch bump
|
|
166
|
+
zh release patch --target release-2.0
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`zh release create` is the one command that actually publishes — it
|
|
170
|
+
requires release notes (`-n`/`--notes` or `-F`/`--notes-file` — one of
|
|
171
|
+
them, always), and after creating the GitHub release it also polls for and
|
|
172
|
+
waits on any workflow run(s) triggered by that release (e.g. a PyPI
|
|
173
|
+
publish job), reporting success or failure instead of leaving that for the
|
|
174
|
+
caller to check separately. A repo with no such workflow just proceeds —
|
|
175
|
+
it's not treated as a failure. The preflight is opt-in via `--preflight`,
|
|
176
|
+
for when you're calling it directly without a prior `zh release`:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
zh release create v1.3.0 -n "$(cat notes.md)"
|
|
180
|
+
zh release create v2.0.0-rc1 --prerelease --preflight # check first, since there was no prior `zh release`
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Every command above that needs `gh` also registers the checkout it ran in
|
|
184
|
+
— path, repo, branch — into a local registry at
|
|
185
|
+
`~/.local/share/zithub/repos.jsonl`, ported from wazup's own (a separate
|
|
186
|
+
database, same design). `zh repos` lists what's been seen, letting an
|
|
187
|
+
agent find a local checkout of a repo by name instead of guessing paths or
|
|
188
|
+
re-cloning; two worktrees or clones of the same repo, at different paths,
|
|
189
|
+
show up as separate entries with their own branch:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
zh repos # everything seen, most-recently-seen first
|
|
193
|
+
zh repos zithub # only checkouts whose repo name or path contains "zithub"
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Install
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
uv tool install zithub
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
zithub-0.3.0/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# zithub
|
|
2
|
+
|
|
3
|
+
`zh` — what's up with this repo, right now, plus batched `gh` PR and release
|
|
4
|
+
actions for AI agents. A standalone `gh`/`git` CLI: bare `zh` gives repo,
|
|
5
|
+
branch, local, PR, and CI status in one shot (no separate status tool
|
|
6
|
+
required), and the rest covers what plain `gh` can't already do in a single
|
|
7
|
+
call — acting on PR review-comment threads (no `gh` subcommand exists for
|
|
8
|
+
these at all), merging with a real preflight
|
|
9
|
+
(draft/review-decision/unresolved-threads/CI, checked and merged in one
|
|
10
|
+
call instead of chained separately), and release preflight/creation. The
|
|
11
|
+
status/CI/PR/my/review/issues reporting is ported from
|
|
12
|
+
[wazup](https://github.com/vivainio/wazup), so `zh` doesn't need wazup
|
|
13
|
+
installed alongside it. It deliberately does *not* wrap `gh pr
|
|
14
|
+
create/close/comment/review/edit`, since those are already one `gh` call
|
|
15
|
+
each and an agent that already knows `gh` gains nothing from a second name
|
|
16
|
+
for the same thing.
|
|
17
|
+
|
|
18
|
+
Requires the [GitHub CLI](https://cli.github.com/) (`gh`) installed and
|
|
19
|
+
authenticated (`gh auth login`).
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
zh # prints help (bare zh does nothing)
|
|
25
|
+
zh status # repo, branch, local, PR, and CI status in one shot
|
|
26
|
+
# (also recent branches/worktrees on the default branch)
|
|
27
|
+
zh ci # just the CI status for the current branch/PR
|
|
28
|
+
zh pr # current branch's PR: status, checks, and review comment
|
|
29
|
+
# threads (unresolved by default; --all also shows resolved)
|
|
30
|
+
zh my # your open PRs across all repos, plus recent closed/merged
|
|
31
|
+
zh my --this # ...just for the current repo
|
|
32
|
+
zh stats # most active repos by recent PR+issue activity (--owner, --days, --limit)
|
|
33
|
+
zh review # PRs awaiting your review, updated in the last 7 days
|
|
34
|
+
zh issues # your open issues in this repo, or recent activity outside one
|
|
35
|
+
zh gh <args...> # gh <args...> as this repo owner's gh account (GH_TOKEN; no global switch)
|
|
36
|
+
zh git <args...> # git <args...>; over https, as this repo owner's gh account (same)
|
|
37
|
+
|
|
38
|
+
zh board sync # fetch your open PRs + activity (CI, review, comments) into a local sqlite db
|
|
39
|
+
# (incremental: only new/updated PRs or unsettled CI; --full refetches all)
|
|
40
|
+
zh board # list synced PRs per repo (biggest first), oldest activity first (--stale-days N)
|
|
41
|
+
zh board focus # grouped: fix CI, needs your reply, ready to merge, waiting on others
|
|
42
|
+
# (each grouped per repo; bot comments never count as "needs your reply")
|
|
43
|
+
zh board bots [add|rm X] # extra logins treated as bots on this host, e.g. a CI service account
|
|
44
|
+
zh board query "<SQL>" # ad hoc read-only SQL against the synced db
|
|
45
|
+
# (one board per GitHub site: $GH_HOST, else origin's host, else github.com)
|
|
46
|
+
|
|
47
|
+
zh pr threads # list review-comment threads (with the ids below)
|
|
48
|
+
zh pr reply # reply to a review-comment thread, optionally --resolve
|
|
49
|
+
zh pr resolve # mark thread(s) resolved, by id or --all
|
|
50
|
+
zh pr unresolve # reopen thread(s)
|
|
51
|
+
|
|
52
|
+
zh plan # draft reply/resolve/merge actions into ONE reviewable file
|
|
53
|
+
# (context as comments, all actions commented out): zh plan > plan.md
|
|
54
|
+
zh plan --apply plan.md [--dry-run] # validate everything, then run it in order
|
|
55
|
+
|
|
56
|
+
zh pr check # target branch, and whether the PR references a ticket
|
|
57
|
+
|
|
58
|
+
zh pr merge # merge a PR
|
|
59
|
+
zh pr ship # merge + delete branch (same as `merge --delete-branch`)
|
|
60
|
+
|
|
61
|
+
zh release # preflight, then commits + the gh command for each next version
|
|
62
|
+
zh release patch/minor/major # preflight, then just that one bumped version + gh command
|
|
63
|
+
zh release create # preflight, then actually create at an explicit version
|
|
64
|
+
|
|
65
|
+
zh repos # local checkouts zh has seen, most-recently-seen first
|
|
66
|
+
zh install-skills # install the zh Claude Code skill
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Add `-w`/`--why` to `zh`, `zh ci`, or `zh pr` to drill into a failing check
|
|
70
|
+
— prints the tail of the failing job's log, right up to the error, instead
|
|
71
|
+
of just a pass/fail icon.
|
|
72
|
+
|
|
73
|
+
`threads`/`reply`/`resolve`/`unresolve` exist because `gh` has no
|
|
74
|
+
subcommand for review threads at all — they go through `gh api graphql`
|
|
75
|
+
directly (`reviewThreads`, `resolveReviewThread`, `unresolveReviewThread`,
|
|
76
|
+
`addPullRequestReviewThreadReply`). Run `zh pr threads` to see each
|
|
77
|
+
thread's id, then act on it:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
zh pr threads # unresolved threads on the current branch's PR
|
|
81
|
+
zh pr reply T_kwDOA... -b "fixed in a1b2c3d" --resolve
|
|
82
|
+
zh pr resolve --all --path src/ # resolve every unresolved thread under src/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`zh pr check` surfaces what you'd otherwise have to open the PR on the web
|
|
86
|
+
(or find a local clone) to see: the base branch it's targeting (no
|
|
87
|
+
judgment — just shown, since only you know whether it's the wrong one),
|
|
88
|
+
its local checkout if `zh repos` has seen one for that branch — warning if
|
|
89
|
+
that checkout is dirty, since switching branches or pulling there would be
|
|
90
|
+
risky — and whether the title/body references a ticket at all: a GitHub
|
|
91
|
+
issue ref (`#123`, `Fixes #123`), any URL (covers a Jira/Linear/etc. link),
|
|
92
|
+
or a bare Jira-style key (`ABC-123`, flagged with a suggestion to link it
|
|
93
|
+
directly). Exits non-zero if no ticket reference is found.
|
|
94
|
+
|
|
95
|
+
Typically called with one or more PR URLs directly (an owner/repo parsed
|
|
96
|
+
right out of the URL, so it works from anywhere — not just a checkout of
|
|
97
|
+
that repo), rather than a bare number, which only resolves against the
|
|
98
|
+
current directory's repo:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
zh pr check # current branch's PR
|
|
102
|
+
zh pr check https://github.com/acme/widgets/pull/42
|
|
103
|
+
zh pr check https://github.com/.../pull/42 https://github.com/.../pull/43 # a summary block per PR
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`zh pr merge` (and `zh pr ship`, which is the same thing with
|
|
107
|
+
`--delete-branch` on by default) bundles what would otherwise be a
|
|
108
|
+
draft/review-decision/unresolved-thread/CI check spread across several
|
|
109
|
+
calls into one preflight, then merges only if everything's clean:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
zh pr merge # preflight, waiting on any pending checks, then squash-merge
|
|
113
|
+
zh pr ship # same, plus delete the branch afterward
|
|
114
|
+
zh pr merge --no-wait # fail fast instead of polling pending checks
|
|
115
|
+
zh pr merge --force # skip the preflight and merge immediately
|
|
116
|
+
zh pr merge --method rebase --keep-branch
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`zh release` (bare, or `patch`/`minor`/`major`) checks: the active gh
|
|
120
|
+
account matches the repo's owner (same convention `wazup` uses — a
|
|
121
|
+
logged-in account whose login, or the org half of a `name_OrgName`
|
|
122
|
+
corporate-SSO login, matches origin's owner; best-effort and never blocks
|
|
123
|
+
the release if nothing matches), the current branch matches the release
|
|
124
|
+
target, local HEAD matches `origin/<target>` exactly, and CI is green on
|
|
125
|
+
that exact commit (falling back to the branch's latest runs if no run
|
|
126
|
+
exists yet for the commit, and proceeding on judgement if none exist at
|
|
127
|
+
all). A dirty worktree only warns — it isn't part of the release either
|
|
128
|
+
way, since the tag points at HEAD.
|
|
129
|
+
|
|
130
|
+
Past the preflight it's read-only: it never creates anything itself. It
|
|
131
|
+
shows the commit log a release's notes get written from, and prints the
|
|
132
|
+
exact `zh release create ...` command to run once notes are ready. That
|
|
133
|
+
command skips its own preflight by default, so running it right after
|
|
134
|
+
`zh release` doesn't redo the CI wait that was just run here.
|
|
135
|
+
`patch`/`minor`/`major` fetch the latest release tag and compute one
|
|
136
|
+
specific bump; bare `zh release` shows all three:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
zh release # -> zh release create <next patch/minor/major> ...
|
|
140
|
+
zh release patch # -> just the patch bump
|
|
141
|
+
zh release patch --target release-2.0
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`zh release create` is the one command that actually publishes — it
|
|
145
|
+
requires release notes (`-n`/`--notes` or `-F`/`--notes-file` — one of
|
|
146
|
+
them, always), and after creating the GitHub release it also polls for and
|
|
147
|
+
waits on any workflow run(s) triggered by that release (e.g. a PyPI
|
|
148
|
+
publish job), reporting success or failure instead of leaving that for the
|
|
149
|
+
caller to check separately. A repo with no such workflow just proceeds —
|
|
150
|
+
it's not treated as a failure. The preflight is opt-in via `--preflight`,
|
|
151
|
+
for when you're calling it directly without a prior `zh release`:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
zh release create v1.3.0 -n "$(cat notes.md)"
|
|
155
|
+
zh release create v2.0.0-rc1 --prerelease --preflight # check first, since there was no prior `zh release`
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Every command above that needs `gh` also registers the checkout it ran in
|
|
159
|
+
— path, repo, branch — into a local registry at
|
|
160
|
+
`~/.local/share/zithub/repos.jsonl`, ported from wazup's own (a separate
|
|
161
|
+
database, same design). `zh repos` lists what's been seen, letting an
|
|
162
|
+
agent find a local checkout of a repo by name instead of guessing paths or
|
|
163
|
+
re-cloning; two worktrees or clones of the same repo, at different paths,
|
|
164
|
+
show up as separate entries with their own branch:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
zh repos # everything seen, most-recently-seen first
|
|
168
|
+
zh repos zithub # only checkouts whose repo name or path contains "zithub"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Install
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
uv tool install zithub
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "zithub"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "What's up with this repo, right now, plus batched gh PR/release actions: status, CI, merge preflight, review-thread reply/resolve, and more"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.10"
|
|
@@ -52,3 +52,7 @@ dev = [
|
|
|
52
52
|
|
|
53
53
|
[tool.pytest.ini_options]
|
|
54
54
|
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
[[tool.uv.index]]
|
|
57
|
+
url = "https://pypi.org/simple"
|
|
58
|
+
default = true
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "zithub"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "What's up with this repo, right now, plus batched gh PR/release actions: status, CI, merge preflight, review-thread reply/resolve, and more"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Ville Vainio", email = "vivainio@gmail.com" }
|
|
@@ -44,3 +44,7 @@ dev = [
|
|
|
44
44
|
|
|
45
45
|
[tool.pytest.ini_options]
|
|
46
46
|
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[[tool.uv.index]]
|
|
49
|
+
url = "https://pypi.org/simple"
|
|
50
|
+
default = true
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Local sqlite cache of your open PRs, synced from GitHub by `zh board
|
|
2
|
+
sync` and read by `zh board` / `zh board query`.
|
|
3
|
+
|
|
4
|
+
A cache rather than a live view: `gh` round trips (one `gh pr view` per
|
|
5
|
+
open PR) are too slow to do on every `zh board` invocation, so sync is a
|
|
6
|
+
separate, explicit step, and stagnation ("no activity in N days") is
|
|
7
|
+
computed against whatever was last synced, not against live data.
|
|
8
|
+
|
|
9
|
+
Kept as an actual sqlite file (not zithub's usual JSONL registry format)
|
|
10
|
+
specifically so it can be queried directly — `zh board query "<SQL>"`,
|
|
11
|
+
or any other sqlite client pointed at the file — rather than only through
|
|
12
|
+
whatever views this module thinks to expose.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sqlite3
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from . import gh
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BoardError(Exception):
|
|
25
|
+
"""Raised for bad input to this module (e.g. a non-SELECT query)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _data_dir() -> str:
|
|
29
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
30
|
+
return os.path.join(base, "zithub")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def db_path(host: str) -> str:
|
|
34
|
+
"""One db per GitHub site (github.com, a GHES host, ...), since "your
|
|
35
|
+
open PRs" — and which login is "you" — differ per site."""
|
|
36
|
+
return os.path.join(_data_dir(), "boards", f"{host}.sqlite")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_SCHEMA = """
|
|
40
|
+
CREATE TABLE IF NOT EXISTS prs (
|
|
41
|
+
repo TEXT NOT NULL,
|
|
42
|
+
number INTEGER NOT NULL,
|
|
43
|
+
title TEXT NOT NULL,
|
|
44
|
+
url TEXT NOT NULL,
|
|
45
|
+
is_draft INTEGER NOT NULL,
|
|
46
|
+
review_decision TEXT NOT NULL,
|
|
47
|
+
ci_state TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL,
|
|
49
|
+
updated_at TEXT NOT NULL,
|
|
50
|
+
comment_count INTEGER NOT NULL,
|
|
51
|
+
last_comment_at TEXT,
|
|
52
|
+
last_comment_author TEXT,
|
|
53
|
+
last_activity_at TEXT NOT NULL,
|
|
54
|
+
synced_at TEXT NOT NULL,
|
|
55
|
+
PRIMARY KEY (repo, number)
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
58
|
+
key TEXT PRIMARY KEY,
|
|
59
|
+
value TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS bots (
|
|
62
|
+
login TEXT PRIMARY KEY
|
|
63
|
+
);
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Bump when what a synced row *means* changes (not just its columns), so
|
|
68
|
+
# rows cached under the old meaning get dropped and refetched by the next
|
|
69
|
+
# (otherwise incremental) sync. 1: last_comment_* skip bot comments and
|
|
70
|
+
# titles are HTML-unescaped.
|
|
71
|
+
_CACHE_VERSION = 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _connect(host: str) -> sqlite3.Connection:
|
|
75
|
+
path = db_path(host)
|
|
76
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
77
|
+
conn = sqlite3.connect(path)
|
|
78
|
+
conn.row_factory = sqlite3.Row
|
|
79
|
+
conn.executescript(_SCHEMA)
|
|
80
|
+
if conn.execute("PRAGMA user_version").fetchone()[0] < _CACHE_VERSION:
|
|
81
|
+
with conn:
|
|
82
|
+
conn.execute("DELETE FROM prs")
|
|
83
|
+
conn.execute(f"PRAGMA user_version = {_CACHE_VERSION}")
|
|
84
|
+
return conn
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sync(
|
|
88
|
+
host: str, prs: list[gh.BoardPr], synced_at: str, login: str | None = None
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Replace the table's contents with exactly `prs` — the scope is
|
|
91
|
+
"your currently-open PRs", so a PR merged/closed since the last sync
|
|
92
|
+
should simply disappear rather than linger as a stale row."""
|
|
93
|
+
prune(host, {(p.repo, p.number) for p in prs})
|
|
94
|
+
upsert(host, prs, synced_at)
|
|
95
|
+
if login:
|
|
96
|
+
set_login(host, login)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cached_versions(host: str) -> dict[tuple[str, int], tuple[str, str]]:
|
|
100
|
+
"""(repo, number) -> (updated_at, ci_state) for every cached PR — what
|
|
101
|
+
`zh board sync` compares against to skip PRs that haven't changed."""
|
|
102
|
+
with _connect(host) as conn:
|
|
103
|
+
return {
|
|
104
|
+
(r["repo"], r["number"]): (r["updated_at"], r["ci_state"])
|
|
105
|
+
for r in conn.execute("SELECT repo, number, updated_at, ci_state FROM prs")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prune(host: str, keep: set[tuple[str, int]]) -> None:
|
|
110
|
+
"""Drop every cached PR not in `keep` (i.e. merged/closed since the
|
|
111
|
+
last sync)."""
|
|
112
|
+
with _connect(host) as conn:
|
|
113
|
+
stale = [
|
|
114
|
+
(r["repo"], r["number"])
|
|
115
|
+
for r in conn.execute("SELECT repo, number FROM prs")
|
|
116
|
+
if (r["repo"], r["number"]) not in keep
|
|
117
|
+
]
|
|
118
|
+
conn.executemany("DELETE FROM prs WHERE repo = ? AND number = ?", stale)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def upsert(host: str, prs: list[gh.BoardPr], synced_at: str) -> None:
|
|
122
|
+
"""Insert or refresh `prs` — committed per call, so a sync that fails
|
|
123
|
+
partway keeps every batch it already fetched."""
|
|
124
|
+
rows = [
|
|
125
|
+
(
|
|
126
|
+
p.repo,
|
|
127
|
+
p.number,
|
|
128
|
+
p.title,
|
|
129
|
+
p.url,
|
|
130
|
+
int(p.is_draft),
|
|
131
|
+
p.review_decision,
|
|
132
|
+
p.ci_state,
|
|
133
|
+
p.created_at,
|
|
134
|
+
p.updated_at,
|
|
135
|
+
p.comment_count,
|
|
136
|
+
p.last_comment_at,
|
|
137
|
+
p.last_comment_author,
|
|
138
|
+
max(p.updated_at, p.last_comment_at or ""),
|
|
139
|
+
synced_at,
|
|
140
|
+
)
|
|
141
|
+
for p in prs
|
|
142
|
+
]
|
|
143
|
+
with _connect(host) as conn:
|
|
144
|
+
conn.executemany("INSERT OR REPLACE INTO prs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", rows)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def set_login(host: str, login: str) -> None:
|
|
148
|
+
"""Stash your active gh login so `zh board focus` can tell "you last
|
|
149
|
+
commented" from "someone else did" without its own gh call."""
|
|
150
|
+
with _connect(host) as conn:
|
|
151
|
+
conn.execute(
|
|
152
|
+
"INSERT INTO meta VALUES ('login', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
153
|
+
(login,),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_login(host: str) -> str | None:
|
|
158
|
+
with _connect(host) as conn:
|
|
159
|
+
row = conn.execute("SELECT value FROM meta WHERE key = 'login'").fetchone()
|
|
160
|
+
return row["value"] if row else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def bot_logins(host: str) -> set[str]:
|
|
164
|
+
"""Logins (lowercase) `zh board sync` treats as bots on this host, on
|
|
165
|
+
top of gh's built-in list — org machine users like a CI account."""
|
|
166
|
+
with _connect(host) as conn:
|
|
167
|
+
return {r["login"] for r in conn.execute("SELECT login FROM bots")}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def add_bots(host: str, logins: list[str]) -> None:
|
|
171
|
+
with _connect(host) as conn:
|
|
172
|
+
conn.executemany("INSERT OR IGNORE INTO bots VALUES (?)", [(login.lower(),) for login in logins])
|
|
173
|
+
_mark_all_for_refetch(conn)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def remove_bots(host: str, logins: list[str]) -> None:
|
|
177
|
+
with _connect(host) as conn:
|
|
178
|
+
conn.executemany("DELETE FROM bots WHERE login = ?", [(login.lower(),) for login in logins])
|
|
179
|
+
_mark_all_for_refetch(conn)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _mark_all_for_refetch(conn: sqlite3.Connection) -> None:
|
|
183
|
+
"""Each row's last-comment fields were picked with the old bot list, so
|
|
184
|
+
blank the updated_at that cached_versions() hands to sync — every row
|
|
185
|
+
then looks changed and gets refetched, while still showing meanwhile."""
|
|
186
|
+
conn.execute("UPDATE prs SET updated_at = ''")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class BoardRow:
|
|
191
|
+
repo: str
|
|
192
|
+
number: int
|
|
193
|
+
title: str
|
|
194
|
+
url: str
|
|
195
|
+
is_draft: bool
|
|
196
|
+
review_decision: str
|
|
197
|
+
ci_state: str
|
|
198
|
+
last_activity_at: str
|
|
199
|
+
comment_count: int
|
|
200
|
+
last_comment_author: str | None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def list_board(host: str) -> list[BoardRow]:
|
|
204
|
+
"""Every synced PR, oldest activity first — so a stagnated PR sorts to
|
|
205
|
+
the top without needing a separate "stale" query."""
|
|
206
|
+
with _connect(host) as conn:
|
|
207
|
+
cur = conn.execute(
|
|
208
|
+
"SELECT repo, number, title, url, is_draft, review_decision, ci_state, "
|
|
209
|
+
"last_activity_at, comment_count, last_comment_author "
|
|
210
|
+
"FROM prs ORDER BY last_activity_at ASC"
|
|
211
|
+
)
|
|
212
|
+
return [
|
|
213
|
+
BoardRow(
|
|
214
|
+
repo=r["repo"],
|
|
215
|
+
number=r["number"],
|
|
216
|
+
title=r["title"],
|
|
217
|
+
url=r["url"],
|
|
218
|
+
is_draft=bool(r["is_draft"]),
|
|
219
|
+
review_decision=r["review_decision"],
|
|
220
|
+
ci_state=r["ci_state"],
|
|
221
|
+
last_activity_at=r["last_activity_at"],
|
|
222
|
+
comment_count=r["comment_count"],
|
|
223
|
+
last_comment_author=r["last_comment_author"],
|
|
224
|
+
)
|
|
225
|
+
for r in cur.fetchall()
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def run_query(host: str, sql: str) -> tuple[list[str], list[tuple]]:
|
|
230
|
+
"""Runs a read-only `SELECT`/`WITH` query against the board db and
|
|
231
|
+
returns (column names, rows). Anything else (INSERT/UPDATE/DELETE/etc,
|
|
232
|
+
including via a stacked statement) is rejected — this is meant for ad
|
|
233
|
+
hoc lookups against the synced snapshot, not for editing it."""
|
|
234
|
+
stripped = sql.strip()
|
|
235
|
+
if not stripped or not stripped.lstrip().upper().startswith(("SELECT", "WITH")):
|
|
236
|
+
raise BoardError("only SELECT/WITH queries are allowed")
|
|
237
|
+
with _connect(host) as conn:
|
|
238
|
+
conn.execute("PRAGMA query_only = ON")
|
|
239
|
+
cur = conn.execute(stripped)
|
|
240
|
+
columns = [d[0] for d in cur.description] if cur.description else []
|
|
241
|
+
return columns, cur.fetchall()
|