Skip to content

Commit 199d42e

Browse files
authored
Link to stack pages in output & TUIs (#584)
* Add shared stack URL and hyperlink helpers * Link stack references in view output and TUI * Link stack references in submit and sync output * Make submit TUI stack labels clickable * Link stack references in checkout output and picker * Link stack references in link command output * Link stack references in merge output and wizard * Link surviving stacks in unstack and reconciliation output * Document stack links and terminal fallbacks * Fix stack link rendering and stale-stack handling * Reuse existing command tests for stack link coverage
1 parent 6721eba commit 199d42e

38 files changed

Lines changed: 1072 additions & 204 deletions

‎README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,9 +557,17 @@ gh stack view --short
557557
gh stack view --json
558558
```
559559

560-
`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
561-
supports them. Otherwise, the full URL is shown for copy/paste. Set
562-
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
560+
Stack references follow the existing PR-link behavior in text output: OSC 8
561+
hyperlinks when supported, or the full URL for copy/paste otherwise. Stack links open
562+
the repository's `/stack/<number>` page; confirmed missing or removed stacks
563+
are left unlinked. Set `GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to
564+
override terminal detection.
565+
566+
TUIs keep labels compact: click `#N` in `view` or `submit` to
567+
open its page, or use your terminal's hyperlink gesture in the `checkout`
568+
picker and `merge` wizard. Those inline views keep plain numbers when terminal
569+
hyperlinks are unavailable. The environment override controls OSC 8 links, not
570+
the application-handled clicks in `view` and `submit`. JSON output is unchanged.
563571

564572
### `gh stack unstack`
565573

‎cmd/checkout.go‎

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ func checkoutRemoteStackByBranch(cfg *config.Config, sf *stack.StackFile, gitDir
292292
if len(matches) > 1 {
293293
stackNumbers := make([]string, len(matches))
294294
for i, match := range matches {
295-
stackNumbers[i] = strconv.Itoa(match.Number)
295+
stackNumbers[i] = cfg.Link(strconv.Itoa(match.Number), cfg.StackURL(match.Number))
296296
}
297297
cfg.Errorf("branch %q belongs to multiple stacks on GitHub (%s)",
298298
branch, strings.Join(stackNumbers, ", "))
@@ -384,9 +384,10 @@ func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir strin
384384
}
385385

386386
remoteStack, err := client.GetStack(stackNumber)
387-
if err != nil || remoteStack == nil || len(remoteStack.PullRequests) == 0 {
388-
// No such stack, stacks unavailable, or a transient failure — let the
389-
// caller try the number as a PR number or branch name.
387+
if err != nil {
388+
return nil, "", errors.Join(errStackNumberNotFound, err)
389+
}
390+
if remoteStack == nil || len(remoteStack.PullRequests) == 0 {
390391
return nil, "", errStackNumberNotFound
391392
}
392393

@@ -474,7 +475,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps,
474475
if err := stack.Save(gitDir, sf); err != nil {
475476
return nil, "", stackSaveError(cfg, err)
476477
}
477-
cfg.Successf("Local stack matches remote%s", stackLabel(remoteStack.Number))
478+
cfg.Successf("Local stack matches remote%s", linkedStackLabel(cfg, remoteStack.Number))
478479
return localStack, targetBranch, nil
479480
}
480481

@@ -712,7 +713,7 @@ func importRemoteStack(
712713
// Update base SHAs from actual local refs
713714
updateBaseSHAs(s)
714715

715-
cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), stackLabel(remoteStackNumber))
716+
cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), linkedStackLabel(cfg, remoteStackNumber))
716717
return s, nil
717718
}
718719

@@ -748,7 +749,7 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
748749
return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack")
749750
}
750751

751-
rows, remoteStacks := gatherCheckoutRows(cfg, sf)
752+
rows, remoteStacks, links := gatherCheckoutRows(cfg, sf)
752753
if currentBranch, branchErr := git.CurrentBranch(); branchErr == nil {
753754
stackNumber, confirmed, confirmErr := offerRemoteStackForBranch(cfg, sf, remoteStacks, currentBranch)
754755
if confirmErr != nil {
@@ -772,7 +773,7 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
772773
return nil, "", nil
773774
}
774775

775-
selected, ok, err := launchCheckoutPicker(rows)
776+
selected, ok, err := launchCheckoutPicker(rows, links)
776777
if err != nil {
777778
return nil, "", err
778779
}
@@ -789,14 +790,29 @@ func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string)
789790
// with the local stacks into the picker's rows. Any GitHub failure (stacks not
790791
// enabled for the repo, no auth, network error) gracefully degrades to a
791792
// local-only list.
792-
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) ([]checkoutview.StackRow, []github.RemoteStack) {
793+
func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) ([]checkoutview.StackRow, []github.RemoteStack, map[int]string) {
793794
var remote []github.RemoteStack
795+
var remoteNumbers map[int]bool // nil means the remote listing was unavailable
794796
if client, err := cfg.GitHubClient(); err == nil {
795797
if stacks, err := client.ListStacks(); err == nil {
796798
remote = stacks
799+
remoteNumbers = make(map[int]bool, len(stacks))
800+
for _, s := range stacks {
801+
remoteNumbers[s.Number] = true
802+
}
797803
}
798804
}
799-
return checkoutview.BuildRows(sf.Stacks, remote), remote
805+
rows := checkoutview.BuildRows(sf.Stacks, remote)
806+
links := make(map[int]string)
807+
if repo, err := cfg.Repo(); err == nil && repo.Owner != "" && repo.Name != "" {
808+
for _, row := range rows {
809+
if row.Number > 0 && (remoteNumbers == nil || remoteNumbers[row.Number]) {
810+
url := github.StackURL(repo.Host, repo.Owner, repo.Name, row.Number)
811+
links[row.Number] = cfg.TerminalLink(row.NumberDisplay(), url)
812+
}
813+
}
814+
}
815+
return rows, remote, links
800816
}
801817

802818
// offerRemoteStackForBranch asks to check out the unique active remote stack
@@ -814,7 +830,8 @@ func offerRemoteStackForBranch(cfg *config.Config, sf *stack.StackFile, remote [
814830
}
815831

816832
stackNumber := matches[0].Number
817-
prompt := fmt.Sprintf("Found stack #%d that includes branch %q. Check out stack #%d?", stackNumber, branch, stackNumber)
833+
cfg.Infof("Found stack %s that includes branch %q.", cfg.StackLink(stackNumber), branch)
834+
prompt := fmt.Sprintf("Check out stack #%d?", stackNumber)
818835
confirmed, err := confirmRemoteStackCheckout(cfg, prompt)
819836
if err != nil {
820837
if errors.Is(err, errInterrupt) {
@@ -886,7 +903,11 @@ func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir st
886903
s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, selected.Number)
887904
if err != nil {
888905
if errors.Is(err, errStackNumberNotFound) {
889-
cfg.Errorf("stack #%d could not be loaded from GitHub", selected.Number)
906+
label := fmt.Sprintf("#%d", selected.Number)
907+
if !isNotFound(err) {
908+
label = cfg.StackLink(selected.Number)
909+
}
910+
cfg.Errorf("stack %s could not be loaded from GitHub", label)
890911
return nil, "", ErrAPIFailure
891912
}
892913
return nil, "", err
@@ -899,8 +920,8 @@ func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir st
899920
// picker occupies only a few lines and leaves the surrounding terminal output
900921
// intact. Mouse motion is intentionally not enabled so the search field never
901922
// receives stray mouse bytes.
902-
func launchCheckoutPicker(rows []checkoutview.StackRow) (checkoutview.StackRow, bool, error) {
903-
p := tea.NewProgram(checkoutview.New(rows))
923+
func launchCheckoutPicker(rows []checkoutview.StackRow, links map[int]string) (checkoutview.StackRow, bool, error) {
924+
p := tea.NewProgram(checkoutview.New(rows).WithNumberLinks(links))
904925
finalModel, err := p.Run()
905926
if err != nil {
906927
return checkoutview.StackRow{}, false, fmt.Errorf("running stack picker: %w", err)

‎cmd/checkout_picker_test.go‎

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestGatherCheckoutRows_FallbackToLocalOnListError(t *testing.T) {
4141
},
4242
}}}
4343

44-
rows, remote := gatherCheckoutRows(cfg, sf)
44+
rows, remote, _ := gatherCheckoutRows(cfg, sf)
4545
require.Len(t, rows, 1, "falls back to a local-only list when ListStacks fails")
4646
assert.Empty(t, remote)
4747
assert.Equal(t, checkoutview.TypeLocal, rows[0].Type)
@@ -70,7 +70,7 @@ func TestGatherCheckoutRows_IncludesRemoteOnlyStacks(t *testing.T) {
7070
Branches: []stack.BranchRef{{Branch: "local-a"}},
7171
}}}
7272

73-
rows, remote := gatherCheckoutRows(cfg, sf)
73+
rows, remote, _ := gatherCheckoutRows(cfg, sf)
7474
require.Len(t, rows, 2, "local and remote-only stacks are both listed")
7575
require.Len(t, remote, 1)
7676

@@ -173,23 +173,29 @@ func TestMatchingRemoteStacksForBranch(t *testing.T) {
173173
}
174174

175175
func TestOfferRemoteStackForBranch_Confirmed(t *testing.T) {
176-
cfg, _, _ := config.NewTestConfig()
177-
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
178-
assert.Equal(t, `Found stack #7 that includes branch "feature". Check out stack #7?`, prompt)
179-
assert.True(t, defaultValue)
180-
return true, nil
176+
for _, force := range []string{"0", "1"} {
177+
t.Run(force, func(t *testing.T) {
178+
t.Setenv("GH_STACK_HYPERLINKS", force)
179+
cfg, outR, errR := config.NewTestConfig()
180+
setTestRepo(cfg)
181+
cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) {
182+
assert.Equal(t, "Check out stack #7?", prompt)
183+
assert.True(t, defaultValue)
184+
return true, nil
185+
}
186+
number, confirmed, err := offerRemoteStackForBranch(cfg, &stack.StackFile{}, []github.RemoteStack{{
187+
Number: 7,
188+
PRDetails: []github.RemoteStackPR{
189+
{Number: 10, State: "open", Head: github.RemoteStackPRHead{Ref: "feature"}},
190+
},
191+
}}, "feature")
192+
output := collectOutput(cfg, outR, errR)
193+
require.NoError(t, err)
194+
assert.True(t, confirmed)
195+
assert.Equal(t, 7, number)
196+
assert.Contains(t, output, "Found stack "+cfg.StackLink(7)+` that includes branch "feature".`)
197+
})
181198
}
182-
183-
number, confirmed, err := offerRemoteStackForBranch(cfg, &stack.StackFile{}, []github.RemoteStack{{
184-
Number: 7,
185-
PRDetails: []github.RemoteStackPR{
186-
{Number: 10, State: "open", Head: github.RemoteStackPRHead{Ref: "feature"}},
187-
},
188-
}}, "feature")
189-
190-
require.NoError(t, err)
191-
assert.True(t, confirmed)
192-
assert.Equal(t, 7, number)
193199
}
194200

195201
func TestOfferRemoteStackForBranch_FallsBack(t *testing.T) {
@@ -445,19 +451,33 @@ func TestResolveCheckoutSelection_RemoteRoutesToClone(t *testing.T) {
445451
}
446452

447453
func TestResolveCheckoutSelection_RemoteLoadFailure(t *testing.T) {
448-
cfg, _, _ := config.NewTestConfig()
449-
cfg.GitHubClientOverride = &github.MockClient{
450-
GetStackFn: func(n int) (*github.RemoteStack, error) {
451-
return nil, errors.New("boom")
452-
},
454+
t.Setenv("GH_STACK_HYPERLINKS", "1")
455+
for _, tt := range []struct {
456+
name string
457+
err error
458+
link bool
459+
}{
460+
{"transient failure", errors.New("boom"), true},
461+
{"deleted stack", &api.HTTPError{StatusCode: 404}, false},
462+
} {
463+
t.Run(tt.name, func(t *testing.T) {
464+
cfg, outR, errR := config.NewTestConfig()
465+
setTestRepo(cfg)
466+
cfg.GitHubClientOverride = &github.MockClient{
467+
GetStackFn: func(int) (*github.RemoteStack, error) { return nil, tt.err },
468+
}
469+
sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7}
470+
_, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel)
471+
output := collectOutput(cfg, outR, errR)
472+
assert.ErrorIs(t, err, ErrAPIFailure)
473+
if tt.link {
474+
assert.Contains(t, output, "\x1b]8;;https://github.com/o/r/stack/7\x1b\\#7\x1b]8;;\x1b\\")
475+
} else {
476+
assert.Contains(t, output, "stack #7 could not be loaded")
477+
assert.NotContains(t, output, "/stack/")
478+
}
479+
})
453480
}
454-
455-
sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7}
456-
_, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel)
457-
458-
var exitErr *ExitError
459-
require.ErrorAs(t, err, &exitErr)
460-
assert.Equal(t, ErrAPIFailure, err)
461481
}
462482

463483
func TestTopUnmergedBranch(t *testing.T) {

0 commit comments

Comments
 (0)