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 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.1.0"
4
- description = "Batched gh CLI actions for PR workflows: merge preflight, review-thread reply/resolve, and more in one call"
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.1.0"
4
- description = "Batched gh CLI actions for PR workflows: merge preflight, review-thread reply/resolve, and more in one call"
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()