zithub 0.2.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zithub
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
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
5
  Keywords: git,github,cli,pull-request,ci,gh
6
6
  Author: Ville Vainio
@@ -46,20 +46,42 @@ authenticated (`gh auth login`).
46
46
  ## Commands
47
47
 
48
48
  ```
49
- zh # repo, branch, local, PR, and CI status in one shot
50
- # (also recent branches/worktrees on the default branch)
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, with
52
+ # already-merged branches marked; flags a uv.lock that leaked a
53
+ # private index/mirror, when using a personal gh account)
54
+ zh fixup uv-lock # rewrite uv.lock's private mirror URLs back to public pypi.org
55
+ # (a later `uv lock` re-adds them; rerun after that)
51
56
  zh ci # just the CI status for the current branch/PR
52
57
  zh pr # current branch's PR: status, checks, and review comment
53
58
  # threads (unresolved by default; --all also shows resolved)
54
- zh my # your open PRs in this repo, or recent PR activity outside one
59
+ zh my # your open PRs across all repos, plus recent closed/merged
60
+ zh my --this # ...just for the current repo
61
+ zh stats # most active repos by recent PR+issue activity (--owner, --days, --limit)
55
62
  zh review # PRs awaiting your review, updated in the last 7 days
56
63
  zh issues # your open issues in this repo, or recent activity outside one
64
+ zh gh <args...> # gh <args...> as this repo owner's gh account (GH_TOKEN; no global switch)
65
+ zh git <args...> # git <args...>; over https, as this repo owner's gh account (same)
66
+
67
+ zh board sync # fetch your open PRs + activity (CI, review, comments) into a local sqlite db
68
+ # (incremental: only new/updated PRs or unsettled CI; --full refetches all)
69
+ zh board # list synced PRs per repo (biggest first), oldest activity first (--stale-days N)
70
+ zh board focus # grouped: fix CI, needs your reply, ready to merge, waiting on others
71
+ # (each grouped per repo; bot comments never count as "needs your reply")
72
+ zh board bots [add|rm X] # extra logins treated as bots on this host, e.g. a CI service account
73
+ zh board query "<SQL>" # ad hoc read-only SQL against the synced db
74
+ # (one board per GitHub site: $GH_HOST, else origin's host, else github.com)
57
75
 
58
76
  zh pr threads # list review-comment threads (with the ids below)
59
77
  zh pr reply # reply to a review-comment thread, optionally --resolve
60
78
  zh pr resolve # mark thread(s) resolved, by id or --all
61
79
  zh pr unresolve # reopen thread(s)
62
80
 
81
+ zh plan # draft reply/resolve/merge actions into ONE reviewable file
82
+ # (context as comments, all actions commented out): zh plan > plan.md
83
+ zh plan --apply plan.md [--dry-run] # validate everything, then run it in order
84
+
63
85
  zh pr check # target branch, and whether the PR references a ticket
64
86
 
65
87
  zh pr merge # merge a PR
@@ -21,20 +21,42 @@ authenticated (`gh auth login`).
21
21
  ## Commands
22
22
 
23
23
  ```
24
- zh # repo, branch, local, PR, and CI status in one shot
25
- # (also recent branches/worktrees on the default branch)
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, with
27
+ # already-merged branches marked; flags a uv.lock that leaked a
28
+ # private index/mirror, when using a personal gh account)
29
+ zh fixup uv-lock # rewrite uv.lock's private mirror URLs back to public pypi.org
30
+ # (a later `uv lock` re-adds them; rerun after that)
26
31
  zh ci # just the CI status for the current branch/PR
27
32
  zh pr # current branch's PR: status, checks, and review comment
28
33
  # threads (unresolved by default; --all also shows resolved)
29
- zh my # your open PRs in this repo, or recent PR activity outside one
34
+ zh my # your open PRs across all repos, plus recent closed/merged
35
+ zh my --this # ...just for the current repo
36
+ zh stats # most active repos by recent PR+issue activity (--owner, --days, --limit)
30
37
  zh review # PRs awaiting your review, updated in the last 7 days
31
38
  zh issues # your open issues in this repo, or recent activity outside one
39
+ zh gh <args...> # gh <args...> as this repo owner's gh account (GH_TOKEN; no global switch)
40
+ zh git <args...> # git <args...>; over https, as this repo owner's gh account (same)
41
+
42
+ zh board sync # fetch your open PRs + activity (CI, review, comments) into a local sqlite db
43
+ # (incremental: only new/updated PRs or unsettled CI; --full refetches all)
44
+ zh board # list synced PRs per repo (biggest first), oldest activity first (--stale-days N)
45
+ zh board focus # grouped: fix CI, needs your reply, ready to merge, waiting on others
46
+ # (each grouped per repo; bot comments never count as "needs your reply")
47
+ zh board bots [add|rm X] # extra logins treated as bots on this host, e.g. a CI service account
48
+ zh board query "<SQL>" # ad hoc read-only SQL against the synced db
49
+ # (one board per GitHub site: $GH_HOST, else origin's host, else github.com)
32
50
 
