Skip to content

✨ feat(tui): inline review comments in the rich PR view (GraphQL) - #532

Open
kbrdn1 wants to merge 5 commits into
devfrom
feat/#528-inline-review-comments
Open

✨ feat(tui): inline review comments in the rich PR view (GraphQL)#532
kbrdn1 wants to merge 5 commits into
devfrom
feat/#528-inline-review-comments

Conversation

@kbrdn1

@kbrdn1 kbrdn1 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Description

The rich PR view (#420) shipped two of the three steps its issue laid out.
This is the third: the comments anchored to a diff hunk, which
gh pr view --json comments cannot return at all.

Those threads, their path / line / diffHunk, and the reply chains
inside them are reachable through GraphQL alone. So this is a second
transport, not a wider field list, and it is deliberately kept off the
existing PR fetch: gwm status calls that same fetch on every invocation
and has no use for review threads.

Closes #528

Type of change

  • ✨ Feature (new functionality)

Changes

  • Forge::fetch_pr_threads with no default implementation, returning a
    ReviewThreads enum rather than a Vec. "This backend has no path to
    inline comments" and "the forge answered, there are none" are both
    terminal states that render differently, and a bare list collapses them
    into one. GitLab answers Unsupported explicitly.
  • gh api graphql query + parser in src/github.rs, flattening GraphQL's
    nodes wrappers into the shape the REST parser already produces, so one
    renderer serves both transports.
  • The query refuses a slug it cannot split into owner/repo. gh pr view
    copes with an empty selector by resolving from the working directory; a
    GraphQL document cannot, and guessing the owner back is the ✨ feat(forge): Forge trait and GitLab (glab) backend #458 finding
    on a new transport.
  • A threads section in rich_view: anchor (src/tui/app.rs:7-11, plus
    resolved / outdated), the diff hunk, then the chain.
  • Own TaskKind + own per-number cache, fired when the view opens.

Tests

  • cargo test passes locally (2700 passed, 0 failed)
  • cargo fmt --check passes
  • cargo clippy --all-targets -- -D warnings passes
  • New tests added under tests/ (6 transport, 16 render, 5 wiring)

Also re-ran the whole suite under a CI-like minimal PATH
(PATH="$(dirname "$(command -v cargo)"):/usr/bin:/bin"): 2700 passed.

Screenshots / TUI captures

Not captured. The section is text rows inside the existing overlay shell;
its layout is pinned by the row-builder tests rather than by a recording.

Checklist

  • Branch follows <type>/#<issue>-<description>
  • Commits follow Gitmoji + Conventional Commits
  • CHANGELOG.md updated under ## [Unreleased]
  • README updated if CLI surface changed (no CLI surface change)
  • examples/gwm.toml.example updated if config schema changed (no schema change)
  • No unwrap() on user-facing paths
  • No println! in TUI render code

Linked issues / docs

Notes for reviewers

Where the payload shape comes from. Not from the issue, which quoted
snacks.nvim's Lua. It was read off a live response: gh api graphql against
PR #514 of this repo. Two things that came out of it and drove the code:
startLine is null on every single-line anchor (the common case), and
author is a nullable object. Every field carries null_to_default.

Hunk lines are truncated, never wrapped. wrap_line splits on
whitespace, so a wrapped + line's continuation rows carry no sigil and
read as context; in a diff the leading + / - / space is the meaning.
A long hunk drops its head rather than its tail, because the forge puts the
anchored line last.

That test is worth a second look: it identifies hunk rows by their role,
not by their first character. The first version filtered on the sigil, which
made half of it vacant, since a filter looking for a sigil cannot see a row
that lost one. Verified by reverting the fix and watching it fail on the
right assertion.

Landing paths. The threads land through one helper wired into both the
drain and the apply_* test seam, and it re-runs sync_rich_overlay rather
than touching rows directly. #420 hit that desync once with a warning two
lines below in the same file; this is the third landing path.

Pre-existing, not addressed here. A CodeRabbit body is mostly <details>
blocks and <!-- fingerprinting:… --> comments, so a capped render shows
HTML comments instead of the finding. That is already true of the
conversation section on dev; fixing it is a markdown-stripping question
for its own issue, not this one.

kbrdn1 added 3 commits August 5, 2026 06:08
`gh pr view --json comments` returns the conversation only. The comments
anchored to a diff hunk, their path / line / diffHunk, and the reply
chains inside a thread are reachable through GraphQL alone, so this adds
a second transport rather than widening the field list.

`ReviewThreads` is an enum, not a `Vec`: "this backend has no path to
inline comments" and "the forge answered, there are none" are both
terminal states that must render differently, and a bare list collapses
them into one. `Forge::fetch_pr_threads` has no default implementation
for the same reason, so a third backend has to answer the question
instead of inheriting an empty list that reads as clean.

The query refuses a slug it cannot split into owner/repo. `gh pr view`
copes with an empty selector by resolving from the working directory;
a GraphQL document cannot, and guessing the owner back would send the
request to whichever instance is ambient. That is the #458 finding on
a new transport.

Every field carries `null_to_default`: `startLine` is null on every
single-line anchor, which is the common case rather than an edge one.

refs #528
Each thread renders as its anchor (`src/tui/app.rs:7-11`, plus
`resolved` / `outdated` when it applies), the diff hunk it hangs from,
then the reply chain, under the reviews section.

Hunk lines are truncated, never wrapped. The wrap path splits on
whitespace, so a wrapped `+` line's continuation rows carry no sigil and
read as context; in a diff the leading `+` / `-` / space IS the meaning.
A long hunk drops its head rather than its tail, because the forge puts
the anchored line last. The test identifies hunk rows by their ROLE, not
by their first character, since a filter that looks for a sigil cannot
see a row that lost one.

The fetch is a separate `TaskKind` with its own per-number cache, not a
field on `PrStatus`: a PR refresh replaces its whole cache entry and
would silently drop threads that had already landed. It is fired when
the view opens rather than alongside every PR fetch, because `gwm status`
calls the same fetch on every invocation and never reads these.

Landing goes through one helper wired into BOTH the drain and the
`apply_*` test seam, and re-runs `sync_rich_overlay` rather than touching
the rows directly, so the view keeps rendering the side the link prefers.
A backend with no forge resolved never marks `Loading`, which would
otherwise leave the section spinning on a request nobody made.

refs #528
The #420 entry closed on "inline review comments are not part of this
change", and both roadmaps carried #528 as still tracked. The keybindings
pages said the view fetches nothing beyond the status refresh, which the
second transport makes untrue.

Also states the GitLab behaviour explicitly: the section is present and
says the backend cannot reach these, because that is a different fact
from "this merge request has none".

closes #528
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 744e01c0-e53f-407c-8c53-e74f23c50447

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

kbrdn1 added 2 commits August 5, 2026 06:33
Every threads test went through `apply_pr_threads_fetch_result`, so the
request itself, the worker and the drain arm were green-but-unrun: the
App fixtures resolve no forge, and `spawn_github_pr_threads` returns
early on exactly that. Both new tests drive a `gh` stand-in through the
real spawn, as the bulk-refresh test already does, and assert on the
result rather than on `is_loading` (issue #425: whether a task is still
running at an arbitrary instant is a timing detail).

The refresh test asserts on the CACHE, not on the rows. Deleting the
re-spawn left it green, because `refresh_github_status` does not rebuild
the overlay (a landing does), so the previous run's rows are still on
screen either way. `Idle` is what the invalidation leaves behind when
nothing asks again, and that is what the assertion now reads.

Gate the re-spawn on the view being open, not on the overlay kind alone:
`close_detail_overlay` leaves `kind` holding its last value, so a refresh
that closed the overlay (the link moved) would still have fired a GraphQL
request for a view no longer on screen.

Also pins the GitLab side of the three-state contract at the backend:
`fetch_pr_threads` answers `Unsupported` without spawning `glab`. Driving
that end to end through the App needs a trusted GitLab config for the
#458 origin reconciliation, which is more setup than the claim is worth.

refs #528
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant