Skip to content

Commit d4adefe

Browse files
committed
create stack api
1 parent facd16d commit d4adefe

7 files changed

Lines changed: 291 additions & 10 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ main (trunk)
5555

5656
The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from it. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it.
5757

58-
When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer.
58+
When you push, `gh stack` creates one PR per branch and links them together as a **Stack** on GitHub. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer.
5959

6060
### Local tracking
6161

@@ -261,6 +261,8 @@ gh stack push [flags]
261261

262262
Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. Uses `--force-with-lease` by default to safely update rebased branches.
263263

264+
After creating PRs, `push` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous push), it is left as-is — updating existing stacks will be supported in an upcoming release.
265+
264266
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.
265267

266268
| Flag | Description |

cmd/push.go

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package cmd
33
import (
44
"errors"
55
"fmt"
6+
"strconv"
67
"strings"
78

9+
"github.com/cli/go-gh/v2/pkg/api"
810
"github.com/cli/go-gh/v2/pkg/prompter"
911
"github.com/github/gh-stack/internal/config"
1012
"github.com/github/gh-stack/internal/git"
@@ -165,15 +167,8 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
165167
}
166168
}
167169

168-
// TODO: Add PRs to a stack
169-
//
170-
// We can call an API after all the individual PRs are created/updated to create the stack at once,
171-
// or we can add a flag to the existing PR API to incrementally build the stack.
172-
//
173-
// For now, the PRs are pushed and created individually but are NOT linked as a formal stack on GitHub.
174-
cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.")
175-
fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n")
176-
fmt.Fprintf(cfg.Err, " grouped into a Stack.\n")
170+
// Create or update the stack on GitHub
171+
createStack(cfg, client, s)
177172

178173
// Update base commit hashes and sync PR state
179174
updateBaseSHAs(s)
@@ -261,3 +256,55 @@ func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, erro
261256
}
262257
return multi.Remotes[selected], nil
263258
}
259+
260+
// createStack attempts to create a stack on GitHub from the active PRs.
261+
// It is a best-effort operation: failures are reported as warnings but do
262+
// not cause the push command to fail (the PRs are already created).
263+
func createStack(cfg *config.Config, client interface{ CreateStack([]int) (int, error) }, s *stack.Stack) {
264+
// Collect PR numbers in stack order (bottom to top).
265+
var prNumbers []int
266+
for _, b := range s.Branches {
267+
if b.IsMerged() {
268+
continue
269+
}
270+
if b.PullRequest != nil {
271+
prNumbers = append(prNumbers, b.PullRequest.Number)
272+
}
273+
}
274+
275+
// The API requires at least 2 PRs to form a stack.
276+
if len(prNumbers) < 2 {
277+
return
278+
}
279+
280+
stackID, err := client.CreateStack(prNumbers)
281+
if err == nil {
282+
s.ID = strconv.Itoa(stackID)
283+
cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers))
284+
return
285+
}
286+
287+
cfg.Warningf("Failed to create stack on GitHub: %v", err)
288+
var httpErr *api.HTTPError
289+
if !errors.As(err, &httpErr) {
290+
cfg.Warningf("Failed to create stack on GitHub: %v", err)
291+
return
292+
}
293+
294+
cfg.Warningf("error %s with code %s", httpErr.StatusCode, httpErr.Message)
295+
switch httpErr.StatusCode {
296+
case 422:
297+
if s.ID != "" {
298+
// Stack was already created in a previous push; the update API
299+
// (PUT) is needed to modify it but is not yet available.
300+
cfg.Infof("Stack already exists on GitHub")
301+
} else {
302+
cfg.Warningf("Could not create stack: %s", httpErr.Message)
303+
cfg.Printf(" Updating existing stacks will be supported in an upcoming release.")
304+
}
305+
case 404:
306+
cfg.Warningf("Stacked PRs are not enabled for this repository")
307+
default:
308+
cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message)
309+
}
310+
}

