Skip to content

Commit cc60343

Browse files
committed
Wire the single-screen submit TUI into gh stack submit
Launch the submit editor from `gh stack submit` in interactive terminals, collecting per-branch PR drafts and applying them in a single batch. In non-interactive terminals or with --auto, fall back to auto-generated titles and skip the editor. Update the README and CLI reference to describe the single-screen flow.
1 parent 56f723a commit cc60343

4 files changed

Lines changed: 201 additions & 36 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,13 @@ Creates a Stacked PR for every branch in the stack, pushing branches to the remo
372372

373373
After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs will be added to the top of the stack.
374374

375-
When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely.
375+
In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (<kbd>Ctrl</kbd>+<kbd>X</kbd>). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with <kbd>Ctrl</kbd>+<kbd>S</kbd>. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles.
376+
377+
In the editor, new PRs default to ready for review; flip any PR to draft with the ready ↔ draft toggle. With `--auto`, new PRs are created as drafts unless you pass `--open`.
376378

377379
| Flag | Description |
378380
|------|-------------|
379-
| `--auto` | Use auto-generated PR titles without prompting |
381+
| `--auto` | Skip the editor and use auto-generated PR titles |
380382
| `--open` | Mark new and existing PRs as ready for review |
381383
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |
382384

cmd/submit.go

Lines changed: 105 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strconv"
77
"strings"
88

9+
tea "github.com/charmbracelet/bubbletea"
910
"github.com/cli/go-gh/v2/pkg/api"
1011
"github.com/cli/go-gh/v2/pkg/prompter"
1112
"github.com/github/gh-stack/internal/config"
@@ -14,6 +15,7 @@ import (
1415
"github.com/github/gh-stack/internal/modify"
1516
"github.com/github/gh-stack/internal/pr"
1617
"github.com/github/gh-stack/internal/stack"
18+
"github.com/github/gh-stack/internal/tui/stackview"
1719
"github.com/github/gh-stack/internal/tui/submitview"
1820
"github.com/spf13/cobra"
1921
)
@@ -32,21 +34,27 @@ func SubmitCmd(cfg *config.Config) *cobra.Command {
3234
Short: "Create a stack of PRs on GitHub",
3335
Long: `Push all branches and create or update a stack of PRs on GitHub.
3436
37+
In an interactive terminal, a single-screen editor opens. Every branch without a
38+
PR is included by default; deselect any you don't want with the checkbox or ^x,
39+
and draft each PR's title, description, and draft state, then submit them all at
40+
once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the
41+
editor and use auto-generated titles.
42+
3543
This command performs several steps:
3644
1. Pushes all branches to the remote
37-
2. Creates new PRs for branches that don't have one
45+
2. Creates new PRs for the included branches
3846
3. Updates base branches for existing PRs
3947
4. Creates or updates the stack on GitHub
4048
41-
New PRs are created as drafts by default. Use --open to mark them as ready
42-
for review.`,
43-
Example: ` # Push and create/update PRs (prompts for PR titles)
49+
In the editor, new PRs default to ready for review; toggle "Open as draft" per
50+
PR. With --auto, new PRs are created as drafts unless you pass --open.`,
51+
Example: ` # Push and create/update PRs (opens the interactive editor)
4452
$ gh stack submit
4553
46-
# Use auto-generated PR titles without prompting
54+
# Skip the editor and use auto-generated PR titles
4755
$ gh stack submit --auto
4856
49-
# Mark all PRs as ready for review
57+
# Mark new and existing PRs as ready for review
5058
$ gh stack submit --open`,
5159
RunE: func(cmd *cobra.Command, args []string) error {
5260
return runSubmit(cfg, opts)
@@ -132,7 +140,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
132140
}
133141

134142
// Sync PR state to detect merged/queued PRs before pushing.
135-
_ = syncStackPRs(cfg, s)
143+
prDetails := syncStackPRs(cfg, s)
136144

137145
// Resolve remote for pushing
138146
remote, err := pickRemote(cfg, currentBranch, opts.remote)
@@ -179,15 +187,28 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
179187
templateContent = pr.FindTemplate(repoRoot)
180188
}
181189

