From 7883cf64c8aacd6be7a13fdc5b406110a70a286d Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 10 Jul 2026 00:34:51 +0900 Subject: [PATCH] feat: show PR/Jira status inline in ccx URL menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JIRA: https://sendbird.atlassian.net/browse/CPLAT-10756 The References preview resolves PR/Jira status (OPEN/MERGED/DRAFT/CLOSED, review decision, checks rollup, Jira status) but the URL menus — both the in-TUI URL modal and the standalone `ccx urls` picker — showed only a bare "PR"/"JIRA" badge and the label. So when jumping between adjacent tmux sessions you couldn't tell at a glance whether a linked PR was still open or already merged without opening it. Make both URL menus status-aware, reusing the same resolve path the References preview uses: - session: export ClassifyURLRef (URL → SessionRef PR/Jira classifier) and add RefStatusText, a plain-text (no-ANSI) status summary usable by both menus and potential --plain output. Both are covered by unit tests. - tui: openURLMenuFromItems now dispatches an async resolve for every PR/Jira row (resolveURLRefsCmd), streaming urlRefStatusMsg back one ref at a time. Rows render the status suffix inline via urlRefStatusText. - cli: the picker's Init() dispatches the same async resolve (resolveRefsCmd), handles pickerRefStatusMsg, and renders the status suffix per row. Both reuse session.ResolveRef, so results are TTL-cached (5 min) and share the existing bounded gh/Jira concurrency — no extra load beyond what the References preview already incurs. File/changes scopes contain no PR/Jira links, so this is a no-op for them. --- internal/cli/picker.go | 74 +++++++++++++++++++++++++++++++++-- internal/session/refs.go | 72 ++++++++++++++++++++++++++++++++++ internal/session/refs_test.go | 37 ++++++++++++++++++ internal/tui/app.go | 20 ++++++++++ internal/tui/urls.go | 60 ++++++++++++++++++++++++++-- 5 files changed, 257 insertions(+), 6 deletions(-) diff --git a/internal/cli/picker.go b/internal/cli/picker.go index 1ebede7..2e266b5 100644 --- a/internal/cli/picker.go +++ b/internal/cli/picker.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "os" "os/exec" @@ -15,6 +16,7 @@ import ( "github.com/mattn/go-runewidth" "github.com/sendbird/ccx/internal/extract" "github.com/sendbird/ccx/internal/kitty" + "github.com/sendbird/ccx/internal/session" ) var pickerDiffStyles = extract.DiffStyles{ @@ -70,6 +72,11 @@ type pickerModel struct { // events, so we only ask once per session. focusReportingEnabled bool + // refStatus holds resolved PR/Jira status keyed by URL, filled + // asynchronously after the picker opens (urls kind only) so PR/Jira rows + // show OPEN/MERGED/review/checks inline. + refStatus map[string]session.SessionRef + result *PickerResult quit bool } @@ -83,10 +90,48 @@ func newPickerModel(kind string, items []PickerItem) pickerModel { artifactSelected: make(map[int]bool), previewMode: pickerPreviewConversation, termFocused: true, + refStatus: make(map[string]session.SessionRef), } } -func (m pickerModel) Init() tea.Cmd { return nil } +// pickerRefStatusMsg carries one resolved PR/Jira status back into the picker. +type pickerRefStatusMsg struct { + ref session.SessionRef +} + +func (m pickerModel) Init() tea.Cmd { + return m.resolveRefsCmd() +} + +// resolveRefsCmd asynchronously resolves PR/Jira status for every GitHub-PR or +// Jira URL in the list, streaming each result back as a pickerRefStatusMsg. It +// reuses session.ResolveRef so results are TTL-cached and share the bounded +// concurrency used elsewhere. Non-urls kinds have no PR/Jira links → no-op. +func (m pickerModel) resolveRefsCmd() tea.Cmd { + if m.kind != "urls" { + return nil + } + var cmds []tea.Cmd + for _, it := range m.allItems { + if it.Item.Category != "pr" && it.Item.Category != "jira" { + continue + } + ref, ok := session.ClassifyURLRef(it.Item.URL) + if !ok { + continue + } + r := ref + cmds = append(cmds, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return pickerRefStatusMsg{ref: session.ResolveRef(ctx, r)} + }) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} // kittyPickerTickMsg fires every 250ms when Kitty is supported so we can // poll tmux pane visibility — tmux does NOT forward focus-out events on @@ -101,6 +146,11 @@ func kittyPickerTickCmd() tea.Cmd { func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case pickerRefStatusMsg: + if m.refStatus != nil { + m.refStatus[msg.ref.URL] = msg.ref + } + return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -706,10 +756,11 @@ func (m pickerModel) View() string { if len(item.Refs) > 1 { refBadge = dim.Render(fmt.Sprintf(" ×%d", len(item.Refs))) } + statusBadge := m.refStatusText(item.Item.URL, dim) if i == m.cursor { - listLines = append(listLines, sel.Render(">")+check+badge+" "+sel.Render(label)+refBadge) + listLines = append(listLines, sel.Render(">")+check+badge+" "+sel.Render(label)+refBadge+statusBadge) } else { - listLines = append(listLines, " "+check+badge+" "+dim.Render(label)+refBadge) + listLines = append(listLines, " "+check+badge+" "+dim.Render(label)+refBadge+statusBadge) } } @@ -901,6 +952,23 @@ func (m pickerModel) selectedURLs() []string { return nil } +// refStatusText returns the styled PR/Jira status suffix (leading two spaces) +// for a URL row, or "" when the URL has no resolved PR/Jira status. +func (m pickerModel) refStatusText(url string, dim lipgloss.Style) string { + if m.refStatus == nil { + return "" + } + ref, ok := m.refStatus[url] + if !ok { + return "" + } + txt := session.RefStatusText(ref) + if txt == "" { + return "" + } + return " " + dim.Render(txt) +} + func (m pickerModel) openItems(urls []string) { for _, u := range urls { var cmd *exec.Cmd diff --git a/internal/session/refs.go b/internal/session/refs.go index 2c032a2..93e1387 100644 --- a/internal/session/refs.go +++ b/internal/session/refs.go @@ -251,6 +251,78 @@ func classifyRef(u string) (SessionRef, bool) { return SessionRef{}, false } +// ClassifyURLRef turns a raw URL into a SessionRef if it is a GitHub PR or a +// Jira browse link, returning ok=false otherwise. Exported so URL menus (which +// classify URLs by host on their own) can reuse the same PR/Jira detection the +// References preview uses, then resolve status via ResolveRef. +func ClassifyURLRef(u string) (SessionRef, bool) { + return classifyRef(u) +} + +// RefStatusText returns a compact, plain-text (no ANSI) status summary for a +// resolved ref, suitable for URL-menu rows and `--plain` output. Returns "" when +// nothing is known yet (unresolved and no state), or "…" while a resolve is in +// flight. Examples: "OPEN · approved · checks ✓", "MERGED", "In Progress". +func RefStatusText(r SessionRef) string { + switch r.Kind { + case RefPR: + var parts []string + if r.State != RefStateUnknown { + parts = append(parts, string(r.State)) + } + if r.ReviewDecision != "" { + parts = append(parts, reviewText(r.ReviewDecision)) + } + if r.ChecksState != "" { + parts = append(parts, checksText(r.ChecksState)) + } + if len(parts) == 0 { + if r.Resolved { + return "" + } + return "…" + } + return strings.Join(parts, " · ") + case RefJira: + if r.JiraStatus != "" { + return r.JiraStatus + } + if r.Resolved { + return "" + } + return "…" + } + return "" +} + +// reviewText renders a PR review decision as plain text. +func reviewText(d string) string { + switch d { + case "APPROVED": + return "approved" + case "CHANGES_REQUESTED": + return "changes requested" + case "REVIEW_REQUIRED": + return "review required" + default: + return strings.ToLower(d) + } +} + +// checksText renders a rolled-up PR checks state as plain text. +func checksText(s string) string { + switch s { + case "SUCCESS": + return "checks ✓" + case "FAILURE", "ERROR": + return "checks ✗" + case "PENDING": + return "checks …" + default: + return "checks " + strings.ToLower(s) + } +} + // prNumber returns the leading digits of a PR URL's number segment, or "" if the // segment is not numeric (e.g. "new" in a "/pull/new/" compare URL). func prNumber(u string) string { diff --git a/internal/session/refs_test.go b/internal/session/refs_test.go index f317aba..91d3af5 100644 --- a/internal/session/refs_test.go +++ b/internal/session/refs_test.go @@ -188,6 +188,43 @@ func TestClassifyRef(t *testing.T) { } } +// TestClassifyURLRef guards the exported wrapper URL menus use to detect PR/Jira +// links; it must agree with the internal classifyRef. +func TestClassifyURLRef(t *testing.T) { + if ref, ok := ClassifyURLRef("https://github.com/sendbird/ccx/pull/52"); !ok || ref.Kind != RefPR || ref.Label != "sendbird/ccx#52" { + t.Errorf("PR: got %+v ok=%v", ref, ok) + } + if ref, ok := ClassifyURLRef("https://sendbird.atlassian.net/browse/CPLAT-9497"); !ok || ref.Kind != RefJira || ref.Label != "CPLAT-9497" { + t.Errorf("Jira: got %+v ok=%v", ref, ok) + } + if _, ok := ClassifyURLRef("https://example.com/foo"); ok { + t.Error("non-PR/Jira URL should not classify") + } +} + +// TestRefStatusText covers the plain-text status summary shown in URL-menu rows. +func TestRefStatusText(t *testing.T) { + cases := []struct { + name string + ref SessionRef + want string + }{ + {"pr unresolved", SessionRef{Kind: RefPR}, "…"}, + {"pr resolved no-status", SessionRef{Kind: RefPR, Resolved: true}, ""}, + {"pr open approved success", SessionRef{Kind: RefPR, State: RefStateOpen, ReviewDecision: "APPROVED", ChecksState: "SUCCESS", Resolved: true}, "OPEN · approved · checks ✓"}, + {"pr merged", SessionRef{Kind: RefPR, State: RefStateMerged, Resolved: true}, "MERGED"}, + {"pr changes failing", SessionRef{Kind: RefPR, State: RefStateOpen, ReviewDecision: "CHANGES_REQUESTED", ChecksState: "FAILURE", Resolved: true}, "OPEN · changes requested · checks ✗"}, + {"jira unresolved", SessionRef{Kind: RefJira}, "…"}, + {"jira in progress", SessionRef{Kind: RefJira, JiraStatus: "In Progress", Resolved: true}, "In Progress"}, + {"jira resolved no-status", SessionRef{Kind: RefJira, Resolved: true}, ""}, + } + for _, c := range cases { + if got := RefStatusText(c.ref); got != c.want { + t.Errorf("%s: got %q want %q", c.name, got, c.want) + } + } +} + func TestSessionRefIsOpen(t *testing.T) { cases := []struct { ref SessionRef diff --git a/internal/tui/app.go b/internal/tui/app.go index af21868..df8bf90 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -393,6 +393,11 @@ type App struct { urlSearchTerm string urlScope string // context label: "session", "message", "block" + // PR/Jira status for URL-menu rows, keyed by URL. Filled asynchronously + // when the menu opens (same gh/Jira resolve + TTL cache the References + // preview uses) so PR/Jira links show OPEN/MERGED/review/checks inline. + urlRefStatus map[string]session.SessionRef + // Changes-specific state for diff preview urlChangeMap map[string]extract.ChangeItem // file path → ChangeItem (for diff rendering) urlDiffVP viewport.Model // scrollable diff viewport @@ -1199,6 +1204,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case urlRefStatusMsg: + // One PR/Jira URL's status landed; store it so the URL menu row renders + // the state inline. Ignore if the menu was closed in the meantime. + if a.urlRefStatus != nil { + a.urlRefStatus[msg.ref.URL] = msg.ref + } + return a, nil + case tea.MouseMsg: return a.handleMouse(msg) @@ -7720,6 +7733,13 @@ type refStatusMsg struct { ref session.SessionRef } +// urlRefStatusMsg carries the resolved status of a single PR/Jira URL shown in +// the URL menu, keyed by URL rather than session so it streams into the menu +// (which is not scoped to a session) one ref at a time. +type urlRefStatusMsg struct { + ref session.SessionRef +} + // PR/Jira status is resolved on demand only for the session whose References // preview is open (updateSessionRefsPreview → resolveRefsStatusCmd, streaming // back as refStatusMsg). An earlier fleet-wide background sweep ran `gh pr view` diff --git a/internal/tui/urls.go b/internal/tui/urls.go index 9928b86..34f1ade 100644 --- a/internal/tui/urls.go +++ b/internal/tui/urls.go @@ -1,8 +1,10 @@ package tui import ( + "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" @@ -279,7 +281,39 @@ func (a *App) openURLMenuFromItems(items []extract.Item, scope string) (tea.Mode a.urlSearching = false a.urlSearchTerm = "" a.urlScope = scope - return a, nil + a.urlRefStatus = make(map[string]session.SessionRef) + return a, a.resolveURLRefsCmd(items) +} + +// resolveURLRefsCmd asynchronously resolves PR/Jira status for every GitHub-PR +// or Jira URL in the menu, streaming each result back as a urlRefStatusMsg. It +// reuses session.ResolveRef, so results are TTL-cached and share the same +// bounded concurrency as the References preview. File/changes scopes contain no +// PR/Jira links, so this is a no-op for them. +func (a *App) resolveURLRefsCmd(items []extract.Item) tea.Cmd { + if a.isFileScope() { + return nil + } + var cmds []tea.Cmd + for _, it := range items { + if it.Category != "pr" && it.Category != "jira" { + continue + } + ref, ok := session.ClassifyURLRef(it.URL) + if !ok { + continue + } + r := ref + cmds = append(cmds, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return urlRefStatusMsg{ref: session.ResolveRef(ctx, r)} + }) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } // handleURLMenu processes key events while the URL menu is open. @@ -439,6 +473,25 @@ func (a *App) closeURLMenu() { a.worktreeAlignActive = false a.urlChangeMap = nil a.urlDiffReady = false + a.urlRefStatus = nil +} + +// urlRefStatusText returns the styled, right-hand PR/Jira status suffix for a +// URL-menu row (leading with two spaces), or "" when the URL is not a resolved +// PR/Jira link. A resolve in flight renders a dim "…". +func (a *App) urlRefStatusText(url string) string { + if a.urlRefStatus == nil { + return "" + } + ref, ok := a.urlRefStatus[url] + if !ok { + return "" + } + txt := session.RefStatusText(ref) + if txt == "" { + return "" + } + return " " + dimStyle.Render(txt) } // isFileScope returns true when the URL menu is showing file-like paths, not URLs. @@ -595,10 +648,11 @@ func (a *App) renderURLMenu() string { if a.urlSelected[item.URL] { check = sel.Render("* ") } + status := a.urlRefStatusText(item.URL) if i == cursor { - lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)) + lines = append(lines, sel.Render(">")+check+badge+" "+sel.Render(label)+status) } else { - lines = append(lines, " "+check+badge+" "+hl.Render(label)) + lines = append(lines, " "+check+badge+" "+hl.Render(label)+status) } }