cmd/push_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package cmd
33
import (
44
"fmt"
55
"io"
6+
"net/url"
67
"testing"
78

9+
"github.com/cli/go-gh/v2/pkg/api"
810
"github.com/github/gh-stack/internal/config"
911
"github.com/github/gh-stack/internal/git"
1012
"github.com/github/gh-stack/internal/github"
@@ -225,3 +227,195 @@ func TestPush_Humanize(t *testing.T) {
225227
})
226228
}
227229
}
230+
231+
func TestCreateStack_Success(t *testing.T) {
232+
s := &stack.Stack{
233+
Trunk: stack.BranchRef{Branch: "main"},
234+
Branches: []stack.BranchRef{
235+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
236+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
237+
},
238+
}
239+
240+
var gotNumbers []int
241+
mock := &github.MockClient{
242+
CreateStackFn: func(prNumbers []int) (int, error) {
243+
gotNumbers = prNumbers
244+
return 42, nil
245+
},
246+
}
247+
248+
cfg, _, errR := config.NewTestConfig()
249+
createStack(cfg, mock, s)
250+
251+
cfg.Err.Close()
252+
errOut, _ := io.ReadAll(errR)
253+
output := string(errOut)
254+
255+
assert.Equal(t, []int{10, 11}, gotNumbers)
256+
assert.Equal(t, "42", s.ID)
257+
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
258+
}
259+
260+
func TestCreateStack_AlreadyExists_WithLocalID(t *testing.T) {
261+
s := &stack.Stack{
262+
ID: "99",
263+
Trunk: stack.BranchRef{Branch: "main"},
264+
Branches: []stack.BranchRef{
265+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
266+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
267+
},
268+
}
269+
270+
mock := &github.MockClient{
271+
CreateStackFn: func([]int) (int, error) {
272+
return 0, &api.HTTPError{
273+
StatusCode: 422,
274+
Message: "Pull requests are already in a stack",
275+
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
276+
}
277+
},
278+
}
279+
280+
cfg, _, errR := config.NewTestConfig()
281+
createStack(cfg, mock, s)
282+
283+
cfg.Err.Close()
284+
errOut, _ := io.ReadAll(errR)
285+
output := string(errOut)
286+
287+
assert.Contains(t, output, "Stack already exists on GitHub")
288+
assert.Equal(t, "99", s.ID)
289+
}
290+
291+
func TestCreateStack_AlreadyExists_NoLocalID(t *testing.T) {
292+
s := &stack.Stack{
293+
Trunk: stack.BranchRef{Branch: "main"},
294+
Branches: []stack.BranchRef{
295+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
296+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
297+
},
298+
}
299+
300+
mock := &github.MockClient{
301+
CreateStackFn: func([]int) (int, error) {
302+
return 0, &api.HTTPError{
303+
StatusCode: 422,
304+
Message: "Pull requests are already in a stack",
305+
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
306+
}
307+
},
308+
}
309+
310+
cfg, _, errR := config.NewTestConfig()
311+
createStack(cfg, mock, s)
312+
313+
cfg.Err.Close()
314+
errOut, _ := io.ReadAll(errR)
315+
output := string(errOut)
316+
317+
assert.Contains(t, output, "Could not create stack")
318+
assert.Contains(t, output, "Updating existing stacks will be supported in an upcoming release")
319+
}
320+
321+
func TestCreateStack_NotAvailable(t *testing.T) {
322+
s := &stack.Stack{
323+
Trunk: stack.BranchRef{Branch: "main"},
324+
Branches: []stack.BranchRef{
325+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
326+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
327+
},
328+
}
329+
330+
mock := &github.MockClient{
331+
CreateStackFn: func([]int) (int, error) {
332+
return 0, &api.HTTPError{
333+
StatusCode: 404,
334+
Message: "Not Found",
335+
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
336+
}
337+
},
338+
}
339+
340+
cfg, _, errR := config.NewTestConfig()
341+
createStack(cfg, mock, s)
342+
343+
cfg.Err.Close()
344+
errOut, _ := io.ReadAll(errR)
345+
output := string(errOut)
346+
347+
assert.Contains(t, output, "not yet available")
348+
}
349+
350+
func TestCreateStack_SkippedForSinglePR(t *testing.T) {
351+
s := &stack.Stack{
352+
Trunk: stack.BranchRef{Branch: "main"},
353+
Branches: []stack.BranchRef{
354+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
355+
},
356+
}
357+
358+
called := false
359+
mock := &github.MockClient{
360+
CreateStackFn: func([]int) (int, error) {
361+
called = true
362+
return 42, nil
363+
},
364+
}
365+
366+
cfg, _, _ := config.NewTestConfig()
367+
createStack(cfg, mock, s)
368+
cfg.Err.Close()
369+
370+
assert.False(t, called, "CreateStack should not be called with fewer than 2 PRs")
371+
}
372+
373+
func TestCreateStack_SkipsMergedBranches(t *testing.T) {
374+
s := &stack.Stack{
375+
Trunk: stack.BranchRef{Branch: "main"},
376+
Branches: []stack.BranchRef{
377+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
378+
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
379+
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
380+
},
381+
}
382+
383+
var gotNumbers []int
384+
mock := &github.MockClient{
385+
CreateStackFn: func(prNumbers []int) (int, error) {
386+
gotNumbers = prNumbers
387+
return 42, nil
388+
},
389+
}
390+
391+
cfg, _, _ := config.NewTestConfig()
392+
createStack(cfg, mock, s)
393+
cfg.Err.Close()
394+
395+
assert.Equal(t, []int{11, 12}, gotNumbers, "should only include non-merged PRs")
396+
}
397+
398+
func TestCreateStack_SkipsBranchesWithoutPR(t *testing.T) {
399+
s := &stack.Stack{
400+
Trunk: stack.BranchRef{Branch: "main"},
401+
Branches: []stack.BranchRef{
402+
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
403+
{Branch: "b2"}, // no PR yet
404+
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
405+
},
406+
}
407+
408+
var gotNumbers []int
409+
mock := &github.MockClient{
410+
CreateStackFn: func(prNumbers []int) (int, error) {
411+
gotNumbers = prNumbers
412+
return 42, nil
413+
},
414+
}
415+
416+
cfg, _, _ := config.NewTestConfig()
417+
createStack(cfg, mock, s)
418+
cfg.Err.Close()
419+
420+
assert.Equal(t, []int{10, 12}, gotNumbers, "should skip branches without PRs")
421+
}

