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 +60 -0
- twgh-0.1.1/README.md +50 -0
- twgh-0.1.1/pyproject.toml +86 -0
- twgh-0.1.1/setup.cfg +4 -0
- twgh-0.1.1/src/twgh/__init__.py +1 -0
- twgh-0.1.1/src/twgh/bin/__init__.py +0 -0
- twgh-0.1.1/src/twgh/bin/cli.py +316 -0
- twgh-0.1.1/src/twgh/lib/__init__.py +0 -0
- twgh-0.1.1/src/twgh/lib/domain.py +215 -0
- twgh-0.1.1/src/twgh/lib/gh.py +347 -0
- twgh-0.1.1/src/twgh/lib/queries.py +49 -0
- twgh-0.1.1/src/twgh/lib/render.py +101 -0
- twgh-0.1.1/src/twgh/lib/snapshot.py +35 -0
- twgh-0.1.1/src/twgh.egg-info/PKG-INFO +60 -0
- twgh-0.1.1/src/twgh.egg-info/SOURCES.txt +17 -0
- twgh-0.1.1/src/twgh.egg-info/dependency_links.txt +1 -0
- twgh-0.1.1/src/twgh.egg-info/entry_points.txt +2 -0
- twgh-0.1.1/src/twgh.egg-info/requires.txt +2 -0
- twgh-0.1.1/src/twgh.egg-info/top_level.txt +1 -0
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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twgh
|