Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,19 @@ npx skills add https://github.com/ShigureLab/gh-llm --skill github-conversation
Read a PR's full timeline — metadata, comments, reviews, checks — with progressive expansion:

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

# Later incremental read: reuse the previous frontmatter `fetched_at`
gh-llm pr view 77900 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z

# Show selected regions only
gh-llm pr view 77900 --repo PaddlePaddle/Paddle --show timeline,checks

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

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

```bash
gh-llm issue view 77924 --repo PaddlePaddle/Paddle
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
gh-llm issue timeline-expand 2 --issue 77924 --repo PaddlePaddle/Paddle
gh-llm issue timeline-expand 2 --issue 77924 --repo PaddlePaddle/Paddle --after 2026-04-08T02:41:17Z
gh-llm issue comment-expand IC_xxx --issue 77924 --repo PaddlePaddle/Paddle
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --expand minimized,details
gh-llm issue view 77924 --repo PaddlePaddle/Paddle --show meta,description
```

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.

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.

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

- **Metadata** is rendered as YAML-style frontmatter at the top of PR/issue views.
- Frontmatter includes `fetched_at`, so the next incremental read can use `--after <fetched_at>`.
- When timeline filtering is active, frontmatter also includes `timeline_after` / `timeline_before` and filtered vs unfiltered event counts.
- **Description** is wrapped in `<description>...</description>` tags.
- **Comment bodies** use `<comment>...</comment>` tags to avoid markdown fence ambiguity with code blocks inside comments.
- **Hidden timeline sections** are separated by `---` dividers and include ready-to-run expand commands to load the omitted content.
Expand Down
7 changes: 7 additions & 0 deletions skills/github-conversation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,15 @@ Use this before forking or opening a PR when you need the default branch, onboar

```bash
gh-llm pr view <pr> --repo <owner/repo>
gh-llm pr view <pr> --repo <owner/repo> --after <previous_fetched_at>
gh-llm pr timeline-expand <page> --pr <pr> --repo <owner/repo>
gh-llm pr timeline-expand <page> --pr <pr> --repo <owner/repo> --after <previous_fetched_at>
gh-llm pr review-expand <PRR_id[,PRR_id...]> --pr <pr> --repo <owner/repo>
gh-llm pr checks --pr <pr> --repo <owner/repo>
```

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.

### Prepare a PR body

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

```bash
gh-llm issue view <issue> --repo <owner/repo>
gh-llm issue view <issue> --repo <owner/repo> --after <previous_fetched_at>
gh-llm issue timeline-expand <page> --issue <issue> --repo <owner/repo>
gh-llm issue timeline-expand <page> --issue <issue> --repo <owner/repo> --after <previous_fetched_at>
```

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.
Frontmatter includes `fetched_at`, plus `timeline_after` / `timeline_before` and filtered vs unfiltered counts when timeline filtering is active.

### Write simple updates

Expand Down
67 changes: 57 additions & 10 deletions src/gh_llm/commands/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from gh_llm.commands.options import (
add_body_input_arguments,
add_timeline_window_arguments,
maybe_resolve_subject,
parse_timeline_window,
raise_unknown_option_value,
resolve_file_or_inline_text,
resolve_subject,
Expand Down Expand Up @@ -65,6 +67,7 @@ def register_issue_parser(subparsers: Any) -> None:
default=[],
help="auto-expand folded content: minimized, details, all (comma-separated or repeatable)",
)
add_timeline_window_arguments(view_parser)
view_parser.set_defaults(handler=cmd_issue_view)

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

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

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

Expand All @@ -126,6 +132,7 @@ def cmd_issue_view(args: Any) -> int:
context, first_page, last_page = pager.build_initial(
meta,
page_size=page_size,
timeline_window=timeline_window,
show_minimized_details=expand.minimized,
show_details_blocks=expand.details,
)
Expand Down Expand Up @@ -194,8 +201,14 @@ def print_block(lines: list[str]) -> None:
def cmd_issue_timeline_expand(args: Any) -> int:
client = GitHubClient()
pager = TimelinePager(client)
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
expand = _parse_expand_options(raw_values=list(getattr(args, "expand", [])))
context, meta = _resolve_context_and_meta(
client=client,
pager=pager,
args=args,
show_minimized_details=expand.minimized,
show_details_blocks=expand.details,
)