33
51
  zh pr threads # list review-comment threads (with the ids below)
34
52
  zh pr reply # reply to a review-comment thread, optionally --resolve
35
53
  zh pr resolve # mark thread(s) resolved, by id or --all
36
54
  zh pr unresolve # reopen thread(s)
37
55
 
56
+ zh plan # draft reply/resolve/merge actions into ONE reviewable file
57
+ # (context as comments, all actions commented out): zh plan > plan.md
58
+ zh plan --apply plan.md [--dry-run] # validate everything, then run it in order
59
+
38
60
  zh pr check # target branch, and whether the PR references a ticket
39
61
 
40
62
  zh pr merge # merge a PR
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zithub"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
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"
@@ -39,6 +39,7 @@ Issues = "https://github.com/vivainio/zithub/issues"
39
39
 
40
40
  [project.scripts]
41
41
  zh = "zithub:main"
42
+ wazup = "zithub:wazup_main"
42
43
 
43
44
  [build-system]
44
45
  requires = ["uv_build>=0.12.3,<0.13.0"]
@@ -47,9 +48,31 @@ build-backend = "uv_build"
47
48
  [dependency-groups]
48
49
  dev = [
49
50
  "pytest>=8.0.0",
51
+ "ruff>=0.17.0",
50
52
  "ty>=0.0.72",
51
53
  ]
52
54
 
55
+ [tool.ruff]
56
+ line-length = 120
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E",
61
+ "F",
62
+ "I",
63
+ "UP",
64
+ "B",
65
+ "BLE",
66
+ "FURB",
67
+ "PLW",
68
+ "RUF",
69
+ ]
70
+ ignore = [
71
+ "E501",
72
+ "B905",
73
+ "PLW2901",
74
+ ]
75
+
53
76
  [tool.pytest.ini_options]
54
77
  testpaths = ["tests"]
55
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zithub"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
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 = [
@@ -31,6 +31,7 @@ Issues = "https://github.com/vivainio/zithub/issues"
31
31
 
32
32
  [project.scripts]
33
33
  zh = "zithub:main"
34
+ wazup = "zithub:wazup_main"
34
35
 
35
36
  [build-system]
36
37
  requires = ["uv_build>=0.12.3,<0.13.0"]
@@ -39,9 +40,18 @@ build-backend = "uv_build"
39
40
  [dependency-groups]
40
41
  dev = [
41
42
  "pytest>=8.0.0",
43
+ "ruff>=0.17.0",
42
44
  "ty>=0.0.72",
43
45
  ]
44
46
 
47
+ [tool.ruff]
48
+ line-length = 120
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "BLE", "FURB", "PLW", "RUF"]
52
+ # E501: the formatter wraps code; long string literals are fine.
53
+ ignore = ["E501", "B905", "PLW2901"]
54
+
45
55
  [tool.pytest.ini_options]
46
56
  testpaths = ["tests"]
47
57
 
