twgh 0.1.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.
twgh-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: twgh
3
+ Version: 0.1.1
4
+ Summary: Read-only GitHub reviewer CLI: triage, re-review diffs, comment threads
5
+ Author-email: sysid <sysid@gmx.de>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: typer>=0.15.1
9
+ Requires-Dist: rich>=13.0.0
10
+
11
+ # twgh
12
+
13
+ Read-only GitHub reviewer CLI: triage what needs you, re-review only what changed
14
+ since you last looked (force-push robust), and see which of your comments were
15
+ addressed — across github.com and GitHub Enterprise.
16
+
17
+ ## Prerequisites
18
+
19
+ - The GitHub CLI [`gh`](https://cli.github.com), authenticated for each host you
20
+ review on: `gh auth login --hostname <host>`. twgh borrows gh for auth, host
21
+ routing, and transport; it never handles your token.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ make install # uv tool install -e . + bash completion
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ | Command | What |
32
+ |---|---|
33
+ | `twgh status` | Cross-repo rollup of PRs needing your review; ★ marks PRs that moved since your last run. |
34
+ | `twgh diff <ref>` | Diff of the files you commented on, since your last review (`--all` for the whole PR). Force-push robust. |
35
+ | `twgh comments <ref>` | All comments, chronological (`--by <user>`, `--mine`, `--all`). |
36
+ | `twgh threads [<ref>]` | Open conversations with a resolved / changed / untouched verdict. No `<ref>` = global inbox across repos. |
37
+ | `twgh open <ref>` | Open the PR in a browser (for the write-actions twgh stays out of). |
38
+
39
+ `<ref>` is a PR URL, `host/owner/repo#N` (the form `status` prints — paste it
40
+ straight back), `owner/repo#N`, `owner/repo/N`, or a bare number inside a repo.
41
+
42
+ ## Host selection
43
+
44
+ `-H/--gh-host` overrides `$GH_HOST`, which defaults to `github.com`. Works as a
45
+ global option (`twgh -H bmw.ghe.com status`) or per command.
46
+
47
+ ## The re-review verdict
48
+
49
+ `twgh threads` classifies each open conversation by what GitHub knows:
50
+
51
+ - `✓ resolved` — the thread is marked resolved
52
+ - `~ changed` — your anchored code moved since you commented (look with `twgh diff`)
53
+ - `· untouched` — your point still stands as written
54
+
55
+ ## Development
56
+
57
+ ```sh
58
+ make test # pytest + coverage (floor 85%)
59
+ make static-analysis # ruff lint-fix, format, ty
60
+ ```
twgh-0.1.1/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # twgh
2
+
3
+ Read-only GitHub reviewer CLI: triage what needs you, re-review only what changed
4
+ since you last looked (force-push robust), and see which of your comments were
5
+ addressed — across github.com and GitHub Enterprise.
6
+
7
+ ## Prerequisites
8
+
9
+ - The GitHub CLI [`gh`](https://cli.github.com), authenticated for each host you
10
+ review on: `gh auth login --hostname <host>`. twgh borrows gh for auth, host
11
+ routing, and transport; it never handles your token.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ make install # uv tool install -e . + bash completion
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ | Command | What |
22
+ |---|---|
23
+ | `twgh status` | Cross-repo rollup of PRs needing your review; ★ marks PRs that moved since your last run. |
24
+ | `twgh diff <ref>` | Diff of the files you commented on, since your last review (`--all` for the whole PR). Force-push robust. |
25
+ | `twgh comments <ref>` | All comments, chronological (`--by <user>`, `--mine`, `--all`). |
26
+ | `twgh threads [<ref>]` | Open conversations with a resolved / changed / untouched verdict. No `<ref>` = global inbox across repos. |
27
+ | `twgh open <ref>` | Open the PR in a browser (for the write-actions twgh stays out of). |
28
+
29
+ `<ref>` is a PR URL, `host/owner/repo#N` (the form `status` prints — paste it
30
+ straight back), `owner/repo#N`, `owner/repo/N`, or a bare number inside a repo.
31
+
32
+ ## Host selection
33
+
34
+ `-H/--gh-host` overrides `$GH_HOST`, which defaults to `github.com`. Works as a
35
+ global option (`twgh -H bmw.ghe.com status`) or per command.
36
+
37
+ ## The re-review verdict
38
+
39
+ `twgh threads` classifies each open conversation by what GitHub knows:
40
+
41
+ - `✓ resolved` — the thread is marked resolved
42
+ - `~ changed` — your anchored code moved since you commented (look with `twgh diff`)
43
+ - `· untouched` — your point still stands as written
44
+
45
+ ## Development
46
+
47
+ ```sh
48
+ make test # pytest + coverage (floor 85%)
49
+ make static-analysis # ruff lint-fix, format, ty
50
+ ```
@@ -0,0 +1,86 @@
1
+ [project]
2
+ name = "twgh"
3
+ version = "0.1.1"
4
+ description = "Read-only GitHub reviewer CLI: triage, re-review diffs, comment threads"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [{ name = "sysid", email = "sysid@gmx.de" }]
8
+ dependencies = [
9
+ "typer>=0.15.1",
10
+ "rich>=13.0.0",
11
+ ]
12
+
13
+ [project.scripts]
14
+ twgh = "twgh.bin.cli:app"
15
+
16
+ [build-system]
17
+ requires = ["setuptools"]
18
+ build-backend = "setuptools.build_meta"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.uv]
24
+ managed = true
25
+ package = true
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "ruff",
30
+ "ty",
31
+ "pytest",
32
+ "pytest-cov",
33
+ "pytest-mock",
34
+ "pytest-asyncio",
35
+ "bump-my-version",
36
+ "twine",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ markers = [
42
+ "integration: hits the real gh CLI / network (opt-in)",
43
+ "experimentation: never runs in CI",
44
+ ]
45
+ testpaths = ["tests"]
46
+
47
+ [tool.coverage.run]
48
+ source = ["src/twgh"]
49
+ branch = true
50
+ omit = ["tests/*", "**/__main__.py"]
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ fail_under = 85
55
+
56
+ [tool.ruff]
57
+ line-length = 88
58
+ indent-width = 4
59
+ target-version = "py312"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E4", "E7", "E9", "F", "I"]
63
+
64
+ [tool.ruff.format]
65
+ quote-style = "double"
66
+ indent-style = "space"
67
+
68
+ [tool.bumpversion]
69
+ current_version = "0.1.1"
70
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
71
+ serialize = ["{major}.{minor}.{patch}"]
72
+ tag = true
73
+ tag_name = "v{new_version}"
74
+ commit = true
75
+ message = "Bump version: {current_version} → {new_version}"
76
+
77
+ [[tool.bumpversion.files]]
78
+ filename = "VERSION"
79
+ [[tool.bumpversion.files]]
80
+ filename = "pyproject.toml"
81
+ search = "version = \"{current_version}\""
82
+ replace = "version = \"{new_version}\""
83
+ [[tool.bumpversion.files]]
84
+ filename = "src/twgh/__init__.py"
85
+ search = "__version__ = \"{current_version}\""
86
+ replace = "__version__ = \"{new_version}\""
twgh-0.1.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
File without changes
@@ -0,0 +1,316 @@
1
+ """twgh — read-only GitHub reviewer CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Annotated, Optional, cast
8
+
9
+ import typer
10
+
11
+ from twgh import __version__
12
+ from twgh.lib import gh, render, snapshot
13
+ from twgh.lib.domain import (
14
+ AnchorFresh,
15
+ PrRef,
16
+ PrRefError,
17
+ PullRequest,
18
+ SnapshotEntry,
19
+ classify_thread,
20
+ parse_pr_ref,
21
+ resolve_anchor,
22
+ review_state,
23
+ select_diff_paths,
24
+ snapshot_delta,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ app = typer.Typer(
30
+ help="twgh — read-only GitHub reviewer CLI (triage, re-review diffs, threads).",
31
+ )
32
+
33
+ # Shared option types
34
+ HostOption = Annotated[
35
+ Optional[str],
36
+ typer.Option(
37
+ "--gh-host",
38
+ "-H",
39
+ help="GitHub host to query. Overrides $GH_HOST. Defaults to $GH_HOST, "
40
+ "then github.com.",
41
+ ),
42
+ ]
43
+
44
+
45
+ def _version_callback(value: bool) -> None:
46
+ if value:
47
+ typer.echo(f"twgh {__version__}")
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.callback(invoke_without_command=True)
52
+ def main(
53
+ ctx: typer.Context,
54
+ version: Annotated[
55
+ bool,
56
+ typer.Option(
57
+ "--version",
58
+ "-V",
59
+ callback=_version_callback,
60
+ is_eager=True,
61
+ help="Show version and exit.",
62
+ ),
63
+ ] = False,
64
+ verbose: Annotated[
65
+ bool, typer.Option("--verbose", "-v", help="Enable DEBUG logging.")
66
+ ] = False,
67
+ gh_host: Annotated[
68
+ Optional[str],
69
+ typer.Option(
70
+ "--gh-host",
71
+ "-H",
72
+ help="GitHub host to query. Overrides $GH_HOST. Defaults to $GH_HOST, "
73
+ "then github.com.",
74
+ ),
75
+ ] = None,
76
+ ) -> None:
77
+ """Global options."""
78
+ logging.basicConfig(level=logging.DEBUG if verbose else logging.WARNING)
79
+ if gh_host:
80
+ # Subcommands inherit the host via gh.resolve_host, which reads $GH_HOST;
81
+ # setting it here makes the global flag flow down without threading it
82
+ # through every command's own resolution.
83
+ os.environ["GH_HOST"] = gh_host
84
+ if ctx.invoked_subcommand is None:
85
+ typer.echo(ctx.get_help())
86
+ raise typer.Exit()
87
+
88
+
89
+ def _cwd_remote() -> PrRef | None:
90
+ """Best-effort owner/repo/host from the current dir's gh repo context."""
91
+ try:
92
+ data = gh.run_gh_json(["repo", "view", "--json", "owner,name"])
93
+ except gh.GhError:
94
+ return None
95
+ if not isinstance(data, dict):
96
+ return None
97
+ data_map = cast("dict[str, object]", data)
98
+ owner_obj = data_map.get("owner")
99
+ owner = (
100
+ cast("dict[str, object]", owner_obj).get("login")
101
+ if isinstance(owner_obj, dict)
102
+ else None
103
+ )
104
+ name = data_map.get("name")
105
+ if not isinstance(owner, str) or not isinstance(name, str):
106
+ return None
107
+ return PrRef(host=gh.resolve_host(None), owner=owner, repo=name, number=0)
108
+
109
+
110
+ def _resolve_ref(ref: str, host: str | None) -> PrRef:
111
+ try:
112
+ return parse_pr_ref(
113
+ ref, cwd_remote=_cwd_remote(), default_host=gh.resolve_host(host)
114
+ )
115
+ except PrRefError as exc:
116
+ typer.echo(f"error: {exc}", err=True)
117
+ raise typer.Exit(code=2) from exc
118
+
119
+
120
+ @app.command()
121
+ def open( # noqa: A001 (shadowing builtin is fine for a CLI verb)
122
+ ref: str,
123
+ gh_host: HostOption = None,
124
+ ) -> None:
125
+ """Open a PR in the browser (for the write-actions twgh stays out of)."""
126
+ gh.browse(_resolve_ref(ref, gh_host))
127
+
128
+
129
+ @app.command()
130
+ def diff(
131
+ ref: str,
132
+ all_files: Annotated[
133
+ bool,
134
+ typer.Option("--all", help="Whole-PR diff, not just files you commented on."),
135
+ ] = False,
136
+ gh_host: HostOption = None,
137
+ ) -> None:
138
+ """Re-review diff: files you commented on, anchor→HEAD, force-push robust."""
139
+ pr_ref = _resolve_ref(ref, gh_host)
140
+ data = gh.fetch_pr_review_data(pr_ref)
141
+ me = gh.current_login(gh_host)
142
+ # Anchor on YOUR last review/comment, not anyone else's on the PR.
143
+ my_reviews = [r for r in data.reviews if r.author == me]
144
+ my_comments = [c for t in data.threads for c in t.comments if c.author == me]
145
+ anchor = resolve_anchor(my_reviews, my_comments)
146
+ if isinstance(anchor, AnchorFresh):
147
+ typer.echo("No prior review/comment anchor — showing whole PR (fresh).")
148
+ # Fresh: diff is a *re-review* tool; with no anchor there is nothing to
149
+ # re-diff. Guide the user to the browser and exit cleanly.
150
+ typer.echo("Tip: review this PR fresh in the browser with `twgh open`.")
151
+ raise typer.Exit()
152
+ files = gh.compare_files(pr_ref, base=anchor.oid, head=data.head_oid)
153
+ paths = select_diff_paths(my_comments)
154
+ if not all_files and paths:
155
+ files = [f for f in files if f.get("filename") in paths]
156
+ elif not all_files and not paths:
157
+ typer.echo("No inline comments → showing whole PR delta.")
158
+ patch_text = "\n".join(
159
+ f"### {f.get('filename')} ({f.get('status')})\n{f.get('patch', '')}"
160
+ for f in files
161
+ )
162
+ render.page_diff(patch_text)
163
+
164
+
165
+ @app.command()
166
+ def threads(
167
+ ref: Annotated[
168
+ Optional[str],
169
+ typer.Argument(
170
+ help="PR ref. Omit for a global inbox of your open threads across repos."
171
+ ),
172
+ ] = None,
173
+ by: Annotated[
174
+ Optional[str], typer.Option("--by", help="Filter by thread author.")
175
+ ] = None,
176
+ mine: Annotated[bool, typer.Option("--mine", help="Only your threads.")] = False,
177
+ all_threads: Annotated[
178
+ bool, typer.Option("--all", help="Include resolved.")
179
+ ] = False,
180
+ gh_host: HostOption = None,
181
+ ) -> None:
182
+ """Open review conversations with a resolved/changed/untouched verdict."""
183
+ if ref is None:
184
+ host = gh.resolve_host(gh_host)
185
+ me = gh.current_login(gh_host)
186
+ prs = gh.search_review_prs(host=host, me=me)
187
+ inbox_rows: list[dict] = []
188
+ for pr in prs:
189
+ data = gh.fetch_pr_review_data(pr.ref)
190
+ for t in data.threads:
191
+ if not all_threads and t.is_resolved:
192
+ continue
193
+ who = me if mine else by
194
+ if who is not None and t.author != who:
195
+ continue
196
+ inbox_rows.append(
197
+ {
198
+ "verdict": classify_thread(t),
199
+ "path": (
200
+ f"{pr.ref.owner}/{pr.ref.repo}#{pr.ref.number}:{t.path}"
201
+ ),
202
+ "author": t.author,
203
+ "body": t.comments[0].body if t.comments else "",
204
+ }
205
+ )
206
+ render.render_threads(inbox_rows)
207
+ raise typer.Exit()
208
+ pr_ref = _resolve_ref(ref, gh_host)
209
+ data = gh.fetch_pr_review_data(pr_ref)
210
+ who = gh.current_login(gh_host) if mine else by
211
+ rows: list[dict] = []
212
+ for t in data.threads:
213
+ if not all_threads and t.is_resolved:
214
+ continue
215
+ if who is not None and t.author != who:
216
+ continue
217
+ rows.append(
218
+ {
219
+ "verdict": classify_thread(t),
220
+ "path": t.path,
221
+ "author": t.author,
222
+ "body": t.comments[0].body if t.comments else "",
223
+ }
224
+ )
225
+ render.render_threads(rows)
226
+
227
+
228
+ @app.command()
229
+ def comments(
230
+ ref: str,
231
+ by: Annotated[
232
+ Optional[str], typer.Option("--by", help="Filter by comment author.")
233
+ ] = None,
234
+ mine: Annotated[bool, typer.Option("--mine", help="Only your comments.")] = False,
235
+ all_comments: Annotated[
236
+ bool,
237
+ typer.Option("--all", help="Include inline comments in resolved threads."),
238
+ ] = False,
239
+ gh_host: HostOption = None,
240
+ ) -> None:
241
+ """Catch-up stream of all comments on a PR, chronological."""
242
+ pr_ref = _resolve_ref(ref, gh_host)
243
+ data = gh.fetch_pr_review_data(pr_ref)
244
+ who = gh.current_login(gh_host) if mine else by
245
+
246
+ rows: list[dict] = []
247
+ # Issue/PR-level comments always show (no resolved state).
248
+ for c in data.issue_comments:
249
+ rows.append({"author": c.author, "body": c.body, "created_at": c.created_at})
250
+ # Inline comments; hide those in resolved threads unless --all.
251
+ for t in data.threads:
252
+ if t.is_resolved and not all_comments:
253
+ continue
254
+ for c in t.comments:
255
+ rows.append(
256
+ {"author": c.author, "body": c.body, "created_at": c.created_at}
257
+ )
258
+
259
+ if who is not None:
260
+ rows = [r for r in rows if r["author"] == who]
261
+ rows.sort(key=lambda r: r["created_at"])
262
+ render.render_comments(rows)
263
+
264
+
265
+ def _pr_key(pr: PullRequest) -> str:
266
+ r = pr.ref
267
+ return f"{r.host}/{r.owner}/{r.repo}#{r.number}"
268
+
269
+
270
+ @app.command()
271
+ def status(
272
+ json_out: Annotated[
273
+ bool, typer.Option("--json", help="Machine-readable output.")
274
+ ] = False,
275
+ gh_host: HostOption = None,
276
+ ) -> None:
277
+ """Cross-repo rollup of PRs that need your review attention."""
278
+ host = gh.resolve_host(gh_host)
279
+ me = gh.current_login(gh_host)
280
+ prs = gh.search_review_prs(host=host, me=me)
281
+
282
+ current = {
283
+ _pr_key(pr): SnapshotEntry(pr.head_oid, pr.open_thread_count, pr.updated_at)
284
+ for pr in prs
285
+ }
286
+ prev = snapshot.load_snapshot()
287
+ new_keys = snapshot_delta(prev, current)
288
+ snapshot.save_snapshot(current)
289
+
290
+ rows = [
291
+ {
292
+ "ref": _pr_key(pr),
293
+ "title": pr.title,
294
+ "author": pr.author,
295
+ "review_state": review_state(pr.my_review, pr.head_oid),
296
+ "open_threads": pr.open_thread_count,
297
+ "is_new": _pr_key(pr) in new_keys,
298
+ }
299
+ for pr in prs
300
+ ]
301
+ if json_out:
302
+ render.render_json(
303
+ [
304
+ {
305
+ "ref": _pr_key(pr),
306
+ "title": pr.title,
307
+ "author": pr.author,
308
+ "review_state": review_state(pr.my_review, pr.head_oid).value,
309
+ "open_threads": pr.open_thread_count,
310
+ "is_new": _pr_key(pr) in new_keys,
311
+ }
312
+ for pr in prs
313
+ ]
314
+ )
315
+ else:
316
+ render.render_status(rows)
File without changes
@@ -0,0 +1,215 @@
1
+ """Pure domain core for twgh: dataclasses and pure functions only.
2
+
3
+ No I/O, no subprocess, stdlib only. This is where the force-push-robustness
4
+ logic lives so it can be unit-tested without invoking `gh`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+
13
+
14
+ class PrRefError(ValueError):
15
+ """Raised when a PR reference string cannot be resolved to a PrRef."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PrRef:
20
+ host: str
21
+ owner: str
22
+ repo: str
23
+ number: int
24
+
25
+
26
+ _URL_RE = re.compile(
27
+ r"^https?://(?P<host>[^/]+)/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pull/(?P<number>\d+)"
28
+ )
29
+ # host/owner/repo#N (or /N) — the exact form `twgh status` prints, so a status
30
+ # row pastes straight into diff/comments. Three path segments before the number.
31
+ _HOST_OWNER_REPO_RE = re.compile(
32
+ r"^(?P<host>[^/]+)/(?P<owner>[^/]+)/(?P<repo>[^/#]+)[#/](?P<number>\d+)$"
33
+ )
34
+ _OWNER_REPO_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/#]+)[#/](?P<number>\d+)$")
35
+ _BARE_RE = re.compile(r"^(?P<number>\d+)$")
36
+
37
+
38
+ def parse_pr_ref(
39
+ s: str,
40
+ cwd_remote: PrRef | None,
41
+ default_host: str = "github.com",
42
+ ) -> PrRef:
43
+ """Resolve a PR reference string.
44
+
45
+ Accepts a full pull URL, ``host/owner/repo#N`` (the form ``status``
46
+ prints), ``owner/repo#N``, ``owner/repo/N``, or a bare ``N`` (which
47
+ requires ``cwd_remote`` to supply host/owner/repo).
48
+ """
49
+ s = s.strip()
50
+ if m := _URL_RE.match(s):
51
+ return PrRef(m["host"], m["owner"], m["repo"], int(m["number"]))
52
+ if m := _HOST_OWNER_REPO_RE.match(s):
53
+ return PrRef(m["host"], m["owner"], m["repo"], int(m["number"]))
54
+ if m := _OWNER_REPO_RE.match(s):
55
+ return PrRef(default_host, m["owner"], m["repo"], int(m["number"]))
56
+ if m := _BARE_RE.match(s):
57
+ if cwd_remote is None:
58
+ raise PrRefError(
59
+ f"Bare PR number '{s}' needs a repo context; run inside a git "
60
+ "repo or use owner/repo#N or a full URL."
61
+ )
62
+ return PrRef(cwd_remote.host, cwd_remote.owner, cwd_remote.repo, int(s))
63
+ raise PrRefError(
64
+ f"Unrecognized PR reference '{s}'. Use a URL, host/owner/repo#N, "
65
+ "owner/repo#N, owner/repo/N, or a bare number inside a repo."
66
+ )
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Review:
71
+ commit_oid: str | None
72
+ state: str # APPROVED | CHANGES_REQUESTED | COMMENTED | PENDING | DISMISSED
73
+ submitted_at: str | None
74
+ author: str = "" # login of the reviewer; used to scope the anchor to "me"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class PullRequest:
79
+ ref: PrRef
80
+ title: str
81
+ author: str
82
+ head_oid: str
83
+ updated_at: str
84
+ my_review: Review | None
85
+ open_thread_count: int
86
+ review_decision: str | None
87
+ ci_state: str | None
88
+ mergeable: str | None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class ReviewComment:
93
+ author: str
94
+ path: str | None
95
+ original_commit_oid: str | None
96
+ body: str
97
+ created_at: str
98
+ is_outdated: bool
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class AnchorSha:
103
+ oid: str
104
+ source: str # "REVIEW" | "COMMENT"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class AnchorFresh:
109
+ pass
110
+
111
+
112
+ Anchor = AnchorSha | AnchorFresh
113
+
114
+
115
+ def resolve_anchor(
116
+ reviews: list[Review],
117
+ comments: list[ReviewComment],
118
+ ) -> Anchor:
119
+ """Resolve the re-review anchor commit.
120
+
121
+ Priority: most recent *submitted* review with a commit, else most recent
122
+ comment with a commit, else fresh (never reviewed).
123
+ """
124
+ submitted = [
125
+ (r.commit_oid, r.submitted_at)
126
+ for r in reviews
127
+ if r.commit_oid and r.submitted_at
128
+ ]
129
+ if submitted:
130
+ oid, _ = max(submitted, key=lambda pair: pair[1])
131
+ return AnchorSha(oid=oid, source="REVIEW")
132
+ with_commit = [
133
+ (c.original_commit_oid, c.created_at) for c in comments if c.original_commit_oid
134
+ ]
135
+ if with_commit:
136
+ oid, _ = max(with_commit, key=lambda pair: pair[1])
137
+ return AnchorSha(oid=oid, source="COMMENT")
138
+ return AnchorFresh()
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Thread:
143
+ author: str
144
+ path: str | None
145
+ is_resolved: bool
146
+ is_outdated: bool
147
+ comments: list[ReviewComment]
148
+
149
+
150
+ class Verdict(Enum):
151
+ RESOLVED = "resolved"
152
+ CHANGED = "changed"
153
+ UNTOUCHED = "untouched"
154
+
155
+
156
+ def classify_thread(thread: Thread) -> Verdict:
157
+ """Classify a review thread by what GitHub knows about it.
158
+
159
+ Resolved beats everything; otherwise a thread whose anchored code has
160
+ moved (any comment outdated, or the thread flagged outdated) is CHANGED;
161
+ otherwise the point still stands as written.
162
+ """
163
+ if thread.is_resolved:
164
+ return Verdict.RESOLVED
165
+ if thread.is_outdated or any(c.is_outdated for c in thread.comments):
166
+ return Verdict.CHANGED
167
+ return Verdict.UNTOUCHED
168
+
169
+
170
+ class ReviewState(Enum):
171
+ UP_TO_DATE = "up_to_date"
172
+ NEEDS_REVIEW = "needs_review"
173
+ NEEDS_RE_REVIEW = "needs_re_review"
174
+
175
+
176
+ def review_state(my_review: Review | None, head_oid: str) -> ReviewState:
177
+ """Determine whether the reviewer still owes a (re-)review of HEAD."""
178
+ if my_review is None or my_review.commit_oid is None:
179
+ return ReviewState.NEEDS_REVIEW
180
+ if my_review.commit_oid == head_oid:
181
+ return ReviewState.UP_TO_DATE
182
+ return ReviewState.NEEDS_RE_REVIEW
183
+
184
+
185
+ def select_diff_paths(my_comments: list[ReviewComment]) -> set[str]:
186
+ """Distinct file paths the reviewer left inline comments on."""
187
+ return {c.path for c in my_comments if c.path}
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class SnapshotEntry:
192
+ head_oid: str
193
+ comment_count: int
194
+ seen_at: str
195
+
196
+
197
+ def snapshot_delta(
198
+ prev: dict[str, SnapshotEntry],
199
+ current: dict[str, SnapshotEntry],
200
+ ) -> set[str]:
201
+ """Keys in ``current`` that are new or moved since ``prev``.
202
+
203
+ First run (empty ``prev``) deliberately flags nothing, so the first
204
+ `status` is not a wall of stars.
205
+ """
206
+ if not prev:
207
+ return set()
208
+ changed: set[str] = set()
209
+ for key, cur in current.items():
210
+ old = prev.get(key)
211
+ if old is None:
212
+ changed.add(key)
213
+ elif cur.head_oid != old.head_oid or cur.comment_count > old.comment_count:
214
+ changed.add(key)
215
+ return changed
@@ -0,0 +1,347 @@
1
+ """GitHub I/O boundary. The only module that shells out to the `gh` CLI.
2
+
3
+ We borrow gh for auth, host routing, and transport; we own the query content.
4
+ No token ever passes through this code — gh holds it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from typing import cast
15
+
16
+ from twgh.lib.domain import PrRef, PullRequest, Review, ReviewComment, Thread
17
+ from twgh.lib.queries import PR_REVIEW_DATA, SEARCH_REVIEW_PRS
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class GhError(RuntimeError):
23
+ """Base class for gh invocation failures."""
24
+
25
+
26
+ class GhNotFoundError(GhError):
27
+ """The `gh` CLI is not installed / not on PATH."""
28
+
29
+
30
+ class GhAuthError(GhError):
31
+ """Not authenticated for the target host."""
32
+
33
+
34
+ class HostUnreachableError(GhError):
35
+ """The target host could not be reached (network / VPN)."""
36
+
37
+
38
+ class GhApiError(GhError):
39
+ """gh returned a non-zero exit for an API call."""
40
+
41
+
42
+ def resolve_host(flag: str | None) -> str:
43
+ """Resolve the GitHub host: flag > $GH_HOST > github.com."""
44
+ if flag:
45
+ return flag
46
+ return os.environ.get("GH_HOST") or "github.com"
47
+
48
+
49
+ def _classify_stderr(stderr: str) -> type[GhError]:
50
+ low = stderr.lower()
51
+ if "auth" in low and ("login" in low or "not logged" in low or "token" in low):
52
+ return GhAuthError
53
+ if "could not resolve host" in low or "connection" in low or "timeout" in low:
54
+ return HostUnreachableError
55
+ return GhApiError
56
+
57
+
58
+ def run_gh_json(args: list[str], host: str | None = None) -> object:
59
+ """Run `gh <args>` (optionally for a host) and parse JSON stdout.
60
+
61
+ Raises a typed GhError subclass on failure.
62
+ """
63
+ argv = ["gh", *args]
64
+ env = dict(os.environ)
65
+ if host:
66
+ env["GH_HOST"] = host
67
+ try:
68
+ proc = subprocess.run(argv, capture_output=True, text=True, env=env)
69
+ except FileNotFoundError as exc:
70
+ raise GhNotFoundError(
71
+ "gh CLI not found — install from https://cli.github.com"
72
+ ) from exc
73
+ if proc.returncode != 0:
74
+ err_cls = _classify_stderr(proc.stderr)
75
+ raise err_cls(proc.stderr.strip() or f"gh exited {proc.returncode}")
76
+ return json.loads(proc.stdout) if proc.stdout.strip() else None
77
+
78
+
79
+ def graphql(
80
+ query: str, variables: dict[str, object], host: str | None = None
81
+ ) -> object:
82
+ """Run a GraphQL query via `gh api graphql`.
83
+
84
+ String variables are forced with ``-f`` so a value that merely *looks*
85
+ numeric/boolean (a repo named "123", a cursor) is not coerced into the
86
+ wrong GraphQL type; only real ints go through ``-F`` for numeric typing.
87
+ """
88
+ args = ["api", "graphql", "-f", f"query={query}"]
89
+ for key, value in variables.items():
90
+ if isinstance(value, bool):
91
+ args += ["-F", f"{key}={str(value).lower()}"]
92
+ elif isinstance(value, int):
93
+ args += ["-F", f"{key}={value}"]
94
+ else:
95
+ args += ["-f", f"{key}={value}"]
96
+ return run_gh_json(args, host=host)
97
+
98
+
99
+ def compare_files(ref: PrRef, base: str, head: str) -> list[dict]:
100
+ """Return per-file patches from the REST compare endpoint, all pages.
101
+
102
+ Both SHAs resolve server-side, so a force-pushed-away ``base`` still works
103
+ as long as a comment references it. ``--paginate`` follows Link headers so
104
+ large PRs are not silently truncated; ``--slurp`` wraps the pages into a
105
+ JSON array we flatten.
106
+ """
107
+ path = f"repos/{ref.owner}/{ref.repo}/compare/{base}...{head}"
108
+ payload = run_gh_json(["api", "--paginate", "--slurp", path], host=ref.host)
109
+ pages = payload if isinstance(payload, list) else [payload]
110
+ files: list[dict] = []
111
+ for page in pages:
112
+ page_files = _as_dict(page).get("files")
113
+ if isinstance(page_files, list):
114
+ files += [f for f in page_files if isinstance(f, dict)]
115
+ return files
116
+
117
+
118
+ def current_login(host: str | None = None) -> str:
119
+ """Return the authenticated user's login for the host."""
120
+ data = run_gh_json(["api", "user", "--jq", "{login: .login}"], host=host)
121
+ login = _as_dict(data).get("login")
122
+ if isinstance(login, str) and login:
123
+ return login
124
+ raise GhApiError("could not determine current gh user")
125
+
126
+
127
+ def browse(ref: PrRef) -> None:
128
+ """Open the PR in a browser via `gh browse`."""
129
+ url = f"https://{ref.host}/{ref.owner}/{ref.repo}/pull/{ref.number}"
130
+ argv = ["gh", "browse", "--repo", f"{ref.owner}/{ref.repo}", str(ref.number)]
131
+ env = dict(os.environ)
132
+ env["GH_HOST"] = ref.host
133
+ try:
134
+ proc = subprocess.run(argv, capture_output=True, text=True, env=env)
135
+ except FileNotFoundError as exc:
136
+ raise GhNotFoundError("gh CLI not found") from exc
137
+ if proc.returncode != 0:
138
+ # Fall back to printing the URL the user can click.
139
+ print(url)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class PrReviewData:
144
+ head_oid: str
145
+ reviews: list[Review]
146
+ threads: list[Thread]
147
+ issue_comments: list[ReviewComment]
148
+
149
+
150
+ def _as_dict(value: object) -> dict[str, object]:
151
+ """Narrow an arbitrary JSON value to a dict (empty when it isn't one)."""
152
+ return cast("dict[str, object]", value) if isinstance(value, dict) else {}
153
+
154
+
155
+ def _nodes(value: object) -> list[dict[str, object]]:
156
+ """Extract the ``nodes`` list of a GraphQL connection, dicts only."""
157
+ nodes = _as_dict(value).get("nodes")
158
+ if not isinstance(nodes, list):
159
+ return []
160
+ return [cast("dict[str, object]", n) for n in nodes if isinstance(n, dict)]
161
+
162
+
163
+ def _str_or(value: object, default: str) -> str:
164
+ return value if isinstance(value, str) else default
165
+
166
+
167
+ def _login(node: dict[str, object]) -> str:
168
+ return _str_or(_as_dict(node.get("author")).get("login"), "?")
169
+
170
+
171
+ def _has_more(connection: object) -> bool:
172
+ """True if a GraphQL connection has an unfetched next page."""
173
+ return bool(_as_dict(_as_dict(connection).get("pageInfo")).get("hasNextPage"))
174
+
175
+
176
+ def _comment_from_node(node: dict[str, object]) -> ReviewComment:
177
+ path = node.get("path")
178
+ oid = _as_dict(node.get("originalCommit")).get("oid")
179
+ return ReviewComment(
180
+ author=_login(node),
181
+ path=path if isinstance(path, str) else None,
182
+ original_commit_oid=oid if isinstance(oid, str) else None,
183
+ body=_str_or(node.get("body"), ""),
184
+ created_at=_str_or(node.get("createdAt"), ""),
185
+ is_outdated=bool(node.get("outdated", False)),
186
+ )
187
+
188
+
189
+ def fetch_pr_review_data(ref: PrRef) -> PrReviewData:
190
+ """Fetch reviews, threads, and issue comments for one PR via GraphQL."""
191
+ raw = graphql(
192
+ PR_REVIEW_DATA,
193
+ {"owner": ref.owner, "repo": ref.repo, "number": ref.number},
194
+ host=ref.host,
195
+ )
196
+ pr = _as_dict(_as_dict(_as_dict(raw).get("data")).get("repository")).get(
197
+ "pullRequest"
198
+ )
199
+ pr_map = _as_dict(pr)
200
+
201
+ if (
202
+ _has_more(pr_map.get("reviews"))
203
+ or _has_more(pr_map.get("reviewThreads"))
204
+ or _has_more(pr_map.get("comments"))
205
+ ):
206
+ logger.warning(
207
+ "%s/%s#%s has more reviews/threads/comments than the 100 fetched; "
208
+ "results may be partial.",
209
+ ref.owner,
210
+ ref.repo,
211
+ ref.number,
212
+ )
213
+
214
+ reviews = [
215
+ Review(
216
+ commit_oid=(
217
+ oid
218
+ if isinstance(oid := _as_dict(n.get("commit")).get("oid"), str)
219
+ else None
220
+ ),
221
+ state=_str_or(n.get("state"), ""),
222
+ submitted_at=(sa if isinstance(sa := n.get("submittedAt"), str) else None),
223
+ author=_login(n),
224
+ )
225
+ for n in _nodes(pr_map.get("reviews"))
226
+ ]
227
+
228
+ threads: list[Thread] = []
229
+ for t in _nodes(pr_map.get("reviewThreads")):
230
+ comment_nodes = _nodes(t.get("comments"))
231
+ first = comment_nodes[0] if comment_nodes else {}
232
+ first_path = first.get("path")
233
+ threads.append(
234
+ Thread(
235
+ author=_login(first),
236
+ path=first_path if isinstance(first_path, str) else None,
237
+ is_resolved=bool(t.get("isResolved")),
238
+ is_outdated=bool(t.get("isOutdated")),
239
+ comments=[_comment_from_node(c) for c in comment_nodes],
240
+ )
241
+ )
242
+
243
+ issue_comments = [
244
+ ReviewComment(
245
+ author=_login(n),
246
+ path=None,
247
+ original_commit_oid=None,
248
+ body=_str_or(n.get("body"), ""),
249
+ created_at=_str_or(n.get("createdAt"), ""),
250
+ is_outdated=False,
251
+ )
252
+ for n in _nodes(pr_map.get("comments"))
253
+ ]
254
+
255
+ return PrReviewData(
256
+ head_oid=_str_or(pr_map.get("headRefOid"), ""),
257
+ reviews=reviews,
258
+ threads=threads,
259
+ issue_comments=issue_comments,
260
+ )
261
+
262
+
263
+ _SEARCH_QUALIFIERS = (
264
+ "is:open is:pr review-requested:@me",
265
+ "is:open is:pr commenter:@me",
266
+ "is:open is:pr reviewed-by:@me",
267
+ )
268
+
269
+
270
+ def _str_or_none(value: object) -> str | None:
271
+ return value if isinstance(value, str) else None
272
+
273
+
274
+ def _thread_author(thread: dict[str, object]) -> str:
275
+ """Login of the thread opener (its first comment's author), or '?'."""
276
+ comment_nodes = _nodes(thread.get("comments"))
277
+ return _login(comment_nodes[0]) if comment_nodes else "?"
278
+
279
+
280
+ def _pr_from_search_node(node: dict[str, object], host: str, me: str) -> PullRequest:
281
+ owner_repo = _str_or(_as_dict(node.get("repository")).get("nameWithOwner"), "/")
282
+ owner, name = owner_repo.split("/", 1)
283
+ number = node.get("number")
284
+ ref = PrRef(
285
+ host=host,
286
+ owner=owner,
287
+ repo=name,
288
+ number=number if isinstance(number, int) else 0,
289
+ )
290
+ my_reviews = [
291
+ Review(
292
+ commit_oid=_str_or_none(_as_dict(r.get("commit")).get("oid")),
293
+ state=_str_or(r.get("state"), ""),
294
+ submitted_at=_str_or_none(r.get("submittedAt")),
295
+ )
296
+ for r in _nodes(node.get("reviews"))
297
+ if _login(r) == me
298
+ ]
299
+ my_review = max(my_reviews, key=lambda r: r.submitted_at or "", default=None)
300
+ # "Your open threads": unresolved threads YOU opened (first comment is yours),
301
+ # not every unresolved thread on the PR.
302
+ open_threads = sum(
303
+ 1
304
+ for t in _nodes(node.get("reviewThreads"))
305
+ if not t.get("isResolved") and _thread_author(t) == me
306
+ )
307
+ commit_nodes = _nodes(node.get("commits"))
308
+ rollup = _as_dict(
309
+ _as_dict((commit_nodes[0] if commit_nodes else {}).get("commit")).get(
310
+ "statusCheckRollup"
311
+ )
312
+ )
313
+ return PullRequest(
314
+ ref=ref,
315
+ title=_str_or(node.get("title"), ""),
316
+ author=_login(node),
317
+ head_oid=_str_or(node.get("headRefOid"), ""),
318
+ updated_at=_str_or(node.get("updatedAt"), ""),
319
+ my_review=my_review,
320
+ open_thread_count=open_threads,
321
+ review_decision=_str_or_none(node.get("reviewDecision")),
322
+ ci_state=_str_or_none(rollup.get("state")),
323
+ mergeable=_str_or_none(node.get("mergeable")),
324
+ )
325
+
326
+
327
+ def search_review_prs(host: str, me: str) -> list[PullRequest]:
328
+ """Find open PRs review-requested-from / commented-by / reviewed-by the user."""
329
+ found: dict[str, PullRequest] = {}
330
+ for qualifier in _SEARCH_QUALIFIERS:
331
+ after: str | None = None
332
+ while True:
333
+ variables: dict[str, object] = {"q": qualifier}
334
+ if after:
335
+ variables["after"] = after
336
+ raw = graphql(SEARCH_REVIEW_PRS, variables, host=host)
337
+ search = _as_dict(_as_dict(_as_dict(raw).get("data")).get("search"))
338
+ for node in _nodes(search):
339
+ pr = _pr_from_search_node(node, host, me)
340
+ key = f"{pr.ref.host}/{pr.ref.owner}/{pr.ref.repo}#{pr.ref.number}"
341
+ found[key] = pr
342
+ page = _as_dict(search.get("pageInfo"))
343
+ if page.get("hasNextPage"):
344
+ after = _str_or_none(page.get("endCursor"))
345
+ else:
346
+ break
347
+ return list(found.values())
@@ -0,0 +1,49 @@
1
+ """GraphQL query strings. The CLI owns the query content; gh owns transport."""
2
+
3
+ SEARCH_REVIEW_PRS = """
4
+ query($q: String!, $after: String) {
5
+ search(query: $q, type: ISSUE, first: 50, after: $after) {
6
+ pageInfo { hasNextPage endCursor }
7
+ nodes {
8
+ ... on PullRequest {
9
+ number title updatedAt mergeable reviewDecision
10
+ author { login }
11
+ repository { nameWithOwner }
12
+ headRefOid
13
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
14
+ reviewThreads(first: 100) {
15
+ nodes { isResolved comments(first: 1) { nodes { author { login } } } }
16
+ }
17
+ reviews(first: 100) { nodes { author { login } state commit { oid } submittedAt } }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ """
23
+
24
+ PR_REVIEW_DATA = """
25
+ query($owner: String!, $repo: String!, $number: Int!) {
26
+ repository(owner: $owner, name: $repo) {
27
+ pullRequest(number: $number) {
28
+ headRefOid
29
+ reviews(first: 100) {
30
+ pageInfo { hasNextPage }
31
+ nodes { author { login } state commit { oid } submittedAt }
32
+ }
33
+ reviewThreads(first: 100) {
34
+ pageInfo { hasNextPage }
35
+ nodes {
36
+ isResolved isOutdated
37
+ comments(first: 100) {
38
+ nodes { author { login } path originalCommit { oid } body createdAt outdated }
39
+ }
40
+ }
41
+ }
42
+ comments(first: 100) {
43
+ pageInfo { hasNextPage }
44
+ nodes { author { login } body createdAt }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ """
@@ -0,0 +1,101 @@
1
+ """Rendering: Rich tables for humans, JSON for scripts. Only module using rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from twgh.lib.domain import ReviewState, Verdict
15
+
16
+ VERDICT_GLYPH = {
17
+ Verdict.RESOLVED: "✓",
18
+ Verdict.CHANGED: "~",
19
+ Verdict.UNTOUCHED: "·",
20
+ }
21
+
22
+ _REVIEW_STATE_LABEL = {
23
+ ReviewState.UP_TO_DATE: "up to date",
24
+ ReviewState.NEEDS_REVIEW: "needs review",
25
+ ReviewState.NEEDS_RE_REVIEW: "needs re-review",
26
+ }
27
+
28
+ _default_console = Console()
29
+
30
+
31
+ def render_json(data: object) -> None:
32
+ """Emit machine-readable JSON to stdout."""
33
+ print(_json.dumps(data, indent=2, default=str))
34
+
35
+
36
+ def render_threads(rows: list[dict], console: Console | None = None) -> None:
37
+ """Render review threads with their three-state verdict glyph."""
38
+ console = console or _default_console
39
+ table = Table(show_header=True)
40
+ table.add_column("")
41
+ table.add_column("file")
42
+ table.add_column("by")
43
+ table.add_column("comment")
44
+ for row in rows:
45
+ glyph = VERDICT_GLYPH[row["verdict"]]
46
+ table.add_row(glyph, row.get("path") or "(PR)", row["author"], row["body"])
47
+ console.print(table)
48
+
49
+
50
+ def render_status(rows: list[dict], console: Console | None = None) -> None:
51
+ """Render the cross-repo status rollup."""
52
+ console = console or _default_console
53
+ table = Table(show_header=True)
54
+ table.add_column("") # ★ NEW marker
55
+ table.add_column("PR")
56
+ table.add_column("title")
57
+ table.add_column("author")
58
+ table.add_column("state")
59
+ table.add_column("threads", justify="right")
60
+ for row in rows:
61
+ marker = "★" if row.get("is_new") else ""
62
+ table.add_row(
63
+ marker,
64
+ row["ref"],
65
+ row["title"],
66
+ row["author"],
67
+ _REVIEW_STATE_LABEL[row["review_state"]],
68
+ str(row["open_threads"]),
69
+ )
70
+ console.print(table)
71
+
72
+
73
+ def render_comments(rows: list[dict], console: Console | None = None) -> None:
74
+ """Render a chronological comment stream."""
75
+ console = console or _default_console
76
+ for row in rows:
77
+ console.print(f"[bold]{row['author']}[/bold] [dim]{row['created_at']}[/dim]")
78
+ console.print(row["body"])
79
+ console.print()
80
+
81
+
82
+ def choose_pager(env: dict[str, str]) -> list[str]:
83
+ """Pick the diff pager: delta if installed, else $PAGER, else `less -R`."""
84
+ if shutil.which("delta"):
85
+ return ["delta"]
86
+ pager = env.get("PAGER")
87
+ if pager:
88
+ return pager.split()
89
+ return ["less", "-R"]
90
+
91
+
92
+ def page_diff(patch_text: str, env: dict[str, str] | None = None) -> None:
93
+ """Send unified-diff text to the chosen pager, or stdout when not a TTY."""
94
+ env = env if env is not None else dict(os.environ)
95
+ if not sys.stdout.isatty():
96
+ print(patch_text)
97
+ return
98
+ pager = choose_pager(env)
99
+ proc = subprocess.run(pager, input=patch_text, text=True)
100
+ if proc.returncode not in (0, None):
101
+ print(patch_text)
@@ -0,0 +1,35 @@
1
+ """JSON snapshot persistence for the NEW-since-last-run highlight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+
10
+ from twgh.lib.domain import SnapshotEntry
11
+
12
+
13
+ def snapshot_path() -> Path:
14
+ """Location of the snapshot file: $XDG_STATE_HOME/twgh or ~/.local/state/twgh."""
15
+ base = os.environ.get("XDG_STATE_HOME")
16
+ root = Path(base) if base else Path(os.environ["HOME"]) / ".local" / "state"
17
+ return root / "twgh" / "snapshot.json"
18
+
19
+
20
+ def load_snapshot(path: Path | None = None) -> dict[str, SnapshotEntry]:
21
+ """Load the snapshot; missing or corrupt file → empty (first-run guard)."""
22
+ path = path or snapshot_path()
23
+ try:
24
+ raw = json.loads(path.read_text())
25
+ except (FileNotFoundError, json.JSONDecodeError):
26
+ return {}
27
+ return {k: SnapshotEntry(**v) for k, v in raw.items()}
28
+
29
+
30
+ def save_snapshot(data: dict[str, SnapshotEntry], path: Path | None = None) -> None:
31
+ """Write the snapshot, creating parent dirs as needed."""
32
+ path = path or snapshot_path()
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ serializable = {k: asdict(v) for k, v in data.items()}
35
+ path.write_text(json.dumps(serializable, indent=2))
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: twgh
3
+ Version: 0.1.1
4
+ Summary: Read-only GitHub reviewer CLI: triage, re-review diffs, comment threads
5
+ Author-email: sysid <sysid@gmx.de>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: typer>=0.15.1
9
+ Requires-Dist: rich>=13.0.0
10
+
11
+ # twgh
12
+
13
+ Read-only GitHub reviewer CLI: triage what needs you, re-review only what changed
14
+ since you last looked (force-push robust), and see which of your comments were
15
+ addressed — across github.com and GitHub Enterprise.
16
+
17
+ ## Prerequisites
18
+
19
+ - The GitHub CLI [`gh`](https://cli.github.com), authenticated for each host you
20
+ review on: `gh auth login --hostname <host>`. twgh borrows gh for auth, host
21
+ routing, and transport; it never handles your token.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ make install # uv tool install -e . + bash completion
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ | Command | What |
32
+ |---|---|
33
+ | `twgh status` | Cross-repo rollup of PRs needing your review; ★ marks PRs that moved since your last run. |
34
+ | `twgh diff <ref>` | Diff of the files you commented on, since your last review (`--all` for the whole PR). Force-push robust. |
35
+ | `twgh comments <ref>` | All comments, chronological (`--by <user>`, `--mine`, `--all`). |
36
+ | `twgh threads [<ref>]` | Open conversations with a resolved / changed / untouched verdict. No `<ref>` = global inbox across repos. |
37
+ | `twgh open <ref>` | Open the PR in a browser (for the write-actions twgh stays out of). |
38
+
39
+ `<ref>` is a PR URL, `host/owner/repo#N` (the form `status` prints — paste it
40
+ straight back), `owner/repo#N`, `owner/repo/N`, or a bare number inside a repo.
41
+
42
+ ## Host selection
43
+
44
+ `-H/--gh-host` overrides `$GH_HOST`, which defaults to `github.com`. Works as a
45
+ global option (`twgh -H bmw.ghe.com status`) or per command.
46
+
47
+ ## The re-review verdict
48
+
49
+ `twgh threads` classifies each open conversation by what GitHub knows:
50
+
51
+ - `✓ resolved` — the thread is marked resolved
52
+ - `~ changed` — your anchored code moved since you commented (look with `twgh diff`)
53
+ - `· untouched` — your point still stands as written
54
+
55
+ ## Development
56
+
57
+ ```sh
58
+ make test # pytest + coverage (floor 85%)
59
+ make static-analysis # ruff lint-fix, format, ty
60
+ ```
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/twgh/__init__.py
4
+ src/twgh.egg-info/PKG-INFO
5
+ src/twgh.egg-info/SOURCES.txt
6
+ src/twgh.egg-info/dependency_links.txt
7
+ src/twgh.egg-info/entry_points.txt
8
+ src/twgh.egg-info/requires.txt
9
+ src/twgh.egg-info/top_level.txt
10
+ src/twgh/bin/__init__.py
11
+ src/twgh/bin/cli.py
12
+ src/twgh/lib/__init__.py
13
+ src/twgh/lib/domain.py
14
+ src/twgh/lib/gh.py
15
+ src/twgh/lib/queries.py
16
+ src/twgh/lib/render.py
17
+ src/twgh/lib/snapshot.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ twgh = twgh.bin.cli:app
@@ -0,0 +1,2 @@
1
+ typer>=0.15.1
2
+ rich>=13.0.0
@@ -0,0 +1 @@
1
+ twgh