Skip to content

Commit 98cca15

Browse files
committed
feat: Implement timeline management for GitHub pull requests
- Add models for PullRequestRef, PullRequestMeta, TimelineEvent, and TimelineContext to represent pull request data and timeline events. - Create a TimelinePager class to handle pagination of timeline events, including fetching and storing pages of events. - Introduce rendering functions to format timeline data for display, including headers, pages, and event details. - Remove the old main entry point for the moelib project as it is no longer needed. - Add comprehensive tests for the CLI functionality, ensuring correct behavior for viewing and expanding pull request timelines. - Update dependency management to include necessary packages for the new functionality.
1 parent 033706c commit 98cca15

16 files changed

Lines changed: 2041 additions & 44 deletions

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,36 @@ Just a template for quickly creating a python library.
1414
</p>
1515

1616
Before the work starts, replace the `moelib` with the name of your library.
17+
18+
## gh-llm prototype
19+
20+
This repo includes a `gh-llm` CLI prototype for PR timeline reading with **real GitHub cursor pagination**.
21+
22+
```bash
23+
# Show PR overview and timeline page 1 + last page (server-side pagination)
24+
gh-llm pr view 123 --repo owner/repo
25+
26+
# Expand a timeline page by number (loads more from GitHub API)
27+
gh-llm pr timeline-expand 2 --pr 77900 --repo owner/repo
28+
29+
# Get full content of one timeline event by index
30+
gh-llm pr event 42 --pr 77900 --repo owner/repo
31+
32+
# Expand resolved review comments for one or more review events
33+
gh-llm pr review-expand PRR_xxx,PRR_yyy --pr 77900 --repo owner/repo
34+
```
35+
36+
Behavior:
37+
38+
- Unified interface:
39+
`gh-llm pr view [<number>|<url>|<branch>] [--repo owner/repo] [--page-size N]`
40+
and `gh-llm pr timeline-expand <page> --pr ... --repo ... [--page-size N]`.
41+
- PR metadata is rendered as frontmatter at the top, followed by the PR description body.
42+
- Timeline data comes from GraphQL `timelineItems` cursor pagination (`first/after` and `last/before`).
43+
- `pr view` fetches and renders page 1 + last page first, then prints actionable expand commands.
44+
- When the last page is short, `pr view` also shows the previous page to preserve enough tail context.
45+
- `pr timeline-expand N` fetches page `N` on-demand via server-side pagination and updates local cursor checkpoints.
46+
- Long event bodies are truncated only when very long; use `gh-llm pr event <index>` to fetch full text.
47+
- Resolved review comments are folded by default in timeline pages; use `gh-llm pr review-expand <PRR_ids>` to expand them in bulk.
48+
- No local session state is required between commands. The tool only uses collision-safe keyed cache for acceleration.
49+
- Timeline includes commit/review/comment and state events (merged/closed/reopened), with ordering matching GitHub timeline flow.

justfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION := `uv run python -c "import sys; from moelib import __version__ as version; sys.stdout.write(version)"`
1+
VERSION := `uv run python -c "import sys; from gh_llm import __version__ as version; sys.stdout.write(version)"`
22

33
install:
44
uv sync --all-extras --dev
@@ -12,7 +12,7 @@ fmt:
1212
prettier --write '**/*.md'
1313

1414
lint:
15-
uv run pyright src/moelib tests
15+
uv run pyright src/gh_llm tests
1616
uv run ruff check .
1717

1818
fmt-docs:

pyproject.toml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[project]
2-
name = "moelib"
2+
name = "gh-llm"
33
version = "0.1.0"
44
description = ""
55
readme = "README.md"
@@ -23,13 +23,13 @@ classifiers = [
2323
]
2424

2525
[project.urls]
26-
Homepage = "https://github.com/ShigureLab/moelib"
27-
Documentation = "https://github.com/ShigureLab/moelib"
28-
Repository = "https://github.com/ShigureLab/moelib"
29-
Issues = "https://github.com/ShigureLab/moelib/issues"
26+
Homepage = "https://github.com/ShigureLab/gh-llm"
27+
Documentation = "https://github.com/ShigureLab/gh-llm"
28+
Repository = "https://github.com/ShigureLab/gh-llm"
29+
Issues = "https://github.com/ShigureLab/gh-llm/issues"
3030

3131
[project.scripts]
32-
moelib = "moelib.__main__:main"
32+
gh-llm = "gh_llm.__main__:main"
3333

3434
[dependency-groups]
3535
dev = [
@@ -40,13 +40,13 @@ dev = [
4040
]
4141

4242
[tool.pyright]
43-
include = ["src/moelib", "tests"]
44-
pythonVersion = "3.10"
43+
include = ["src/gh_llm", "tests"]
44+
pythonVersion = "3.14"
4545
typeCheckingMode = "strict"
4646

4747
[tool.ruff]
4848
line-length = 120
49-
target-version = "py310"
49+
target-version = "py314"
5050

5151
[tool.ruff.lint]
5252
select = [
@@ -95,7 +95,7 @@ future-annotations = true
9595

9696
[tool.ruff.lint.isort]
9797
required-imports = ["from __future__ import annotations"]
98-
known-first-party = ["moelib"]
98+
known-first-party = ["gh_llm"]
9999
combine-as-imports = true
100100

101101
[build-system]

src/gh_llm/__main__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
from gh_llm.cli import run
6+
7+
8+
def main() -> None:
9+
raise SystemExit(run(sys.argv[1:]))
10+
11+
12+
if __name__ == "__main__":
13+
main()

src/gh_llm/cache.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from hashlib import sha256
6+
from pathlib import Path
7+
from typing import cast
8+
9+
10+
class CacheStore:
11+
def __init__(self, cache_dir: Path | None = None) -> None:
12+
self._cache_dir = cache_dir or _default_cache_dir()
13+
14+
def get_json(self, namespace: str, key: str) -> dict[str, object] | None:
15+
path = self._path(namespace=namespace, key=key)
16+
if not path.exists():
17+
return None
18+
raw: object = json.loads(path.read_text(encoding="utf-8"))
19+
if not isinstance(raw, dict):
20+
return None
21+
raw_dict = cast("dict[object, object]", raw)
22+
return {str(k): v for k, v in raw_dict.items()}
23+
24+
def set_json(self, namespace: str, key: str, value: dict[str, object]) -> None:
25+
path = self._path(namespace=namespace, key=key)
26+
path.parent.mkdir(parents=True, exist_ok=True)
27+
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
28+
29+
def _path(self, *, namespace: str, key: str) -> Path:
30+
safe_namespace = namespace.replace("/", "_")
31+
digest = sha256(key.encode("utf-8")).hexdigest()
32+
return self._cache_dir / safe_namespace / f"{digest}.json"
33+
34+
35+
def _default_cache_dir() -> Path:
36+
xdg_cache = os.environ.get("XDG_CACHE_HOME")
37+
if xdg_cache:
38+
return Path(xdg_cache) / "gh-llm"
39+
return Path.home() / ".cache" / "gh-llm"

0 commit comments

Comments
 (0)