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
35 changes: 21 additions & 14 deletions src/gh_llm/commands/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from gh_llm.github_api import GitHubClient
from gh_llm.invocation import display_command_with
from gh_llm.models import PullRequestDiffPage
from gh_llm.pager import DEFAULT_PAGE_SIZE, TimelinePager
from gh_llm.pager import DEFAULT_PAGE_SIZE, TimelinePager, build_context_from_meta
from gh_llm.render import (
render_checks_section,
render_comment_node_detail,
Expand Down Expand Up @@ -325,16 +325,22 @@ def cmd_pr_view(args: Any) -> int:
pager = TimelinePager(client)

meta = client.resolve_pull_request(selector=args.pr, repo=args.repo)
context, first_page, last_page = pager.build_initial(
meta,
page_size=page_size,
show_resolved_details=expand.resolved,
show_outdated_details=True,
show_minimized_details=expand.minimized,
show_details_blocks=expand.details,
diff_hunk_lines=diff_hunk_lines,
)
shown_pages: set[int] = {1}
context = build_context_from_meta(meta=meta, page_size=page_size)
first_page: TimelinePage | None = None
last_page: TimelinePage | None = None
shown_pages: set[int] = set()

if show.timeline:
context, first_page, last_page = pager.build_initial(
meta,
page_size=page_size,
show_resolved_details=expand.resolved,
show_outdated_details=True,
show_minimized_details=expand.minimized,
show_details_blocks=expand.details,
diff_hunk_lines=diff_hunk_lines,
)
shown_pages.add(1)

wrote_output = False

Expand All @@ -353,6 +359,7 @@ def print_block(lines: list[str]) -> None:
if show.description:
print_block(render_description(context))
if show.timeline:
assert first_page is not None
print_block(["## Timeline"])
print_block(render_page(1, context, first_page))

Expand Down Expand Up @@ -565,9 +572,9 @@ def cmd_pr_thread_expand(args: Any) -> int:

def cmd_pr_checks(args: Any) -> int:
client = GitHubClient()
pager = TimelinePager(client)
context, meta = _resolve_context_and_meta(client=client, pager=pager, args=args)
checks = client.fetch_checks(meta.ref)
meta = _resolve_pr_meta(client=client, args=args)
context = build_context_from_meta(meta=meta, page_size=DEFAULT_PAGE_SIZE)
checks = client.fetch_checks(meta.ref) if meta.state == "OPEN" else []
for line in render_checks_section(
context=context,
checks=checks,
Expand Down
7 changes: 7 additions & 0 deletions src/gh_llm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ class TimelineContext:
is_draft: bool
body: str
updated_at: str
timeline_loaded: bool = True
labels: tuple[str, ...] = ()
kind: str = "pr"
pr_reactions_summary: str | None = None
Expand Down Expand Up @@ -198,6 +199,7 @@ def to_dict(self) -> dict[str, object]:
"is_draft": self.is_draft,
"body": self.body,
"updated_at": self.updated_at,
"timeline_loaded": self.timeline_loaded,
"labels": list(self.labels),
"kind": self.kind,
"pr_reactions_summary": self.pr_reactions_summary,
Expand Down Expand Up @@ -243,6 +245,11 @@ def from_dict(cls, value: dict[str, object]) -> TimelineContext:
is_draft=bool(value.get("is_draft")),
body=_as_str(value.get("body"), ""),
updated_at=_as_str(value.get("updated_at"), ""),
timeline_loaded=(
_as_int(value.get("total_pages"), 0) > 0
if value.get("timeline_loaded") is None
else bool(value.get("timeline_loaded"))
),
labels=tuple(_as_str(item, "") for item in _as_list(value.get("labels")) if item),
kind=_as_str(value.get("kind"), "pr"),
pr_reactions_summary=_as_str_optional(value.get("pr_reactions_summary")),
Expand Down
98 changes: 60 additions & 38 deletions src/gh_llm/pager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,64 @@
DEFAULT_PAGE_SIZE = 8


def build_context_from_meta(
meta: PullRequestMeta,
page_size: int,
*,
total_count: int | None = None,
total_pages: int | None = None,
) -> TimelineContext:
_validate_page_size(page_size)

timeline_loaded = total_count is not None and total_pages is not None
resolved_total_count = 0 if total_count is None else total_count
resolved_total_pages = 0 if total_pages is None else total_pages

return TimelineContext(
owner=meta.ref.owner,
name=meta.ref.name,
number=meta.ref.number,
page_size=page_size,
total_count=resolved_total_count,
total_pages=resolved_total_pages,
title=meta.title,
url=meta.url,
author=meta.author,
state=meta.state,
is_draft=meta.is_draft,
body=meta.body,
updated_at=meta.updated_at,
timeline_loaded=timeline_loaded,
labels=meta.labels,
kind=meta.kind,
pr_reactions_summary=meta.reactions_summary,
can_edit_pr_body=meta.can_edit_body,
is_merged=meta.is_merged,
head_ref_name=meta.head_ref_name,
head_ref_repo=meta.head_ref_repo,
head_ref_oid=meta.head_ref_oid,
head_ref_deleted=meta.head_ref_deleted,
pr_node_id=meta.node_id,
merge_state_status=meta.merge_state_status,
mergeable=meta.mergeable,
review_decision=meta.review_decision,
requires_approving_reviews=meta.requires_approving_reviews,
required_approving_review_count=meta.required_approving_review_count,
requires_code_owner_reviews=meta.requires_code_owner_reviews,
approved_review_count=meta.approved_review_count,
requires_status_checks=meta.requires_status_checks,
base_ref_name=meta.base_ref_name,
base_ref_oid=meta.base_ref_oid,
merge_commit_allowed=meta.merge_commit_allowed,
squash_merge_allowed=meta.squash_merge_allowed,
rebase_merge_allowed=meta.rebase_merge_allowed,
co_author_trailers=meta.co_author_trailers,
conflict_files=meta.conflict_files,
forward_after_by_page=({1: None} if timeline_loaded else {}),
backward_before_by_page=({resolved_total_pages: None} if timeline_loaded else {}),
)


class TimelinePager:
def __init__(self, client: GitHubClient) -> None:
self._client = client
Expand Down Expand Up @@ -45,47 +103,11 @@ def build_initial(
total_count = first_page.total_count
total_pages = _page_count(total_count, page_size)

context = TimelineContext(
owner=meta.ref.owner,
name=meta.ref.name,
number=meta.ref.number,
context = build_context_from_meta(
meta=meta,
page_size=page_size,
total_count=total_count,
total_pages=total_pages,
title=meta.title,
url=meta.url,
author=meta.author,
state=meta.state,
is_draft=meta.is_draft,
body=meta.body,
updated_at=meta.updated_at,
labels=meta.labels,
kind=meta.kind,
pr_reactions_summary=meta.reactions_summary,
can_edit_pr_body=meta.can_edit_body,
is_merged=meta.is_merged,
head_ref_name=meta.head_ref_name,
head_ref_repo=meta.head_ref_repo,
head_ref_oid=meta.head_ref_oid,
head_ref_deleted=meta.head_ref_deleted,
pr_node_id=meta.node_id,
merge_state_status=meta.merge_state_status,
mergeable=meta.mergeable,
review_decision=meta.review_decision,
requires_approving_reviews=meta.requires_approving_reviews,
required_approving_review_count=meta.required_approving_review_count,
requires_code_owner_reviews=meta.requires_code_owner_reviews,
approved_review_count=meta.approved_review_count,
requires_status_checks=meta.requires_status_checks,
base_ref_name=meta.base_ref_name,
base_ref_oid=meta.base_ref_oid,
merge_commit_allowed=meta.merge_commit_allowed,
squash_merge_allowed=meta.squash_merge_allowed,
rebase_merge_allowed=meta.rebase_merge_allowed,
co_author_trailers=meta.co_author_trailers,
conflict_files=meta.conflict_files,
forward_after_by_page={1: None},
backward_before_by_page={total_pages: None},
)
self._remember_forward(context, page=1, cursor_used=None, page_result=first_page)

Expand Down
11 changes: 8 additions & 3 deletions src/gh_llm/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ def render_frontmatter(context: TimelineContext) -> list[str]:
f"labels: {json.dumps(list(context.labels), ensure_ascii=False)}",
f"draft: {str(context.is_draft).lower()}",
f"updated_at: {context.updated_at}",
f"timeline_events: {context.total_count}",
f"page_size: {context.page_size}",
f"total_pages: {context.total_pages}",
]
if context.timeline_loaded:
lines.extend(
[
f"timeline_events: {context.total_count}",
f"page_size: {context.page_size}",
f"total_pages: {context.total_pages}",
]
)
if context.kind == "pr":
lines.append(f"is_merged: {str(context.is_merged).lower()}")
if context.head_ref_repo:
Expand Down
85 changes: 85 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,91 @@ def test_view_and_expand_use_real_cursor_pagination(
assert "END_MARKER" in out


def test_pr_view_show_meta_skips_timeline_bootstrap(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)

code = cli.run(["pr", "view", "77928", "--repo", "PaddlePaddle/Paddle", "--show", "meta"])
assert code == 0

out = capsys.readouterr().out
assert "pr: 77928" in out
assert "timeline_events:" not in out
assert "## Timeline" not in out
assert "## Checks" not in out

graphql_queries = [_extract_form(call, "query") for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
assert any("headRefName" in query and "timelineItems" not in query for query in graphql_queries)
assert not any("timelineItems(" in query for query in graphql_queries)
assert not any("reviewThreads(first:100" in query for query in graphql_queries)
assert not any("statusCheckRollup" in query for query in graphql_queries)


def test_pr_view_show_checks_fetches_checks_without_timeline_bootstrap(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)

code = cli.run(["pr", "view", "77928", "--repo", "PaddlePaddle/Paddle", "--show", "checks"])
assert code == 0

out = capsys.readouterr().out
assert "## Checks" in out
assert "[IN_PROGRESS/NONE] unit-tests (check-run)" in out
assert "## Timeline" not in out

graphql_queries = [_extract_form(call, "query") for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
assert any("statusCheckRollup" in query for query in graphql_queries)
assert not any("timelineItems(" in query for query in graphql_queries)
assert not any("reviewThreads(first:100" in query for query in graphql_queries)


def test_pr_checks_command_skips_timeline_bootstrap(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)

code = cli.run(["pr", "checks", "--pr", "77928", "--repo", "PaddlePaddle/Paddle", "--all"])
assert code == 0

out = capsys.readouterr().out
assert "## Checks" in out
assert "[COMPLETED/SUCCESS] lint (check-run)" in out

graphql_queries = [_extract_form(call, "query") for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
assert any("statusCheckRollup" in query for query in graphql_queries)
assert not any("timelineItems(" in query for query in graphql_queries)
assert not any("reviewThreads(first:100" in query for query in graphql_queries)


def test_pr_view_show_mergeability_fetches_status_without_timeline_bootstrap(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
responder = GhResponder()
monkeypatch.setattr(github_api.subprocess, "run", responder.run)

code = cli.run(["pr", "view", "77971", "--repo", "PaddlePaddle/Paddle", "--show", "mergeability"])
assert code == 0

out = capsys.readouterr().out
assert "## Mergeability" in out
assert "Status: Merging is blocked" in out
assert "## Timeline" not in out

graphql_queries = [_extract_form(call, "query") for call in responder.calls if call[:3] == ["gh", "api", "graphql"]]
assert any("statusCheckRollup" in query for query in graphql_queries)
assert not any("timelineItems(" in query for query in graphql_queries)
assert not any("reviewThreads(first:100" in query for query in graphql_queries)


def test_web_like_extra_timeline_events_are_rendered(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
Expand Down
Loading