Skip to content

Commit 72d5fee

Browse files
Nyako Shigurecodex
andauthored
✨ feat: support timeline --before/--after filters (#41)
Co-authored-by: Codex <codex@openai.com>
1 parent 7190bb4 commit 72d5fee

9 files changed

Lines changed: 979 additions & 34 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,19 @@ npx skills add https://github.com/ShigureLab/gh-llm --skill github-conversation
6464
Read a PR's full timeline — metadata, comments, reviews, checks — with progressive expansion:
6565

6666
```bash
67-
# Show first + last timeline pages with actionable hints
67+
# Initial read: show first + last timeline pages with actionable hints
6868
gh-llm pr view 77900 --repo PaddlePaddle/Paddle
6969
gh llm pr view 77900 --repo PaddlePaddle/Paddle
7070

71+
# Later incremental read: reuse the previous frontmatter `fetched_at`
72+
gh-llm pr view 77900 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
73+
7174
# Show selected regions only
7275
gh-llm pr view 77900 --repo PaddlePaddle/Paddle --show timeline,checks
7376

7477
# Expand one hidden timeline page
7578
gh-llm pr timeline-expand 2 --pr 77900 --repo PaddlePaddle/Paddle
79+
gh-llm pr timeline-expand 2 --pr 77900 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
7680

7781
# Auto-expand folded content in default/timeline view
7882
gh-llm pr view 77900 --repo PaddlePaddle/Paddle --expand resolved,minimized
@@ -118,12 +122,16 @@ Issue reading works the same way as PR reading — timeline view with progressiv
118122

119123
```bash
120124
gh-llm issue view 77924 --repo PaddlePaddle/Paddle
125+
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
121126
gh-llm issue timeline-expand 2 --issue 77924 --repo PaddlePaddle/Paddle
127+
gh-llm issue timeline-expand 2 --issue 77924 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
122128
gh-llm issue comment-expand IC_xxx --issue 77924 --repo PaddlePaddle/Paddle
123129
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --expand minimized,details
124130
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --show meta,description
125131
```
126132

133+
For incremental follow-ups, copy the previous output's `fetched_at` value into `--after <fetched_at>`. `--before` is also available when you want to inspect only older timeline slices.
134+
127135
When `--show` does not include `timeline` (for example `--show meta`, `--show summary`, or `--show actions`), both `pr view` and `issue view` stay on the lightweight metadata path and skip timeline bootstrap.
128136

129137
Use `--show` to choose which output sections to render. Use `--expand` to automatically open folded content within those sections.
@@ -309,6 +317,8 @@ This supports the normal flow where one review contains multiple inline comments
309317
All output follows consistent formatting rules so both humans and LLMs can parse it reliably:
310318

311319
- **Metadata** is rendered as YAML-style frontmatter at the top of PR/issue views.
320+
- Frontmatter includes `fetched_at`, so the next incremental read can use `--after <fetched_at>`.
321+
- When timeline filtering is active, frontmatter also includes `timeline_after` / `timeline_before` and filtered vs unfiltered event counts.
312322
- **Description** is wrapped in `<description>...</description>` tags.
313323
- **Comment bodies** use `<comment>...</comment>` tags to avoid markdown fence ambiguity with code blocks inside comments.
314324
- **Hidden timeline sections** are separated by `---` dividers and include ready-to-run expand commands to load the omitted content.

skills/github-conversation/SKILL.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@ Use this before forking or opening a PR when you need the default branch, onboar
109109

110110
```bash
111111
gh-llm pr view <pr> --repo <owner/repo>
112+
gh-llm pr view <pr> --repo <owner/repo> --after <previous_fetched_at>
112113
gh-llm pr timeline-expand <page> --pr <pr> --repo <owner/repo>
114+
gh-llm pr timeline-expand <page> --pr <pr> --repo <owner/repo> --after <previous_fetched_at>
113115
gh-llm pr review-expand <PRR_id[,PRR_id...]> --pr <pr> --repo <owner/repo>
114116
gh-llm pr checks --pr <pr> --repo <owner/repo>
115117
```
116118

119+
Use plain `view` for the first pass. On follow-up reads, reuse the previous frontmatter `fetched_at` as `--after <previous_fetched_at>` for an incremental timeline refresh.
120+
117121
### Prepare a PR body
118122

119123
```bash
@@ -131,10 +135,13 @@ Use this before `gh pr create` when you need to load a repo PR template, append
131135

132136
```bash
133137
gh-llm issue view <issue> --repo <owner/repo>
138+
gh-llm issue view <issue> --repo <owner/repo> --after <previous_fetched_at>
134139
gh-llm issue timeline-expand <page> --issue <issue> --repo <owner/repo>
140+
gh-llm issue timeline-expand <page> --issue <issue> --repo <owner/repo> --after <previous_fetched_at>
135141
```
136142

137143
For lightweight inspection, prefer non-timeline `--show` combinations such as `--show meta`, `--show summary`, or `--show actions`; `gh-llm` keeps those paths on metadata-only loading unless `timeline` is explicitly requested.
144+
Frontmatter includes `fetched_at`, plus `timeline_after` / `timeline_before` and filtered vs unfiltered counts when timeline filtering is active.
138145

139146
### Write simple updates
140147

src/gh_llm/commands/issue.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
from gh_llm.commands.options import (
77
add_body_input_arguments,
8+
add_timeline_window_arguments,
89
maybe_resolve_subject,
10+
parse_timeline_window,
911
raise_unknown_option_value,
1012
resolve_file_or_inline_text,
1113
resolve_subject,
@@ -65,6 +67,7 @@ def register_issue_parser(subparsers: Any) -> None:
6567
default=[],
6668
help="auto-expand folded content: minimized, details, all (comma-separated or repeatable)",
6769
)
70+
add_timeline_window_arguments(view_parser)
6871
view_parser.set_defaults(handler=cmd_issue_view)
6972

7073
timeline_expand_parser = issue_subparsers.add_parser("timeline-expand", help="load one timeline page by number")
@@ -78,6 +81,7 @@ def register_issue_parser(subparsers: Any) -> None:
7881
default=[],
7982
help="auto-expand folded content: minimized, details, all (comma-separated or repeatable)",
8083
)
84+
add_timeline_window_arguments(timeline_expand_parser)
8185
timeline_expand_parser.set_defaults(handler=cmd_issue_timeline_expand)
8286

