Skip to content

Commit d6e6db7

Browse files
committed
json output mode for view
1 parent 2604976 commit d6e6db7

3 files changed

Lines changed: 254 additions & 56 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,14 +265,14 @@ Shows all branches in the stack, their ordering, PR links, and the most recent c
265265
| Flag | Description |
266266
|------|-------------|
267267
| `-s, --short` | Compact output (branch names only) |
268-
| `-w, --web` | Open all associated PRs in the browser |
268+
| `--json` | Output stack data as JSON |
269269

270270
**Examples:**
271271

272272
```sh
273273
gh stack view
274274
gh stack view --short
275-
gh stack view --web
275+
gh stack view --json
276276
```
277277

278278
### `gh stack unstack`

cmd/view.go

Lines changed: 78 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ package cmd
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"os/exec"
89
"strings"
910
"time"
1011

1112
tea "github.com/charmbracelet/bubbletea"
12-
"github.com/cli/go-gh/v2/pkg/browser"
1313
"github.com/github/gh-stack/internal/config"
1414
"github.com/github/gh-stack/internal/git"
1515
"github.com/github/gh-stack/internal/stack"
@@ -18,8 +18,8 @@ import (
1818
)
1919

2020
type viewOptions struct {
21-
short bool
22-
web bool
21+
short bool
22+
asJSON bool
2323
}
2424

2525
func ViewCmd(cfg *config.Config) *cobra.Command {
@@ -34,7 +34,7 @@ func ViewCmd(cfg *config.Config) *cobra.Command {
3434
}
3535

3636
cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output")
37-
cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open PRs in the browser")
37+
cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output stack data as JSON")
3838

3939
return cmd
4040
}
@@ -53,8 +53,8 @@ func runView(cfg *config.Config, opts *viewOptions) error {
5353
syncStackPRs(cfg, s)
5454
_ = stack.Save(gitDir, sf)
5555

56-
if opts.web {
57-
return viewWeb(cfg, s)
56+
if opts.asJSON {
57+
return viewJSON(cfg, s, currentBranch)
5858
}
5959

6060
if opts.short {
@@ -115,6 +115,78 @@ func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef
115115
return ""
116116
}
117117

118+
// JSON output types for gh stack view --json.
119+
type viewJSONOutput struct {
120+
Trunk string `json:"trunk"`
121+
Prefix string `json:"prefix,omitempty"`
122+
CurrentBranch string `json:"currentBranch"`
123+
Branches []viewJSONBranch `json:"branches"`
124+
}
125+
126+
type viewJSONBranch struct {
127+
Name string `json:"name"`
128+
Head string `json:"head,omitempty"`
129+
Base string `json:"base,omitempty"`
130+
IsCurrent bool `json:"isCurrent"`
131+
IsMerged bool `json:"isMerged"`
132+
NeedsRebase bool `json:"needsRebase"`
133+
PR *viewJSONPR `json:"pr,omitempty"`
134+
}
135+
136+
type viewJSONPR struct {
137+
Number int `json:"number"`
138+
URL string `json:"url,omitempty"`
139+
State string `json:"state"`
140+
}
141+
142+
func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error {
143+
out := viewJSONOutput{
144+
Trunk: s.Trunk.Branch,
145+
Prefix: s.Prefix,
146+
CurrentBranch: currentBranch,
147+
Branches: make([]viewJSONBranch, 0, len(s.Branches)),
148+
}
149+
150+
for _, b := range s.Branches {
151+
jb := viewJSONBranch{
152+
Name: b.Branch,
153+
Head: b.Head,
154+
Base: b.Base,
155+
IsCurrent: b.Branch == currentBranch,
156+
IsMerged: b.IsMerged(),
157+
}
158+
159+
// Check if the branch needs rebasing (base not ancestor of branch).
160+
if !jb.IsMerged {
161+
baseBranch := s.ActiveBaseBranch(b.Branch)
162+
if isAnc, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !isAnc {
163+
jb.NeedsRebase = true
164+
}
165+
}
166+
167+
if b.PullRequest != nil && b.PullRequest.Number != 0 {
168+
state := "OPEN"
169+
if b.PullRequest.Merged {
170+
state = "MERGED"
171+
}
172+
jb.PR = &viewJSONPR{
173+
Number: b.PullRequest.Number,
174+
URL: b.PullRequest.URL,
175+
State: state,
176+
}
177+
}
178+
179+
out.Branches = append(out.Branches, jb)
180+
}
181+
182+
data, err := json.MarshalIndent(out, "", " ")
183+
if err != nil {
184+
return fmt.Errorf("marshalling JSON: %w", err)
185+
}
186+
_, err = fmt.Fprintf(cfg.Out, "%s\n", data)
187+
return err
188+
}
189+
118190
func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) string {
119191
if b.PullRequest == nil || b.PullRequest.Number == 0 {
120192
return ""
@@ -318,51 +390,3 @@ func timeAgo(t time.Time) string {
318390
return fmt.Sprintf("%d months ago", months)
319391
}
320392
}
321-
322-
func viewWeb(cfg *config.Config, s *stack.Stack) error {
323-
client, err := cfg.GitHubClient()
324-
if err != nil {
325-
return err
326-
}
327-
328-
repo, err := cfg.Repo()
329-
if err != nil {
330-
return err
331-
}
332-
333-
b := browser.New("", cfg.Out, cfg.Err)
334-
335-
opened := 0
336-
for _, br := range s.Branches {
337-
if br.IsMerged() {
338-
continue
339-
}
340-
var url string
341-
if br.PullRequest != nil && br.PullRequest.URL != "" {
342-
url = br.PullRequest.URL
343-
} else {
344-
pr, err := client.FindPRForBranch(br.Branch)
345-
if err != nil || pr == nil {
346-
continue
347-
}
348-
url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.Owner, repo.Name, pr.Number)
349-
}
350-
if err := b.Browse(url); err != nil {
351-
cfg.Warningf("failed to open %s: %v", url, err)
352-
} else {
353-
opened++
354-
}
355-
}
356-
357-
if opened == 0 {
358-
cfg.Printf("No PRs found to open in browser.")
359-
} else {
360-
cfg.Successf("Opened %d PRs in browser", opened)
361-
}
362-
363-
if mergedCount := len(s.MergedBranches()); mergedCount > 0 {
364-
cfg.Printf("Skipped %d merged PRs", mergedCount)
365-
}
366-
367-
return nil
368-
}

cmd/view_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"io"
46
"testing"
57
"time"
68

9+
"github.com/github/gh-stack/internal/config"
10+
"github.com/github/gh-stack/internal/git"
11+
"github.com/github/gh-stack/internal/stack"
712
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
814
)
915