190+
// In an interactive terminal, open the TUI so the user can pick which new
191+
// branches become PRs and draft each PR's title, description, and draft
192+
// state. The drafts feed the create path below. On the --auto /
193+
// non-interactive path drafts stays nil and ensurePR/createPR fall back to
194+
// auto-generated titles and bodies (today's behavior).
195+
var drafts map[string]*submitview.PRDraft
196+
if cfg.IsInteractive() && !opts.auto {
197+
collected, cancelled, tuiErr := collectPRDrafts(cfg, s, currentBranch, prDetails, templateContent)
198+
if tuiErr != nil {
199+
cfg.Errorf("failed to run the submit editor: %s", tuiErr)
200+
return ErrSilent
201+
}
202+
if cancelled {
203+
cfg.Printf("Submit cancelled — no branches were pushed")
204+
return nil
205+
}
206+
drafts = collected
207+
}
208+
182209
// Push each branch and create/update its PR in stack order (bottom to top).
183210
// Sequential pushing ensures each branch's base is up-to-date on the
184211
// remote before the next branch is pushed, preventing race conditions.
185-
//
186-
// drafts carries per-PR overrides from the interactive editor. It is nil on
187-
// the --auto / non-interactive path, in which case ensurePR/createPR fall
188-
// back to auto-generated titles and bodies (today's behavior).
189-
var drafts map[string]*submitview.PRDraft
190-
191212
cfg.Printf("Pushing to %s...", remote)
192213
for i, b := range s.Branches {
193214
if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() {
@@ -229,6 +250,73 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
229250
return nil
230251
}
231252

253+
// collectPRDrafts loads branch display data and runs the interactive submit TUI
254+
// so the user can choose which new branches become PRs and draft each one. It
255+
// returns the per-branch overrides, whether the user cancelled, and any error.
256+
// When the stack contains no branches without a PR, it skips the TUI and
257+
// returns nil drafts so the normal push/relink path runs.
258+
func collectPRDrafts(cfg *config.Config, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) {
259+
fmt.Fprintf(cfg.Err, "Loading stack...")
260+
viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails)
261+
fmt.Fprintf(cfg.Err, "\r\033[2K")
262+
263+
// Reverse so index 0 = top of stack (matches the visual order).
264+
reversed := make([]stackview.BranchNode, len(viewNodes))
265+
for i, n := range viewNodes {
266+
reversed[len(viewNodes)-1-i] = n
267+
}
268+
nodes := submitview.NewSubmitNodes(reversed, templateContent)
269+
270+
// Nothing to create — skip the TUI and run the normal push/relink path.
271+
if submitview.CountNew(nodes) == 0 {
272+
return nil, false, nil
273+
}
274+
275+
repoLabel := ""
276+
if repo, err := cfg.Repo(); err == nil {
277+
repoLabel = repo.Owner + "/" + repo.Name
278+
}
279+
280+
model := submitview.New(submitview.Options{
281+
Nodes: nodes,
282+
Trunk: s.Trunk,
283+
StackName: stackDisplayName(s),
284+
RepoLabel: repoLabel,
285+
Version: Version,
286+
})
287+
288+
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseAllMotion())
289+
final, err := p.Run()
290+
if err != nil {
291+
return nil, false, fmt.Errorf("running submit TUI: %w", err)
292+
}
293+
294+
m, ok := final.(submitview.Model)
295+
if !ok {
296+
return nil, false, fmt.Errorf("unexpected model type %T", final)
297+
}
298+
if m.Cancelled() || !m.SubmitRequested() {
299+
return nil, true, nil
300+
}
301+
return submitview.BuildDrafts(m.Nodes()), false, nil
302+
}
303+
304+
// stackDisplayName returns a human-readable name for the stack, used in the TUI
305+
// header: the stack prefix, else the common branch-name prefix, else the
306+
// bottom-most branch.
307+
func stackDisplayName(s *stack.Stack) string {
308+
if s.Prefix != "" {
309+
return strings.TrimRight(s.Prefix, "/")
310+
}
311+
if p := submitview.CommonPrefix(s.BranchNames()); p != "" {
312+
return strings.TrimRight(p, "/")
313+
}
314+
if len(s.Branches) > 0 {
315+
return s.Branches[0].Branch
316+
}
317+
return s.Trunk.Branch
318+
}
319+
232320
// ensurePR finds or creates a PR for the branch at index i, and updates
233321
// its base branch if needed. This is the single place where PR state is
234322
// reconciled during submit.
@@ -330,27 +418,12 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
330418
body = generatePRBody(d.Body, "")
331419
isDraft = d.Draft
332420
} else {
333-
// Auto / non-interactive default path.
421+
// Auto / non-interactive default path: an auto-generated title and a
422+
// body built from the branch's commits (the interactive title is
423+
// drafted in the submit TUI instead).
334424
var commitBody string
335425
title, commitBody = defaultPRTitleBody(baseBranch, b.Branch)
336-
originalTitle := title
337-
if !opts.auto && cfg.IsInteractive() {
338-
input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title)
339-
if err != nil {
340-
if isInterruptError(err) {
341-
return errInterrupt
342-
}
343-
// Non-interrupt error: keep the auto-generated title.
344-
} else if input != "" {
345-
title = input
346-
}
347-
}
348-
349-
prBody := commitBody
350-
if title != originalTitle && commitBody != "" {
351-
prBody = originalTitle + "\n\n" + commitBody
352-
}
353-
body = generatePRBody(prBody, templateContent)
426+
body = generatePRBody(commitBody, templateContent)
354427
}
355428