8387
details_expand_parser = issue_subparsers.add_parser(
@@ -88,6 +92,7 @@ def register_issue_parser(subparsers: Any) -> None:
8892
details_expand_parser.add_argument("--issue", help="Issue number/url")
8993
details_expand_parser.add_argument("--repo", help="repository in OWNER/REPO format")
9094
details_expand_parser.add_argument("--page-size", type=int, help="timeline entries per page")
95+
add_timeline_window_arguments(details_expand_parser)
9196
details_expand_parser.set_defaults(handler=cmd_issue_details_expand)
9297

9398
comment_edit_parser = issue_subparsers.add_parser("comment-edit", help="edit one issue comment by node id")
@@ -113,6 +118,7 @@ def cmd_issue_view(args: Any) -> int:
113118
page_size = int(args.page_size)
114119
expand = _parse_expand_options(raw_values=list(getattr(args, "expand", [])))
115120
show = _parse_show_options(raw_values=list(getattr(args, "show", [])))
121+
timeline_window = _resolve_timeline_window(args)
116122
client = GitHubClient()
117123
pager = TimelinePager(client)
118124

@@ -126,6 +132,7 @@ def cmd_issue_view(args: Any) -> int:
126132
context, first_page, last_page = pager.build_initial(
127133
meta,
128134
page_size=page_size,
135+
timeline_window=timeline_window,
129136
show_minimized_details=expand.minimized,
130137
show_details_blocks=expand.details,
131138
)
@@ -194,8 +201,14 @@ def print_block(lines: list[str]) -> None:
194201
def cmd_issue_timeline_expand(args: Any) -> int:
195202
client = GitHubClient()
196203
pager = TimelinePager(client)
197-
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
198204
expand = _parse_expand_options(raw_values=list(getattr(args, "expand", [])))
205+
context, meta = _resolve_context_and_meta(
206+
client=client,
207+
pager=pager,
208+
args=args,
209+
show_minimized_details=expand.minimized,
210+
show_details_blocks=expand.details,
211+
)
199212

200213
page = pager.fetch_page(
201214
meta=meta,
@@ -217,13 +230,19 @@ def cmd_issue_timeline_expand(args: Any) -> int:
217230
def cmd_issue_details_expand(args: Any) -> int:
218231
client = GitHubClient()
219232
pager = TimelinePager(client)
220-
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
233+
context, meta = _resolve_context_and_meta(
234+
client=client,
235+
pager=pager,
236+
args=args,
237+
show_minimized_details=True,
238+
show_details_blocks=True,
239+
)
221240

222241
index = int(args.index)
223-
if index < 1 or index > context.total_count:
242+
page_number = _resolve_timeline_page_for_index(context=context, index=index)
243+
if page_number is None:
224244
raise RuntimeError(f"invalid event index {index}, expected in 1..{context.total_count}")
225245

226-
page_number = ((index - 1) // context.page_size) + 1
227246
page = pager.fetch_page(
228247
meta=meta,
229248
context=context,
@@ -232,10 +251,12 @@ def cmd_issue_details_expand(args: Any) -> int:
232251
show_details_blocks=True,
233252
diff_hunk_lines=None,
234253
)
235-
page_start = (page_number - 1) * context.page_size + 1
236-
offset = index - page_start
237-
if offset < 0 or offset >= len(page.items):
238-
raise RuntimeError("event index is outside loaded page range")
254+
try:
255+
offset = page.absolute_indexes.index(index)
256+
except ValueError:
257+
raise RuntimeError("event index is outside loaded page range") from None
258+
except AttributeError as error: # pragma: no cover - defensive fallback
259+
raise RuntimeError("event index is outside loaded page range") from error
239260

240261
for line in render_event_detail_blocks(index=index, event=page.items[offset]):
241262
print(line)
@@ -282,15 +303,41 @@ def _resolve_optional_issue(*, client: GitHubClient, args: Any) -> PullRequestMe
282303

283304

284305
def _resolve_context_and_meta(
285-
*, client: GitHubClient, pager: TimelinePager, args: Any
306+
*,
307+
client: GitHubClient,
308+
pager: TimelinePager,
309+
args: Any,
310+
show_minimized_details: bool = False,
311+
show_details_blocks: bool = False,
286312
) -> tuple[TimelineContext, PullRequestMeta]:
287313
page_size = getattr(args, "page_size", None)
288314
effective_page_size = DEFAULT_PAGE_SIZE if page_size is None else int(page_size)
289315
meta = _resolve_issue_meta(client=client, args=args)
290-
context, _, _ = pager.build_initial(meta=meta, page_size=effective_page_size)
316+
context, _, _ = pager.build_initial(
317+
meta=meta,
318+
page_size=effective_page_size,
319+
timeline_window=_resolve_timeline_window(args),
320+
show_minimized_details=show_minimized_details,
321+
show_details_blocks=show_details_blocks,
322+
)
291323
return context, meta
292324

293325

326+
def _resolve_timeline_window(args: Any):
327+
return parse_timeline_window(after=getattr(args, "after", None), before=getattr(args, "before", None))
328+
329+
330+
def _resolve_timeline_page_for_index(*, context: TimelineContext, index: int) -> int | None:
331+
if context.timeline_filtered:
332+
for page_number, page in context.filtered_pages.items():
333+
if index in page.absolute_indexes:
334+
return page_number
335+
return None
336+
if index < 1 or index > context.total_count:
337+
return None
338+
return ((index - 1) // context.page_size) + 1
339+
340+
294341
def _parse_expand_options(*, raw_values: list[str]) -> _ExpandOptions:
295342
minimized = False
296343
details = False

src/gh_llm/commands/options.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22

33
import sys
4+
from datetime import UTC, datetime
45
from difflib import get_close_matches
56
from pathlib import Path
67
from typing import TYPE_CHECKING, Any, NoReturn
78

9+
from gh_llm.models import TimelineWindow
10+
811
if TYPE_CHECKING:
912
from collections.abc import Callable
1013

@@ -29,6 +32,40 @@ def add_body_input_arguments(
2932
)
3033

3134

35+
def add_timeline_window_arguments(parser: Any) -> None:
36+
parser.add_argument(
37+
"--after",
38+
help="only include timeline events strictly after this ISO 8601 / RFC3339 timestamp",
39+
)
40+
parser.add_argument(
41+
"--before",
42+
help="only include timeline events strictly before this ISO 8601 / RFC3339 timestamp",
43+
)
44+
45+
46+
def parse_timeline_window(*, after: str | None, before: str | None) -> TimelineWindow:
47+
after_value = _parse_timestamp(raw=after, flag="--after")
48+
before_value = _parse_timestamp(raw=before, flag="--before")
49+
if after_value is not None and before_value is not None and after_value >= before_value:
50+
raise RuntimeError("invalid time range: `--after` must be earlier than `--before`")
51+
return TimelineWindow(
52+
after=after_value,
53+
before=before_value,
54+
after_text=(format_timestamp_utc(after_value) if after_value is not None else None),
55+
before_text=(format_timestamp_utc(before_value) if before_value is not None else None),
56+
)
57+
58+
59+
def format_timestamp_utc(value: datetime) -> str:
60+
utc_value = value.astimezone(UTC)
61+
timespec = "microseconds" if utc_value.microsecond else "seconds"
62+
return utc_value.isoformat(timespec=timespec).replace("+00:00", "Z")
63+
64+
65+
def current_timestamp_utc() -> str:
66+
return format_timestamp_utc(datetime.now(UTC).replace(microsecond=0))
67+
68+
3269
def read_text_from_path_or_stdin(path: str) -> str:
3370
if path == "-":
3471
return sys.stdin.read()
@@ -94,3 +131,20 @@ def raise_unknown_option_value(
94131
suggest_text = f" Did you mean '{suggestion[0]}'?" if suggestion else ""
95132
valid_text = ", ".join(valid_values)
96133
raise RuntimeError(f"unknown {flag} option: {token}. Valid values: {valid_text}.{suggest_text}")
134+
135+
136+
def _parse_timestamp(*, raw: str | None, flag: str) -> datetime | None:
137+
if raw is None:
138+
return None
139+
normalized = raw.strip()
140+
if not normalized:
141+
raise RuntimeError(f"{flag} requires a timestamp value")
142+
if normalized.endswith(("Z", "z")):
143+
normalized = normalized[:-1] + "+00:00"
144+
try:
145+
value = datetime.fromisoformat(normalized)
146+
except ValueError as error:
147+
raise RuntimeError(f"invalid {flag} timestamp: {raw}") from error
148+
if value.tzinfo is None or value.utcoffset() is None:
149+
raise RuntimeError(f"invalid {flag} timestamp: {raw}")
150+
return value.astimezone(UTC)

0 commit comments

Comments
 (0)