@@ -0,0 +1,3 @@
1
+ from .cli import main, wazup_main
2
+
3
+ __all__ = ["main", "wazup_main"]
@@ -0,0 +1,242 @@
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(host: str, prs: list[gh.BoardPr], synced_at: str, login: str | None = None) -> None:
88
+ """Replace the table's contents with exactly `prs` — the scope is
89
+ "your currently-open PRs", so a PR merged/closed since the last sync
90
+ should simply disappear rather than linger as a stale row."""
91
+ prune(host, {(p.repo, p.number) for p in prs})
92
+ upsert(host, prs, synced_at)
93
+ if login:
94
+ set_login(host, login)
95
+
96
+
97
+ def cached_versions(host: str) -> dict[tuple[str, int], tuple[str, str]]:
98
+ """(repo, number) -> (updated_at, ci_state) for every cached PR — what
99
+ `zh board sync` compares against to skip PRs that haven't changed."""
100
+ with _connect(host) as conn:
101
+ return {
102
+ (r["repo"], r["number"]): (r["updated_at"], r["ci_state"])
103
+ for r in conn.execute("SELECT repo, number, updated_at, ci_state FROM prs")
104
+ }
105
+
106
+
107
+ def prune(host: str, keep: set[tuple[str, int]]) -> None:
108
+ """Drop every cached PR not in `keep` (i.e. merged/closed since the
109
+ last sync)."""
110
+ with _connect(host) as conn:
111
+ stale = [
112
+ (r["repo"], r["number"])
113
+ for r in conn.execute("SELECT repo, number FROM prs")
114
+ if (r["repo"], r["number"]) not in keep
115
+ ]
116
+ conn.executemany("DELETE FROM prs WHERE repo = ? AND number = ?", stale)
117
+
118
+
119
+ def upsert(host: str, prs: list[gh.BoardPr], synced_at: str) -> None:
120
+ """Insert or refresh `prs` — committed per call, so a sync that fails
121
+ partway keeps every batch it already fetched."""
122
+ rows = [
123
+ (
124
+ p.repo,
125
+ p.number,
126
+ p.title,
127
+ p.url,
128
+ int(p.is_draft),
129
+ p.review_decision,
130
+ p.ci_state,
131
+ p.created_at,
132
+ p.updated_at,
133
+ p.comment_count,
134
+ p.last_comment_at,
135
+ p.last_comment_author,
136
+ max(p.updated_at, p.last_comment_at or ""),
137
+ synced_at,
138
+ )
139
+ for p in prs
140
+ ]
141
+ with _connect(host) as conn:
142
+ conn.executemany("INSERT OR REPLACE INTO prs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", rows)
143
+
144
+
145
+ def set_login(host: str, login: str) -> None:
146
+ """Stash your active gh login so `zh board focus` can tell "you last
147
+ commented" from "someone else did" without its own gh call."""
148
+ with _connect(host) as conn:
149
+ conn.execute(
150
+ "INSERT INTO meta VALUES ('login', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
151
+ (login,),
152
+ )
153
+
154
+
155
+ def get_login(host: str) -> str | None:
156
+ with _connect(host) as conn:
157
+ row = conn.execute("SELECT value FROM meta WHERE key = 'login'").fetchone()
158
+ return row["value"] if row else None
159
+
160
+
161
+ def bot_logins(host: str) -> set[str]:
162
+ """Logins (lowercase) `zh board sync` treats as bots on this host, on
163
+ top of gh's built-in list — org machine users like a CI account."""
164
+ with _connect(host) as conn:
165
+ return {r["login"] for r in conn.execute("SELECT login FROM bots")}
166
+
167
+
168
+ def add_bots(host: str, logins: list[str]) -> None:
169
+ with _connect(host) as conn:
170
+ conn.executemany(
171
+ "INSERT OR IGNORE INTO bots VALUES (?)",
172
+ [(login.lower(),) for login in logins],
173
+ )
174
+ _mark_all_for_refetch(conn)
175
+
176
+
177
+ def remove_bots(host: str, logins: list[str]) -> None:
178
+ with _connect(host) as conn:
179
+ conn.executemany("DELETE FROM bots WHERE login = ?", [(login.lower(),) for login in logins])
180
+ _mark_all_for_refetch(conn)
181
+
182
+
183
+ def _mark_all_for_refetch(conn: sqlite3.Connection) -> None:
184
+ """Each row's last-comment fields were picked with the old bot list, so
185
+ blank the updated_at that cached_versions() hands to sync — every row
186
+ then looks changed and gets refetched, while still showing meanwhile."""
187
+ conn.execute("UPDATE prs SET updated_at = ''")
188
+
189
+
190
+ @dataclass
191
+ class BoardRow:
192
+ repo: str
193
+ number: int
194
+ title: str
195
+ url: str
196
+ is_draft: bool
197
+ review_decision: str
198
+ ci_state: str
199
+ last_activity_at: str
200
+ comment_count: int
201
+ last_comment_author: str | None
202
+
203
+
204
+ def list_board(host: str) -> list[BoardRow]:
205
+ """Every synced PR, oldest activity first — so a stagnated PR sorts to
206
+ the top without needing a separate "stale" query."""
207
+ with _connect(host) as conn:
208
+ cur = conn.execute(
209
+ "SELECT repo, number, title, url, is_draft, review_decision, ci_state, "
210
+ "last_activity_at, comment_count, last_comment_author "
211
+ "FROM prs ORDER BY last_activity_at ASC"
212
+ )
213
+ return [
214
+ BoardRow(
215
+ repo=r["repo"],
216
+ number=r["number"],
217
+ title=r["title"],
218
+ url=r["url"],
219
+ is_draft=bool(r["is_draft"]),
220
+ review_decision=r["review_decision"],
221
+ ci_state=r["ci_state"],
222
+ last_activity_at=r["last_activity_at"],
223
+ comment_count=r["comment_count"],
224
+ last_comment_author=r["last_comment_author"],
225
+ )
226
+ for r in cur.fetchall()
227
+ ]
228
+
229
+
230
+ def run_query(host: str, sql: str) -> tuple[list[str], list[tuple]]:
231
+ """Runs a read-only `SELECT`/`WITH` query against the board db and
232
+ returns (column names, rows). Anything else (INSERT/UPDATE/DELETE/etc,
233
+ including via a stacked statement) is rejected — this is meant for ad
234
+ hoc lookups against the synced snapshot, not for editing it."""
235
+ stripped = sql.strip()
236
+ if not stripped or not stripped.lstrip().upper().startswith(("SELECT", "WITH")):
237
+ raise BoardError("only SELECT/WITH queries are allowed")
238
+ with _connect(host) as conn:
239
+ conn.execute("PRAGMA query_only = ON")
240
+ cur = conn.execute(stripped)
241
+ columns = [d[0] for d in cur.description] if cur.description else []
242
+ return columns, cur.fetchall()