page = pager.fetch_page(
meta=meta,
Expand All @@ -217,13 +230,19 @@ def cmd_issue_timeline_expand(args: Any) -> int:
def cmd_issue_details_expand(args: Any) -> int:
client = GitHubClient()
pager = TimelinePager(client)
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
context, meta = _resolve_context_and_meta(
client=client,
pager=pager,
args=args,
show_minimized_details=True,
show_details_blocks=True,
)

index = int(args.index)
if index < 1 or index > context.total_count:
page_number = _resolve_timeline_page_for_index(context=context, index=index)
if page_number is None:
raise RuntimeError(f"invalid event index {index}, expected in 1..{context.total_count}")

page_number = ((index - 1) // context.page_size) + 1
page = pager.fetch_page(
meta=meta,
context=context,
Expand All @@ -232,10 +251,12 @@ def cmd_issue_details_expand(args: Any) -> int:
show_details_blocks=True,
diff_hunk_lines=None,
)
page_start = (page_number - 1) * context.page_size + 1
offset = index - page_start
if offset < 0 or offset >= len(page.items):
raise RuntimeError("event index is outside loaded page range")
try:
offset = page.absolute_indexes.index(index)
except ValueError:
raise RuntimeError("event index is outside loaded page range") from None
except AttributeError as error: # pragma: no cover - defensive fallback
raise RuntimeError("event index is outside loaded page range") from error

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


def _resolve_context_and_meta(
*, client: GitHubClient, pager: TimelinePager, args: Any
*,
client: GitHubClient,
pager: TimelinePager,
args: Any,
show_minimized_details: bool = False,
show_details_blocks: bool = False,
) -> tuple[TimelineContext, PullRequestMeta]:
page_size = getattr(args, "page_size", None)
effective_page_size = DEFAULT_PAGE_SIZE if page_size is None else int(page_size)
meta = _resolve_issue_meta(client=client, args=args)
context, _, _ = pager.build_initial(meta=meta, page_size=effective_page_size)
context, _, _ = pager.build_initial(
meta=meta,
page_size=effective_page_size,
timeline_window=_resolve_timeline_window(args),
show_minimized_details=show_minimized_details,
show_details_blocks=show_details_blocks,
)
return context, meta


def _resolve_timeline_window(args: Any):
return parse_timeline_window(after=getattr(args, "after", None), before=getattr(args, "before", None))


def _resolve_timeline_page_for_index(*, context: TimelineContext, index: int) -> int | None:
if context.timeline_filtered:
for page_number, page in context.filtered_pages.items():
if index in page.absolute_indexes:
return page_number
return None
if index < 1 or index > context.total_count:
return None
return ((index - 1) // context.page_size) + 1


def _parse_expand_options(*, raw_values: list[str]) -> _ExpandOptions:
minimized = False
details = False
Expand Down
54 changes: 54 additions & 0 deletions src/gh_llm/commands/options.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from __future__ import annotations

import sys
from datetime import UTC, datetime
from difflib import get_close_matches
from pathlib import Path
from typing import TYPE_CHECKING, Any, NoReturn

from gh_llm.models import TimelineWindow

if TYPE_CHECKING:
from collections.abc import Callable

Expand All @@ -29,6 +32,40 @@ def add_body_input_arguments(
)


def add_timeline_window_arguments(parser: Any) -> None:
parser.add_argument(
"--after",
help="only include timeline events strictly after this ISO 8601 / RFC3339 timestamp",
)
parser.add_argument(
"--before",
help="only include timeline events strictly before this ISO 8601 / RFC3339 timestamp",
)


def parse_timeline_window(*, after: str | None, before: str | None) -> TimelineWindow:
after_value = _parse_timestamp(raw=after, flag="--after")
before_value = _parse_timestamp(raw=before, flag="--before")
if after_value is not None and before_value is not None and after_value >= before_value:
raise RuntimeError("invalid time range: `--after` must be earlier than `--before`")
return TimelineWindow(
after=after_value,
before=before_value,
after_text=(format_timestamp_utc(after_value) if after_value is not None else None),
before_text=(format_timestamp_utc(before_value) if before_value is not None else None),
)


def format_timestamp_utc(value: datetime) -> str:
utc_value = value.astimezone(UTC)
timespec = "microseconds" if utc_value.microsecond else "seconds"
return utc_value.isoformat(timespec=timespec).replace("+00:00", "Z")


def current_timestamp_utc() -> str:
return format_timestamp_utc(datetime.now(UTC).replace(microsecond=0))


def read_text_from_path_or_stdin(path: str) -> str:
if path == "-":
return sys.stdin.read()
Expand Down Expand Up @@ -94,3 +131,20 @@ def raise_unknown_option_value(
suggest_text = f" Did you mean '{suggestion[0]}'?" if suggestion else ""
valid_text = ", ".join(valid_values)
raise RuntimeError(f"unknown {flag} option: {token}. Valid values: {valid_text}.{suggest_text}")


def _parse_timestamp(*, raw: str | None, flag: str) -> datetime | None:
if raw is None:
return None
normalized = raw.strip()
if not normalized:
raise RuntimeError(f"{flag} requires a timestamp value")
if normalized.endswith(("Z", "z")):
normalized = normalized[:-1] + "+00:00"
try:
value = datetime.fromisoformat(normalized)
except ValueError as error:
raise RuntimeError(f"invalid {flag} timestamp: {raw}") from error
if value.tzinfo is None or value.utcoffset() is None:
raise RuntimeError(f"invalid {flag} timestamp: {raw}")
return value.astimezone(UTC)
Loading