internal/github/client_interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type ClientOps interface {
88
FindAnyPRForBranch(branch string) (*PullRequest, error)
99
FindPRDetailsForBranch(branch string) (*PRDetails, error)
1010
CreatePR(base, head, title, body string, draft bool) (*PullRequest, error)
11+
CreateStack(prNumbers []int) (int, error)
1112
DeleteStack() error
1213
}
1314

internal/github/github.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package github
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57

68
"github.com/cli/go-gh/v2/pkg/api"
@@ -270,6 +272,32 @@ func (c *Client) DeleteStack() error {
270272
return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API")
271273
}
272274

275+
// CreateStack creates a stack on GitHub from an ordered list of PR numbers.
276+
// The PR numbers must be ordered from bottom to top of the stack and must
277+
// form a valid base-to-head chain. Returns the server-assigned stack ID.
278+
func (c *Client) CreateStack(prNumbers []int) (int, error) {
279+
type createStackRequest struct {
280+
PullRequestNumbers []int `json:"pull_request_numbers"`
281+
}
282+
283+
body, err := json.Marshal(createStackRequest{PullRequestNumbers: prNumbers})
284+
if err != nil {
285+
return 0, fmt.Errorf("marshaling request: %w", err)
286+
}
287+
288+
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo)
289+
290+
var response struct {
291+
ID int `json:"id"`
292+
}
293+
294+
if err := c.rest.Post(path, bytes.NewReader(body), &response); err != nil {
295+
return 0, err
296+
}
297+
298+
return response.ID, nil
299+
}
300+
273301
func (c *Client) repositoryID() (string, error) {
274302
var query struct {
275303
Repository struct {

internal/github/mock_client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type MockClient struct {
1010
FindAnyPRForBranchFn func(string) (*PullRequest, error)
1111
FindPRDetailsForBranchFn func(string) (*PRDetails, error)
1212
CreatePRFn func(string, string, string, string, bool) (*PullRequest, error)
13+
CreateStackFn func([]int) (int, error)
1314
DeleteStackFn func() error
1415
}
1516

@@ -50,3 +51,10 @@ func (m *MockClient) DeleteStack() error {
5051
}
5152
return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API")
5253
}
54+
55+
func (m *MockClient) CreateStack(prNumbers []int) (int, error) {
56+
if m.CreateStackFn != nil {
57+
return m.CreateStackFn(prNumbers)
58+
}
59+
return 0, nil
60+
}

skills/gh-stack/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ gh stack push --skip-prs
489489

490490
- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`)
491491
- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch)
492+
- After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled)
492493
- Syncs PR metadata for branches that already have PRs
493494

494495
**PR title auto-generation (`--auto`):**

0 commit comments

Comments
 (0)