356429
newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft)

cmd/submit_tui_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/github/gh-stack/internal/config"
7+
"github.com/github/gh-stack/internal/git"
8+
"github.com/github/gh-stack/internal/github"
9+
"github.com/github/gh-stack/internal/stack"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestStackDisplayName(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
s *stack.Stack
18+
want string
19+
}{
20+
{
21+
name: "uses stack prefix",
22+
s: &stack.Stack{Prefix: "feat/", Trunk: stack.BranchRef{Branch: "main"}},
23+
want: "feat",
24+
},
25+
{
26+
name: "falls back to common branch prefix",
27+
s: &stack.Stack{
28+
Trunk: stack.BranchRef{Branch: "main"},
29+
Branches: []stack.BranchRef{{Branch: "feat/auth/a"}, {Branch: "feat/auth/b"}},
30+
},
31+
want: "feat/auth",
32+
},
33+
{
34+
name: "single branch falls back to its name",
35+
s: &stack.Stack{
36+
Trunk: stack.BranchRef{Branch: "main"},
37+
Branches: []stack.BranchRef{{Branch: "solo"}},
38+
},
39+
want: "solo",
40+
},
41+
{
42+
name: "no branches falls back to trunk",
43+
s: &stack.Stack{Trunk: stack.BranchRef{Branch: "main"}},
44+
want: "main",
45+
},
46+
}
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
assert.Equal(t, tt.want, stackDisplayName(tt.s))
50+
})
51+
}
52+
}
53+
54+
// TestCollectPRDrafts_SkipsWhenNoNewBranches verifies the TUI is skipped (no
55+
// program launched) when every branch already has a PR, returning nil drafts so
56+
// the normal push/relink path runs.
57+
func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) {
58+
s := &stack.Stack{
59+
Trunk: stack.BranchRef{Branch: "main"},
60+
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
61+
}
62+
63+
mock := &git.MockOps{
64+
RootDirFn: func() (string, error) { return t.TempDir(), nil },
65+
IsAncestorFn: func(a, b string) (bool, error) { return true, nil },
66+
MergeBaseFn: func(a, b string) (string, error) { return a, nil },
67+
LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { return []git.CommitInfo{{Subject: "c"}}, nil },
68+
DiffStatFilesFn: func(base, head string) ([]git.FileDiffStat, error) { return nil, nil },
69+
}
70+
restore := git.SetOps(mock)
71+
defer restore()
72+
73+
cfg, _, _ := config.NewTestConfig()
74+
prDetails := map[string]*github.PRDetails{
75+
"b1": {Number: 1, State: "OPEN"},
76+
"b2": {Number: 2, State: "OPEN"},
77+
}
78+
79+
drafts, cancelled, err := collectPRDrafts(cfg, s, "b1", prDetails, "")
80+
require.NoError(t, err)
81+
assert.False(t, cancelled)
82+
assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil")
83+
}

docs/src/content/docs/reference/cli.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,18 @@ gh stack submit [flags]
269269

270270
Creates a Stacked PR for every branch in the stack, pushing branches to the remote. After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs are added to the existing stack.
271271

272-
When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely. New PRs are created as **drafts by default**; use `--open` to create new PRs as ready for review and to mark existing PRs as ready for review.
272+
In an interactive terminal, `submit` opens a full-screen editor on a single screen:
273+
274+
- **Left panel** — every branch without a PR is **included by default**; deselect any you don't want to submit with <kbd>Ctrl</kbd>+<kbd>X</kbd>. Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it that it depends on. Branches that already have a PR (open, draft, queued, or merged) are shown for context but are locked; edit those on the web.
275+
- **Right panel** — for the focused branch, draft the title, description (pre-filled from your repo's PR template or commits, with a Glamour markdown preview and `$EDITOR` escape), and whether it opens ready for review or as a draft (a ready ↔ draft toggle). Focusing a locked branch shows a read-only card with a link to its PR (<kbd>o</kbd> to open in the browser).
276+
277+
Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to submit all included PRs at once. The editor supports both keyboard and mouse input. Pass `--auto` (or run in a non-interactive terminal, such as CI) to skip the editor and use auto-generated titles.
278+
279+
In the editor, new PRs default to **ready for review**; flip any PR to **draft** with the ready ↔ draft toggle. With `--auto`, new PRs are created as **drafts** unless you pass `--open`.
273280

274281
| Flag | Description |
275282
|------|-------------|
276-
| `--auto` | Use auto-generated PR titles without prompting |
283+
| `--auto` | Skip the editor and use auto-generated PR titles |
277284
| `--open` | Create new PRs as ready for review instead of drafts, and mark existing PRs as ready for review |
278285
| `--remote <name>` | Remote to push to (defaults to auto-detected remote) |
279286

0 commit comments

Comments
 (0)