1016
func TestTimeAgo(t *testing.T) {
@@ -30,3 +36,171 @@ func TestTimeAgo(t *testing.T) {
3036
})
3137
}
3238
}
39+
40+
func TestViewJSON(t *testing.T) {
41+
git.SetOps(&git.MockOps{
42+
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
43+
return true, nil // all branches are linear
44+
},
45+
})
46+
47+
tests := []struct {
48+
name string
49+
stack *stack.Stack
50+
currentBranch string
51+
wantTrunk string
52+
wantBranches int
53+
wantCurrent string
54+
}{
55+
{
56+
name: "basic stack with PRs",
57+
stack: &stack.Stack{
58+
Prefix: "feat",
59+
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
60+
Branches: []stack.BranchRef{
61+
{
62+
Branch: "feat/01",
63+
Head: "bbb",
64+
Base: "aaa",
65+
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"},
66+
},
67+
{
68+
Branch: "feat/02",
69+
Head: "ccc",
70+
Base: "bbb",
71+
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
72+
},
73+
},
74+
},
75+
currentBranch: "feat/02",
76+
wantTrunk: "main",
77+
wantBranches: 2,
78+
wantCurrent: "feat/02",
79+
},
80+
{
81+
name: "stack with merged branch",
82+
stack: &stack.Stack{
83+
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
84+
Branches: []stack.BranchRef{
85+
{
86+
Branch: "layer-1",
87+
Head: "bbb",
88+
Base: "aaa",
89+
PullRequest: &stack.PullRequestRef{Number: 10, Merged: true},
90+
},
91+
{
92+
Branch: "layer-2",
93+
Head: "ccc",
94+
Base: "bbb",
95+
},
96+
},
97+
},
98+
currentBranch: "layer-2",
99+
wantTrunk: "main",
100+
wantBranches: 2,
101+
wantCurrent: "layer-2",
102+
},
103+
{
104+
name: "empty stack",
105+
stack: &stack.Stack{
106+
Trunk: stack.BranchRef{Branch: "main"},
107+
Branches: []stack.BranchRef{},
108+
},
109+
currentBranch: "main",
110+
wantTrunk: "main",
111+
wantBranches: 0,
112+
wantCurrent: "main",
113+
},
114+
}
115+
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
cfg, outR, _ := config.NewTestConfig()
119+
defer outR.Close()
120+
121+
err := viewJSON(cfg, tt.stack, tt.currentBranch)
122+
require.NoError(t, err)
123+
cfg.Out.Close()
124+
125+
raw, err := io.ReadAll(outR)
126+
require.NoError(t, err)
127+
128+
var got viewJSONOutput
129+
err = json.Unmarshal(raw, &got)
130+
require.NoError(t, err, "output should be valid JSON: %s", string(raw))
131+
132+
assert.Equal(t, tt.wantTrunk, got.Trunk)
133+
assert.Equal(t, tt.wantCurrent, got.CurrentBranch)
134+
assert.Len(t, got.Branches, tt.wantBranches)
135+
})
136+
}
137+
}
138+
139+
func TestViewJSON_BranchFields(t *testing.T) {
140+
git.SetOps(&git.MockOps{
141+
IsAncestorFn: func(ancestor, descendant string) (bool, error) {
142+
// feat/02 needs rebase
143+
if descendant == "feat/02" {
144+
return false, nil
145+
}
146+
return true, nil
147+
},
148+
})
149+
150+
s := &stack.Stack{
151+
Prefix: "feat",
152+
Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"},
153+
Branches: []stack.BranchRef{
154+
{
155+
Branch: "feat/01",
156+
Head: "bbb222",
157+
Base: "aaa111",
158+
PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42", Merged: true},
159+
},
160+
{
161+
Branch: "feat/02",
162+
Head: "ccc333",
163+
Base: "bbb222",
164+
PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"},
165+
},
166+
},
167+
}
168+
169+
cfg, outR, _ := config.NewTestConfig()
170+
defer outR.Close()
171+
172+
err := viewJSON(cfg, s, "feat/02")
173+
require.NoError(t, err)
174+
cfg.Out.Close()
175+
176+
raw, err := io.ReadAll(outR)
177+
require.NoError(t, err)
178+
179+
var got viewJSONOutput
180+
require.NoError(t, json.Unmarshal(raw, &got))
181+
182+
assert.Equal(t, "feat", got.Prefix)
183+
184+
// First branch: merged
185+
b0 := got.Branches[0]
186+
assert.Equal(t, "feat/01", b0.Name)
187+
assert.Equal(t, "bbb222", b0.Head)
188+
assert.Equal(t, "aaa111", b0.Base)
189+
assert.False(t, b0.IsCurrent)
190+
assert.True(t, b0.IsMerged)
191+
assert.False(t, b0.NeedsRebase, "merged branches should not need rebase")
192+
require.NotNil(t, b0.PR)
193+
assert.Equal(t, 42, b0.PR.Number)
194+
assert.Equal(t, "MERGED", b0.PR.State)
195+
assert.Equal(t, "https://github.com/o/r/pull/42", b0.PR.URL)
196+
197+
// Second branch: current, needs rebase
198+
b1 := got.Branches[1]
199+
assert.Equal(t, "feat/02", b1.Name)
200+
assert.True(t, b1.IsCurrent)
201+
assert.False(t, b1.IsMerged)
202+
assert.True(t, b1.NeedsRebase)
203+
require.NotNil(t, b1.PR)
204+
assert.Equal(t, 43, b1.PR.Number)
205+
assert.Equal(t, "OPEN", b1.PR.State)
206+
}

0 commit comments

Comments
 (0)