Migrate to new Stacks REST API (#177)

* Add stack Number field to local model and schema

The new Stacks REST API exposes a human-facing stack number (shown in the
github.com UI) alongside the internal stack id. Add a Number field to the
stack.Stack model and document it in schema.json so it can be persisted in
the .git/gh-stack file. Purely additive; behavior is unchanged until callers
populate it.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* Cut over stack operations to the public Stacks REST API

Replace the private cli_internal stack endpoints with the new public
Stacks REST API (/repos/{owner}/{repo}/stacks):
- ListStacks / FindStackForPR (?pull_request= filter) / GetStack for reads
- CreateStack, which now returns the created stack including its number
- AddToStack for delta-only appends (there is no full-replace endpoint)
- Unstack for server-driven removal (204 dissolved / 200 partial / 422)

Migrate all callers (checkout, submit, link, sync, unstack, utils) and
drop the client-side unstack eligibility pre-check — the server now
decides which PRs can be unstacked. checkout discovers stacks via the
pull_request filter; submit/link express updates as append-only deltas;
unstack adopts partial-unstack semantics, keeping local tracking when
PRs remain stacked on GitHub.

RemoteStack now carries the stack number, and stack updates resolve a
stack's number from its internal id for stack files that predate the
Number field.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* Remove the personal access token (PAT) limitation

The new Stacks REST API is public, so any user authenticated with the
GitHub CLI (including via a PAT with repo scope) can perform stack
operations once the feature is enabled for their repository. Remove the
PAT detection and the private-preview gating:

- Delete Config.WarnIfPAT / IsPersonalAccessToken and the TokenForHostFn
  test hook (internal/config/auth.go is no longer needed).
- Drop the submit pre-flight that aborted on a PAT.
- Rename warnStacksUnavailableOrPAT to warnStacksUnavailable and simplify
  it to the "stacked PRs not enabled" message.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* address review comments
This commit is contained in:
Sameen Karim
2026-07-15 12:07:44 -04:00
committed by GitHub
parent 95f04b8fed
commit a82dc3ef1d
21 changed files with 1023 additions and 932 deletions
+29 -33
View File
@@ -188,12 +188,13 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
return nil, "", ErrAPIFailure
}
// Step 1: List stacks and find one containing the target PR
remoteStack, err := findRemoteStackForPR(client, prNumber)
// Step 1: Find the stack containing the target PR via the list endpoint's
// server-side pull_request filter.
remoteStack, err := client.FindStackForPR(prNumber)
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
return nil, "", ErrAPIFailure
}
cfg.Errorf("failed to list stacks: %v", err)
@@ -205,7 +206,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
}
// Step 2: Fetch PR details for every PR in the remote stack
prs, err := fetchStackPRDetails(client, remoteStack.PullRequests)
prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers())
if err != nil {
cfg.Errorf("failed to fetch PR details: %v", err)
return nil, "", ErrAPIFailure
@@ -245,11 +246,14 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
syncRemotePRState(localStack, prs)
// Case A: branch is in a local stack — check composition
if stackCompositionMatches(localStack, remoteStack.PullRequests) {
if stackCompositionMatches(localStack, remoteStack.PRNumbers()) {
// Composition matches — checkout
if localStack.ID == "" {
localStack.ID = remoteStackID
}
// remoteStack is authoritative for both identifiers here, so
// refresh them together. Updating only one (e.g. the number while
// keeping a stale ID) breaks later ID-based discovery when the old
// remote stack was replaced by a new one holding the same PRs.
localStack.ID = remoteStackID
localStack.Number = remoteStack.Number
if err := stack.Save(gitDir, sf); err != nil {
return nil, "", handleSaveError(cfg, err)
}
@@ -274,7 +278,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
return nil, "", ErrSilent
}
s, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID)
s, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStack.Number)
if err != nil {
return nil, "", err
}
@@ -286,23 +290,6 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string,
return s, targetBranch, nil
}
// findRemoteStackForPR queries the list stacks API and returns the stack
// containing the given PR number, or nil if no stack contains it.
func findRemoteStackForPR(client github.ClientOps, prNumber int) (*github.RemoteStack, error) {
stacks, err := client.ListStacks()
if err != nil {
return nil, err
}
for i := range stacks {
for _, n := range stacks[i].PullRequests {
if n == prNumber {
return &stacks[i], nil
}
}
}
return nil, nil
}
// fetchStackPRDetails fetches PR details for each number in the stack.
// Returns PRs in the same order as the input numbers.
func fetchStackPRDetails(client github.ClientOps, prNumbers []int) ([]*github.PullRequest, error) {
@@ -418,7 +405,7 @@ func handleCompositionConflict(
return nil, ErrSilent
}
s, importErr := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID)
s, importErr := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStack.Number)
if importErr != nil {
return nil, importErr
}
@@ -429,19 +416,26 @@ func handleCompositionConflict(
return s, nil
case 1:
// Delete remote stack, keep local
if err := client.DeleteStack(remoteStackID); err != nil {
// Unstack the remote stack, keep local
_, dissolved, err := client.Unstack(remoteStack.Number)
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
cfg.Warningf("Remote stack already deleted")
cfg.Warningf("Remote stack already removed")
} else if errors.As(err, &httpErr) && httpErr.StatusCode == 422 {
cfg.Errorf("Cannot unstack remote stack: %s", httpErr.Message)
return nil, ErrAPIFailure
} else {
cfg.Errorf("failed to delete remote stack: %v", err)
cfg.Errorf("failed to unstack remote stack: %v", err)
return nil, ErrAPIFailure
}
} else if dissolved {
cfg.Successf("Remote stack removed")
} else {
cfg.Successf("Remote stack deleted")
cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub")
}
localStack.ID = ""
localStack.Number = 0
if err := stack.Save(gitDir, sf); err != nil {
return nil, handleSaveError(cfg, err)
}
@@ -475,6 +469,7 @@ func importRemoteStack(
trunk string,
prs []*github.PullRequest,
remoteStackID string,
remoteStackNumber int,
) (*stack.Stack, error) {
// Fetch latest refs from remote
if err := git.Fetch(remote); err != nil {
@@ -512,7 +507,8 @@ func importRemoteStack(
trunkSHA, _ := git.RevParse(trunk)
newStack := stack.Stack{
ID: remoteStackID,
ID: remoteStackID,
Number: remoteStackNumber,
Trunk: stack.BranchRef{
Branch: trunk,
Head: trunkSHA,
+33 -79
View File
@@ -158,9 +158,9 @@ func TestCheckout_NumericTarget_StacksNotAvailable(t *testing.T) {
require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}}))
cfg, outR, errR := config.NewTestConfig()
setTestTokenForHost(cfg, "gho_test_oauth_token")
setTestRepo(cfg)
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}
@@ -184,10 +184,8 @@ func TestCheckout_NumericTarget_PRNotInStack(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return nil, nil // PR 99 is not part of any stack
},
}
@@ -243,10 +241,8 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 42, PullRequests: []int{10, 11, 12}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11, 12}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -331,10 +327,8 @@ func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 99, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -445,11 +439,9 @@ func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) {
apiCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
apiCalled = true
return []github.RemoteStack{
{ID: 99, PullRequests: []int{10, 11}},
}, nil
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -464,7 +456,7 @@ func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) {
_ = collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.True(t, apiCalled, "should have called ListStacks API when local miss")
assert.True(t, apiCalled, "should have queried the remote stack API when local miss")
assert.Equal(t, "feat-2", checkedOut)
}
@@ -493,8 +485,8 @@ func TestCheckout_NumericTarget_FallbackToBranchName(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil // no remote stacks
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return nil, nil // no remote stack contains this PR
},
}
@@ -527,11 +519,9 @@ func TestCheckout_NumericTarget_CompositionMismatch_NonInteractive(t *testing.T)
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
// Remote stack has PRs 10, 11, 12 (extra PR added)
return []github.RemoteStack{
{ID: 42, PullRequests: []int{10, 11, 12}},
}, nil
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11, 12}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -590,10 +580,8 @@ func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 50, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 50, Number: 50, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -662,10 +650,8 @@ func TestCheckout_NumericTarget_MergedBranchDeletedFromRemote(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 60, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 60, Number: 60, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -698,10 +684,8 @@ func TestCheckout_NumericTarget_AllPRsMerged(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 70, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 70, Number: 70, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
prs := map[int]*github.PullRequest{
@@ -732,7 +716,7 @@ func TestCheckout_NumericTarget_APIError(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return nil, fmt.Errorf("network error")
},
}
@@ -796,8 +780,8 @@ func TestCheckout_NumericTarget_EmptyStacks(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil // no stacks at all
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return nil, nil // no stacks at all
},
}
@@ -903,34 +887,6 @@ func TestStackCompositionMatches(t *testing.T) {
}
}
func TestFindRemoteStackForPR(t *testing.T) {
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 11}},
{ID: 2, PullRequests: []int{20, 21, 22}},
}, nil
},
}
// Found in first stack
rs, err := findRemoteStackForPR(mock, 11)
require.NoError(t, err)
require.NotNil(t, rs)
assert.Equal(t, 1, rs.ID)
// Found in second stack
rs, err = findRemoteStackForPR(mock, 21)
require.NoError(t, err)
require.NotNil(t, rs)
assert.Equal(t, 2, rs.ID)
// Not found
rs, err = findRemoteStackForPR(mock, 99)
require.NoError(t, err)
assert.Nil(t, rs)
}
func TestCheckout_ByPRURL_Local(t *testing.T) {
// When a PR URL resolves to a locally tracked stack, no API call needed
gitDir := t.TempDir()
@@ -973,14 +929,14 @@ func TestCheckout_ByPRURL_Remote(t *testing.T) {
}
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool { return name == "main" },
FetchFn: func(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) bool { return name == "main" },
FetchFn: func(string) error { return nil },
CreateBranchFn: func(string, string) error { return nil },
SetUpstreamTrackingFn: func(string, string) error { return nil },
RevParseFn: func(string) (string, error) { return "abc123", nil },
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
RevParseFn: func(string) (string, error) { return "abc123", nil },
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
CheckoutBranchFn: func(name string) error {
checkedOut = name
return nil
@@ -993,10 +949,8 @@ func TestCheckout_ByPRURL_Remote(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 11}},
}, nil
FindStackForPRFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{10, 11}}, nil
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
if pr, ok := prDB[n]; ok {
+41 -14
View File
@@ -321,7 +321,7 @@ func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*r
func validatePREligibility(cfg *config.Config, found []*resolvedArg, targetStack *github.RemoteStack) error {
inTargetStack := make(map[int]bool)
if targetStack != nil {
for _, n := range targetStack.PullRequests {
for _, n := range targetStack.PRNumbers() {
inTargetStack[n] = true
}
}
@@ -366,7 +366,7 @@ func listStacksSafe(cfg *config.Config, client github.ClientOps) ([]github.Remot
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
return nil, ErrStacksUnavailable
}
cfg.Errorf("failed to list stacks: %v", err)
@@ -392,7 +392,7 @@ func prevalidateStack(cfg *config.Config, matchedStack *github.RemoteStack, know
}
var dropped []int
for _, n := range matchedStack.PullRequests {
for _, n := range matchedStack.PRNumbers() {
if !knownSet[n] {
dropped = append(dropped, n)
}
@@ -401,7 +401,7 @@ func prevalidateStack(cfg *config.Config, matchedStack *github.RemoteStack, know
if len(dropped) > 0 {
cfg.Errorf("Cannot update stack: this would remove %s from the stack",
formatPRList(dropped))
cfg.Printf("Current stack: %s", formatPRList(matchedStack.PullRequests))
cfg.Printf("Current stack: %s", formatPRList(matchedStack.PRNumbers()))
cfg.Printf("Include all existing PRs in the command to update the stack")
return ErrInvalidArgs
}
@@ -535,7 +535,7 @@ func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.Re
var matched *github.RemoteStack
for i := range stacks {
for _, n := range stacks[i].PullRequests {
for _, n := range stacks[i].PRNumbers() {
if prSet[n] {
if matched != nil && matched.ID != stacks[i].ID {
return nil, fmt.Errorf("PRs belong to multiple stacks — unstack them first, then re-link")
@@ -551,8 +551,7 @@ func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.Re
// createLink creates a new stack with the given PR numbers.
func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) error {
_, err := client.CreateStack(prNumbers)
if err != nil {
if _, err := client.CreateStack(prNumbers); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
@@ -560,7 +559,7 @@ func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) er
cfg.Errorf("Cannot create stack: %s", httpErr.Message)
return ErrAPIFailure
case 404:
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
return ErrStacksUnavailable
default:
cfg.Errorf("Failed to create stack (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
@@ -576,10 +575,14 @@ func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) er
}
// updateLink updates an existing stack with the given PR numbers.
// The update is additive-only: it errors if any existing PRs would be removed.
// The update is additive-only: it errors if any existing PRs would be removed,
// and (because the add endpoint appends to the top) if the existing PRs are not
// an ordered prefix of the desired list.
func updateLink(cfg *config.Config, client github.ClientOps, existing *github.RemoteStack, prNumbers []int) error {
current := existing.PRNumbers()
// Check if the input exactly matches the existing stack.
if slicesEqual(existing.PullRequests, prNumbers) {
if slicesEqual(current, prNumbers) {
cfg.Successf("Stack with %d PRs is already up to date", len(prNumbers))
return nil
}
@@ -591,7 +594,7 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re
}
var dropped []int
for _, n := range existing.PullRequests {
for _, n := range current {
if !newSet[n] {
dropped = append(dropped, n)
}
@@ -600,13 +603,21 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re
if len(dropped) > 0 {
cfg.Errorf("Cannot update stack: this would remove %s from the stack",
formatPRList(dropped))
cfg.Printf("Current stack: %s", formatPRList(existing.PullRequests))
cfg.Printf("Current stack: %s", formatPRList(current))
cfg.Printf("Include all existing PRs in the command to update the stack")
return ErrInvalidArgs
}
stackID := strconv.Itoa(existing.ID)
if err := client.UpdateStack(stackID, prNumbers); err != nil {
// The add endpoint appends to the top of the stack, so the existing PRs
// must be an ordered prefix of the desired list.
delta, ok := appendDelta(current, prNumbers)
if !ok {
cfg.Errorf("Cannot update stack: new PRs must be added to the top of the existing stack")
cfg.Printf("Current stack: %s", formatPRList(current))
return ErrInvalidArgs
}
if _, err := client.AddToStack(existing.Number, delta); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
@@ -642,6 +653,22 @@ func slicesEqual(a, b []int) bool {
return true
}
// appendDelta returns the PR numbers that must be appended to current to reach
// desired, with ok=true, when current is an exact ordered prefix of desired.
// When desired diverges from current (a reorder or removal), ok is false. This
// mirrors the Stacks add endpoint, which only appends to the top of a stack.
func appendDelta(current, desired []int) (delta []int, ok bool) {
if len(current) > len(desired) {
return nil, false
}
for i, n := range current {
if desired[i] != n {
return nil, false
}
}
return desired[len(current):], true
}
func formatPRList(numbers []int) string {
if len(numbers) == 0 {
return ""
+75 -75
View File
@@ -47,9 +47,9 @@ func TestLink_PRNumbers_CreateNewStack(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createdPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -69,7 +69,7 @@ func TestLink_PRNumbers_CreateNewStack(t *testing.T) {
}
func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) {
var updatedID string
var updatedNumber int
var updatedPRs []int
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
@@ -83,13 +83,13 @@ func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{10, 20}},
{ID: 7, Number: 7, PullRequests: []int{10, 20}},
}, nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
updatedID = stackID
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
updatedNumber = stackNumber
updatedPRs = prNumbers
return nil
return &github.RemoteStack{ID: 7, Number: stackNumber, PullRequests: []int{10, 20, 30}}, nil
},
}
@@ -104,8 +104,8 @@ func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) {
output := string(errOut)
assert.NoError(t, err)
assert.Equal(t, "7", updatedID)
assert.Equal(t, []int{10, 20, 30}, updatedPRs)
assert.Equal(t, 7, updatedNumber)
assert.Equal(t, []int{30}, updatedPRs)
assert.Contains(t, output, "Updated stack to 3 PRs")
}
@@ -122,12 +122,12 @@ func TestLink_PRNumbers_ExactMatch_NoOp(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{10, 20, 30}},
{ID: 7, Number: 7, PullRequests: []int{10, 20, 30}},
}, nil
},
UpdateStackFn: func(string, []int) error {
t.Fatal("UpdateStack should not be called for exact match")
return nil
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack should not be called for exact match")
return nil, nil
},
}
@@ -158,7 +158,7 @@ func TestLink_PRNumbers_WouldRemovePRs(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{10, 20, 30}},
{ID: 7, Number: 7, PullRequests: []int{10, 20, 30}},
}, nil
},
}
@@ -190,8 +190,8 @@ func TestLink_PRNumbers_MultipleStacks(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10, 20}},
{ID: 2, PullRequests: []int{30, 40}},
{ID: 1, Number: 1, PullRequests: []int{10, 20}},
{ID: 2, Number: 2, PullRequests: []int{30, 40}},
}, nil
},
}
@@ -244,7 +244,7 @@ func TestLink_DuplicateArgs(t *testing.T) {
func TestLink_StacksUnavailable(t *testing.T) {
cfg, _, errR := config.NewTestConfig()
setTestTokenForHost(cfg, "gho_test_oauth_token")
setTestRepo(cfg)
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil
@@ -277,8 +277,8 @@ func TestLink_Create422(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 0, Number: 0}, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests must form a stack",
}
@@ -379,9 +379,9 @@ func TestLink_RejectsQueuedPR(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack should not be called for ineligible PRs")
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
}
@@ -419,9 +419,9 @@ func TestLink_RejectsAutoMergeEnabledPR(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack should not be called for ineligible PRs")
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
}
@@ -561,7 +561,7 @@ func TestLink_ReportsMultipleIneligiblePRs(t *testing.T) {
// Regression test to ensure a queued PR that is already a member of
// the target stack does not block adding new PRs to that same stack.
func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) {
var updatedID string
var updatedNumber int
var updatedPRs []int
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
@@ -581,17 +581,17 @@ func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{100}},
{ID: 7, Number: 7, PullRequests: []int{100}},
}, nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
updatedID = stackID
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
updatedNumber = stackNumber
updatedPRs = prNumbers
return nil
return &github.RemoteStack{ID: 7, Number: stackNumber, PullRequests: []int{100, 101, 102}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack should not be called when updating an existing stack")
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
}
@@ -606,8 +606,8 @@ func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) {
output := string(errOut)
require.NoError(t, err)
assert.Equal(t, "7", updatedID)
assert.Equal(t, []int{100, 101, 102}, updatedPRs)
assert.Equal(t, 7, updatedNumber)
assert.Equal(t, []int{101, 102}, updatedPRs)
assert.NotContains(t, output, "cannot be added to a stack")
}
@@ -633,12 +633,12 @@ func TestLink_AllowsMergedPRAlreadyInStack(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 8, PullRequests: []int{100}},
{ID: 8, Number: 8, PullRequests: []int{100}},
}, nil
},
UpdateStackFn: func(_ string, prNumbers []int) error {
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
updatedPRs = prNumbers
return nil
return &github.RemoteStack{ID: 8, Number: stackNumber, PullRequests: []int{100, 101}}, nil
},
}
@@ -653,7 +653,7 @@ func TestLink_AllowsMergedPRAlreadyInStack(t *testing.T) {
output := string(errOut)
require.NoError(t, err)
assert.Equal(t, []int{100, 101}, updatedPRs)
assert.Equal(t, []int{101}, updatedPRs)
assert.NotContains(t, output, "cannot be added to a stack")
}
@@ -678,12 +678,12 @@ func TestLink_AllowsAutoMergePRAlreadyInStack(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 9, PullRequests: []int{100}},
{ID: 9, Number: 9, PullRequests: []int{100}},
}, nil
},
UpdateStackFn: func(_ string, prNumbers []int) error {
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
updatedPRs = prNumbers
return nil
return &github.RemoteStack{ID: 9, Number: stackNumber, PullRequests: []int{100, 101}}, nil
},
}
@@ -698,7 +698,7 @@ func TestLink_AllowsAutoMergePRAlreadyInStack(t *testing.T) {
output := string(errOut)
require.NoError(t, err)
assert.Equal(t, []int{100, 101}, updatedPRs)
assert.Equal(t, []int{101}, updatedPRs)
assert.NotContains(t, output, "cannot be added to a stack")
}
@@ -724,12 +724,12 @@ func TestLink_RejectsQueuedPRNotInStack_WhenAddingToExistingStack(t *testing.T)
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{100}},
{ID: 7, Number: 7, PullRequests: []int{100}},
}, nil
},
UpdateStackFn: func(string, []int) error {
t.Fatal("UpdateStack should not be called when a new PR is ineligible")
return nil
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack should not be called when a new PR is ineligible")
return nil, nil
},
}
@@ -776,9 +776,9 @@ func TestLink_BranchNames_AllHavePRs(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
stackedPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -840,9 +840,9 @@ func TestLink_BranchNames_CreatesMissingPRs(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
stackedPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -900,8 +900,8 @@ func TestLink_BranchNames_AllNeedPRs(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -960,7 +960,7 @@ func TestLink_BranchNames_DefaultDraft(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1004,7 +1004,7 @@ func TestLink_BranchNames_OpenFlag(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1062,7 +1062,7 @@ func TestLink_OpenFlag_ConvertsDraftPRs(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1109,9 +1109,9 @@ func TestLink_MixedArgs_PRNumberAndBranch(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
stackedPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1161,9 +1161,9 @@ func TestLink_NumericArg_PRNotFound_TreatedAsBranch(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
stackedPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1227,7 +1227,7 @@ func TestLink_FixesBaseBranches(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil },
}
cmd := LinkCmd(cfg)
@@ -1282,15 +1282,15 @@ func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{10}},
{ID: 7, Number: 7, PullRequests: []int{10}},
}, nil
},
UpdateStackFn: func(string, []int) error {
return &api.HTTPError{StatusCode: 404, Message: "Not Found"}
AddToStackFn: func(stackNumber int, _ []int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
created = true
return 99, nil
return &github.RemoteStack{ID: 99, Number: 99}, nil
},
}
@@ -1338,7 +1338,7 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) {
return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil },
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1383,7 +1383,7 @@ func TestLink_RemoteFlag(t *testing.T) {
return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil },
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1414,7 +1414,7 @@ func TestLink_SkipsPushForPRNumbersOnly(t *testing.T) {
return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil },
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1453,7 +1453,7 @@ func TestLink_PrevalidatesBeforeCreatingPRs(t *testing.T) {
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 7, PullRequests: []int{104, 105, 106}},
{ID: 7, Number: 7, PullRequests: []int{104, 105, 106}},
}, nil
},
}
@@ -1636,7 +1636,7 @@ func TestLink_SkipsBaseFix_ForNewlyCreatedPRs(t *testing.T) {
}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil },
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := LinkCmd(cfg)
@@ -1687,7 +1687,7 @@ func TestLink_BranchNames_UsesPRTemplate(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil },
}
cmd := LinkCmd(cfg)
@@ -1742,7 +1742,7 @@ func TestLink_IgnoresSymlinkedPRTemplate(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil },
}
cmd := LinkCmd(cfg)
@@ -1795,7 +1795,7 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func([]int) (int, error) { return 42, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil },
}
cmd := LinkCmd(cfg)
@@ -1828,9 +1828,9 @@ func TestLink_PRURLs_CreateNewStack(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createdPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1893,9 +1893,9 @@ func TestLink_MixedURLsAndNumbers(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createdPRs = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
+73 -18
View File
@@ -113,16 +113,11 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
return ErrAPIFailure
}
// Pre-flight: abort early if the user is authenticating with a PAT.
if cfg.WarnIfPAT() {
return ErrStacksUnavailable
}
// Verify that the repository has stacked PRs enabled.
stacksAvailable := s.ID != ""
if !stacksAvailable {
if _, err := client.ListStacks(); err != nil {
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
if cfg.IsInteractive() {
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false)
@@ -609,7 +604,7 @@ func remoteStackPRs(client github.ClientOps, stackID string) []int {
}
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == stackID {
return rs.PullRequests
return rs.PRNumbers()
}
}
return nil
@@ -661,7 +656,15 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S
// Delete the old remote stack
if state.PriorRemoteStackID != "" {
if err := client.DeleteStack(state.PriorRemoteStackID); err != nil {
number, found, lookupErr := stackNumberByID(client, state.PriorRemoteStackID)
if lookupErr != nil {
cfg.Warningf("Failed to look up existing stack: %v", lookupErr)
cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit"))
return lookupErr
}
if !found {
cfg.Printf("Previous stack already deleted on GitHub")
} else if _, _, err := client.Unstack(number); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
cfg.Printf("Previous stack already deleted on GitHub")
@@ -675,6 +678,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S
}
// Clear the old stack ID so syncStack creates a new one
s.ID = ""
s.Number = 0
}
return nil
@@ -761,7 +765,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta
// A remote stack already contains some of our PRs. Refuse to silently drop
// any PRs it holds that we aren't tracking locally; let the user reconcile.
if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 {
if dropped := prsMissingFrom(matched.PRNumbers(), prNumbers); len(dropped) > 0 {
cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack",
formatPRList(dropped), plural(len(dropped), "is", "are"))
cfg.Printf(" Run `%s` to import the full stack",
@@ -773,8 +777,9 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta
// more on top). Adopt the remote stack ID — recording it locally — and
// update the stack with our full, ordered PR list to append any new PRs.
s.ID = strconv.Itoa(matched.ID)
s.Number = matched.Number
if slicesEqual(matched.PullRequests, prNumbers) {
if slicesEqual(matched.PRNumbers(), prNumbers) {
cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers))
return true
}
@@ -799,12 +804,57 @@ func prsMissingFrom(remote, local []int) []int {
return missing
}
// updateStack calls the PUT endpoint to sync the full PR list for an existing stack.
// If the remote stack was deleted (404), it clears the local ID and falls through
// to createNewStack so the user doesn't need to re-run the command.
// Returns true when the remote stack was updated (or recreated) successfully.
// updateStack brings the remote stack in line with the local PR list by
// appending any new PRs via the add endpoint. It reads the current remote stack
// to compute the delta; when the desired list isn't a clean append onto the
// remote stack (a reorder or a removal, e.g. merged PRs leaving the stack) it
// leaves the remote stack untouched. If the remote stack is gone (404) it
// clears the local ID and re-creates it. Returns true when the remote stack
// reflects the local stack (updated, already in sync, or recreated).
func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
if err := client.UpdateStack(s.ID, prNumbers); err != nil {
number, err := ensureStackNumber(client, s)
if err != nil || number == 0 {
// Can't resolve the remote stack — treat as missing and (re)create.
s.ID = ""
s.Number = 0
return createNewStack(cfg, client, s, prNumbers)
}
remote, err := client.GetStack(number)
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
s.ID = ""
s.Number = 0
return createNewStack(cfg, client, s, prNumbers)
}
cfg.Warningf("Failed to read stack on GitHub: %v", err)
return false
}
current := remote.PRNumbers()
if slicesEqual(current, prNumbers) {
s.ID = strconv.Itoa(remote.ID)
s.Number = remote.Number
cfg.Successf("Stack on GitHub is up to date with %d PRs", len(prNumbers))
return true
}
delta, isAppend := appendDelta(current, prNumbers)
if !isAppend || len(delta) == 0 {
// The desired list isn't a clean append onto the remote stack — the add
// endpoint can't express a reorder or removal. This is expected once
// part of the stack has landed and merged PRs have left the stack.
if len(s.MergedBranches()) > 0 {
cfg.Infof("Merged PRs have left the stack on GitHub, so it wasn't updated — your unmerged PRs were pushed and re-based onto the trunk")
} else {
cfg.Warningf("The stack on GitHub differs from your local stack and couldn't be updated automatically")
}
return false
}
rs, err := client.AddToStack(number, delta)
if err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
@@ -812,6 +862,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
// Stack was deleted on GitHub — clear the stale ID and
// immediately try to re-create it.
s.ID = ""
s.Number = 0
return createNewStack(cfg, client, s, prNumbers)
case 422:
// A merged branch whose ref has been deleted upstream breaks the
@@ -832,6 +883,9 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
}
return false
}
s.ID = strconv.Itoa(rs.ID)
s.Number = rs.Number
cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers))
return true
}
@@ -840,9 +894,10 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
// three types of 422 errors the API may return.
// Returns true when the stack was created or is confirmed already in sync.
func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool {
stackID, err := client.CreateStack(prNumbers)
rs, err := client.CreateStack(prNumbers)
if err == nil {
s.ID = strconv.Itoa(stackID)
s.ID = strconv.Itoa(rs.ID)
s.Number = rs.Number
cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers))
return true
}
@@ -857,7 +912,7 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack,
case 422:
return handleCreate422(cfg, httpErr, prNumbers)
case 404:
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
return false
default:
cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message)
+217 -249
View File
@@ -134,8 +134,8 @@ func TestSubmit_CreatesPRsAndStack(t *testing.T) {
URL: fmt.Sprintf("https://github.com/owner/repo/pull/%d", prCounter),
}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -194,7 +194,7 @@ func TestSubmit_DefaultDraft(t *testing.T) {
createdDraft = draft
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -236,7 +236,7 @@ func TestSubmit_OpenFlag(t *testing.T) {
createdDraft = draft
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -293,7 +293,7 @@ func TestSubmit_OpenFlag_ConvertsDraftPRs(t *testing.T) {
markedReady = append(markedReady, prID)
return nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -412,8 +412,9 @@ func TestSubmit_ForksWhenRemoteStackFullyMerged(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
ID: "42",
Number: 42,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}},
@@ -469,9 +470,9 @@ func TestSubmit_ForksWhenRemoteStackFullyMerged(t *testing.T) {
HeadRefName: head,
}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createStackPRs = prNumbers
return 99, nil
return &github.RemoteStack{ID: 99, Number: 99}, nil
},
}
@@ -562,7 +563,7 @@ func TestSubmit_NoForkWhenRemoteStackHasOpenPR(t *testing.T) {
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 42, PullRequests: []int{1, 2, 3}}}, nil
return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{1, 2, 3}}}, nil
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
switch n {
@@ -585,12 +586,15 @@ func TestSubmit_NoForkWhenRemoteStackHasOpenPR(t *testing.T) {
HeadRefName: head,
}, nil
},
UpdateStackFn: func(string, []int) error {
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1, 2, 3}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
// Merged-and-deleted base branches break the chain on GitHub.
return &api.HTTPError{
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/42"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks/42/add"},
}
},
}
@@ -627,21 +631,27 @@ func TestUpdateStack_BrokenChainAfterMerge(t *testing.T) {
return &api.HTTPError{
StatusCode: 422,
Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/42"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks/42/add"},
}
}
t.Run("merged branches present is reported calmly", func(t *testing.T) {
s := &stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
ID: "42",
Number: 42,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
},
}
mock := &github.MockClient{UpdateStackFn: func(string, []int) error { return mustFormErr() }}
mock := &github.MockClient{
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1, 2}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) { return nil, mustFormErr() },
}
cfg, _, errR := config.NewTestConfig()
updateStack(cfg, mock, s, []int{1, 2, 3})
cfg.Err.Close()
@@ -653,14 +663,20 @@ func TestUpdateStack_BrokenChainAfterMerge(t *testing.T) {
t.Run("no merged branches still warns", func(t *testing.T) {
s := &stack.Stack{
ID: "42",
Trunk: stack.BranchRef{Branch: "main"},
ID: "42",
Number: 42,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
},
}
mock := &github.MockClient{UpdateStackFn: func(string, []int) error { return mustFormErr() }}
mock := &github.MockClient{
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) { return nil, mustFormErr() },
}
cfg, _, errR := config.NewTestConfig()
updateStack(cfg, mock, s, []int{1, 2})
cfg.Err.Close()
@@ -731,9 +747,9 @@ func TestSyncStack_NewStack_CreateSuccess(t *testing.T) {
var gotNumbers []int
mock := &github.MockClient{
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
gotNumbers = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -751,8 +767,9 @@ func TestSyncStack_NewStack_CreateSuccess(t *testing.T) {
func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) {
s := &stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -760,18 +777,21 @@ func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) {
},
}
var gotStackID string
var gotStackNumber int
var gotNumbers []int
createCalled := false
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
gotStackID = stackID
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: stackNumber, Number: stackNumber, PullRequests: []int{10, 11}}, nil
},
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
gotStackNumber = stackNumber
gotNumbers = prNumbers
return nil
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11, 12}}, nil
},
}
@@ -783,15 +803,16 @@ func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) {
output := string(errOut)
assert.False(t, createCalled, "CreateStack should not be called when s.ID is set")
assert.Equal(t, "99", gotStackID)
assert.Equal(t, []int{10, 11, 12}, gotNumbers)
assert.Equal(t, 99, gotStackNumber)
assert.Equal(t, []int{12}, gotNumbers)
assert.Contains(t, output, "Stack updated on GitHub with 3 PRs")
}
func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) {
s := &stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -799,11 +820,14 @@ func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) {
}
mock := &github.MockClient{
UpdateStackFn: func(string, []int) error {
return &api.HTTPError{
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Validation failed",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/99"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks/99/add"},
}
},
}
@@ -820,8 +844,9 @@ func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) {
func TestSyncStack_ExistingStack_Update404(t *testing.T) {
s := &stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -830,16 +855,19 @@ func TestSyncStack_ExistingStack_Update404(t *testing.T) {
var createCalled bool
mock := &github.MockClient{
UpdateStackFn: func(string, []int) error {
return &api.HTTPError{
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 404,
Message: "Not Found",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/99"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks/99/add"},
}
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createCalled = true
return 55, nil
return &github.RemoteStack{ID: 55, Number: 55}, nil
},
}
@@ -866,11 +894,11 @@ func TestSyncStack_AlreadyStacked_OurStack(t *testing.T) {
}
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests #10, #11 are already stacked",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
}
},
}
@@ -898,11 +926,11 @@ func TestSyncStack_AlreadyStacked_DifferentStack(t *testing.T) {
}
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests #10, #11 are already stacked",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
}
},
}
@@ -933,15 +961,15 @@ func TestSyncStack_AdoptsExistingRemoteStack_ExactMatch(t *testing.T) {
var createCalled, updateCalled bool
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11}}}, nil
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
UpdateStackFn: func(string, []int) error {
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
updateCalled = true
return nil
return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11}}, nil
},
}
@@ -973,20 +1001,23 @@ func TestSyncStack_AdoptsExistingRemoteStack_AddsNewPR(t *testing.T) {
}
var createCalled bool
var gotStackID string
var gotStackNumber int
var gotNumbers []int
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11}}}, nil
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
gotStackID = stackID
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 77, Number: stackNumber, PullRequests: []int{10, 11}}, nil
},
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
gotStackNumber = stackNumber
gotNumbers = prNumbers
return nil
return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil
},
}
@@ -999,8 +1030,8 @@ func TestSyncStack_AdoptsExistingRemoteStack_AddsNewPR(t *testing.T) {
assert.False(t, createCalled, "should adopt and update, not create")
assert.Equal(t, "77", s.ID, "should adopt the remote stack ID")
assert.Equal(t, "77", gotStackID, "should update the adopted stack")
assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should send the full local PR list")
assert.Equal(t, 77, gotStackNumber, "should update the adopted stack")
assert.Equal(t, []int{12}, gotNumbers, "should send only the new PR delta")
assert.Contains(t, output, "Stack updated on GitHub with 3 PRs")
}
@@ -1018,15 +1049,15 @@ func TestSyncStack_RemoteStackHasExtraPRs_Refuses(t *testing.T) {
var createCalled, updateCalled bool
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11, 12}}}, nil
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
UpdateStackFn: func(string, []int) error {
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
updateCalled = true
return nil
return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil
},
}
@@ -1058,17 +1089,17 @@ func TestSyncStack_PRsSpanMultipleRemoteStacks_Warns(t *testing.T) {
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 1, PullRequests: []int{10}},
{ID: 2, PullRequests: []int{11}},
{ID: 1, Number: 1, PullRequests: []int{10}},
{ID: 2, Number: 2, PullRequests: []int{11}},
}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{ID: 0, Number: 0}, nil
},
UpdateStackFn: func(string, []int) error {
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
updateCalled = true
return nil
return &github.RemoteStack{ID: 1, Number: 1}, nil
},
}
@@ -1101,9 +1132,9 @@ func TestSyncStack_ListStacksError_FallsThroughToCreate(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, fmt.Errorf("network down")
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 88, nil
return &github.RemoteStack{ID: 88, Number: 88}, nil
},
}
@@ -1134,11 +1165,11 @@ func TestSyncStack_AlreadyPartOfAStack_FallbackPhrasing(t *testing.T) {
mock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests are already part of a stack",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
}
},
}
@@ -1165,11 +1196,11 @@ func TestSyncStack_InvalidChain_422(t *testing.T) {
}
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 422,
Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
}
},
}
@@ -1195,11 +1226,11 @@ func TestSyncStack_NotAvailable(t *testing.T) {
}
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{
StatusCode: 404,
Message: "Not Found",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
}
},
}
@@ -1225,13 +1256,13 @@ func TestSyncStack_SkippedForSinglePR(t *testing.T) {
createCalled := false
updateCalled := false
mock := &github.MockClient{
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
UpdateStackFn: func(string, []int) error {
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
updateCalled = true
return nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1255,9 +1286,9 @@ func TestSyncStack_IncludesMergedBranches(t *testing.T) {
var gotNumbers []int
mock := &github.MockClient{
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
gotNumbers = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1280,9 +1311,9 @@ func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) {
var gotNumbers []int
mock := &github.MockClient{
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
gotNumbers = prNumbers
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1343,8 +1374,8 @@ func TestSubmit_UpdatesBaseBranch(t *testing.T) {
}{number, base})
return nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1370,8 +1401,9 @@ func TestSubmit_UpdatesBaseBranch(t *testing.T) {
func TestSubmit_SkipsBaseUpdateWhenStacked(t *testing.T) {
// Stack already exists (s.ID is set), so base updates should be skipped.
s := stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -1410,8 +1442,11 @@ func TestSubmit_SkipsBaseUpdateWhenStacked(t *testing.T) {
updateCalled = true
return nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
return nil
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil
},
}
@@ -1494,8 +1529,8 @@ func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) {
}{number, base})
return nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1555,9 +1590,7 @@ func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) {
},
}
// Use an OAuth token so the PAT pre-flight check passes and we
// exercise the ListStacks 404 path.
setTestTokenForHost(cfg, "gho_test_oauth_token")
setTestRepo(cfg)
cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
@@ -1610,9 +1643,7 @@ func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T)
},
}
// Use an OAuth token so the PAT pre-flight check passes and we
// exercise the ListStacks 404 path.
setTestTokenForHost(cfg, "gho_test_oauth_token")
setTestRepo(cfg)
cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
@@ -1642,9 +1673,9 @@ func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) {
createCalled := false
mock := &github.MockClient{
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createCalled = true
return 42, nil
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -1699,7 +1730,7 @@ func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) {
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 99, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 99, Number: 99}, nil },
}
cmd := SubmitCmd(cfg)
@@ -1717,8 +1748,9 @@ func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) {
func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
s := stack.Stack{
ID: "42", // Existing stack — pre-flight check should be skipped.
Trunk: stack.BranchRef{Branch: "main"},
ID: "42", // Existing stack — pre-flight check should be skipped.
Number: 42,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -1738,7 +1770,7 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
listStacksCallCount++
return []github.RemoteStack{{ID: 42, PullRequests: []int{10, 11}}}, nil
return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{10, 11}}}, nil
},
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
switch number {
@@ -1752,7 +1784,12 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil
},
UpdateStackFn: func(string, []int) error { return nil },
GetStackFn: func(int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10}}, nil
},
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11}}, nil
},
}
cmd := SubmitCmd(cfg)
@@ -1790,15 +1827,18 @@ func newPendingSubmitState(priorStackID string) *modify.StateFile {
func TestHandlePendingModify_DeletesOldStack(t *testing.T) {
gitDir := t.TempDir()
saveModifyState(t, gitDir, newPendingSubmitState("stack-123"))
saveModifyState(t, gitDir, newPendingSubmitState("123"))
s := &stack.Stack{ID: "stack-123", Trunk: stack.BranchRef{Branch: "main"}}
s := &stack.Stack{ID: "123", Number: 42, Trunk: stack.BranchRef{Branch: "main"}}
var deletedStackID string
var unstackedNumber int
client := &github.MockClient{
DeleteStackFn: func(id string) error {
deletedStackID = id
return nil
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 123, Number: 42}}, nil
},
UnstackFn: func(number int) (*github.RemoteStack, bool, error) {
unstackedNumber = number
return nil, true, nil
},
}
@@ -1808,7 +1848,7 @@ func TestHandlePendingModify_DeletesOldStack(t *testing.T) {
err := handlePendingModify(cfg, client, s, gitDir)
require.NoError(t, err)
assert.Equal(t, "stack-123", deletedStackID)
assert.Equal(t, 42, unstackedNumber)
assert.Equal(t, "", s.ID)
}
@@ -1820,9 +1860,9 @@ func TestHandlePendingModify_NoStateFile(t *testing.T) {
deleteCalled := false
client := &github.MockClient{
DeleteStackFn: func(id string) error {
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
deleteCalled = true
return nil
return nil, true, nil
},
}
@@ -1832,7 +1872,7 @@ func TestHandlePendingModify_NoStateFile(t *testing.T) {
err := handlePendingModify(cfg, client, s, gitDir)
assert.NoError(t, err)
assert.False(t, deleteCalled, "DeleteStack should not be called when no state file exists")
assert.False(t, deleteCalled, "Unstack should not be called when no state file exists")
assert.Equal(t, "stack-123", s.ID, "stack ID should remain unchanged")
}
@@ -1850,9 +1890,9 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) {
deleteCalled := false
client := &github.MockClient{
DeleteStackFn: func(id string) error {
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
deleteCalled = true
return nil
return nil, true, nil
},
}
@@ -1862,20 +1902,23 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) {
err := handlePendingModify(cfg, client, s, gitDir)
assert.NoError(t, err)
assert.False(t, deleteCalled, "DeleteStack should not be called for non-pending_submit phase")
assert.False(t, deleteCalled, "Unstack should not be called for non-pending_submit phase")
assert.Equal(t, "stack-99", s.ID, "stack ID should remain unchanged")
}
func TestHandlePendingModify_DeleteFails(t *testing.T) {
gitDir := t.TempDir()
saveModifyState(t, gitDir, newPendingSubmitState("stack-456"))
saveModifyState(t, gitDir, newPendingSubmitState("456"))
s := &stack.Stack{ID: "stack-456", Trunk: stack.BranchRef{Branch: "main"}}
s := &stack.Stack{ID: "456", Number: 43, Trunk: stack.BranchRef{Branch: "main"}}
client := &github.MockClient{
DeleteStackFn: func(id string) error {
return fmt.Errorf("server error")
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 456, Number: 43}}, nil
},
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
return nil, false, fmt.Errorf("server error")
},
}
@@ -1885,22 +1928,25 @@ func TestHandlePendingModify_DeleteFails(t *testing.T) {
err := handlePendingModify(cfg, client, s, gitDir)
assert.Error(t, err)
assert.Equal(t, "stack-456", s.ID, "stack ID should NOT be cleared on delete failure")
assert.Equal(t, "456", s.ID, "stack ID should NOT be cleared on delete failure")
}
func TestHandlePendingModify_Delete404(t *testing.T) {
gitDir := t.TempDir()
saveModifyState(t, gitDir, newPendingSubmitState("stack-gone"))
saveModifyState(t, gitDir, newPendingSubmitState("404"))
s := &stack.Stack{ID: "stack-gone", Trunk: stack.BranchRef{Branch: "main"}}
s := &stack.Stack{ID: "404", Number: 44, Trunk: stack.BranchRef{Branch: "main"}}
client := &github.MockClient{
DeleteStackFn: func(id string) error {
return &api.HTTPError{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 404, Number: 44}}, nil
},
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
return nil, false, &api.HTTPError{
StatusCode: 404,
Message: "Not Found",
RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/stack-gone"},
RequestURL: &url.URL{Path: "/repos/o/r/stacks/44"},
}
},
}
@@ -1943,8 +1989,9 @@ func TestClearPendingModifyState_NoFile(t *testing.T) {
func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
s := stack.Stack{
ID: "old-stack-42",
Trunk: stack.BranchRef{Branch: "main"},
ID: "42",
Number: 7,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
@@ -1954,7 +2001,7 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
saveModifyState(t, tmpDir, newPendingSubmitState("old-stack-42"))
saveModifyState(t, tmpDir, newPendingSubmitState("42"))
// Track call ordering
var callOrder []string
@@ -1970,15 +2017,17 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
restore := git.SetOps(mock)
defer restore()
var deletedStackID string
var unstackedNumber int
var createdStackPRs []int
unstacked := false
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
DeleteStackFn: func(id string) error {
deletedStackID = id
callOrder = append(callOrder, "delete:"+id)
return nil
UnstackFn: func(number int) (*github.RemoteStack, bool, error) {
unstackedNumber = number
unstacked = true
callOrder = append(callOrder, fmt.Sprintf("unstack:%d", number))
return nil, true, nil
},
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
switch branch {
@@ -2006,13 +2055,17 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
}
return nil, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createdStackPRs = prNumbers
callOrder = append(callOrder, "create_stack")
return 99, nil
return &github.RemoteStack{ID: 99, Number: 99}, nil
},
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
// The old stack exists until it is unstacked, then it is gone.
if unstacked {
return []github.RemoteStack{}, nil
}
return []github.RemoteStack{{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}}, nil
},
}
@@ -2027,8 +2080,8 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
assert.NoError(t, err)
// DeleteStack called with old stack ID
assert.Equal(t, "old-stack-42", deletedStackID)
// Unstack called with old stack number
assert.Equal(t, 7, unstackedNumber)
// Push called per-branch (3 separate calls, not 1 atomic call)
require.Len(t, pushCalls, 3, "should push each branch individually")
@@ -2042,13 +2095,13 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
// CreateStack called with all 3 PRs
assert.Equal(t, []int{10, 11, 12}, createdStackPRs)
// Verify ordering: delete before push, push before create_stack
// Verify ordering: unstack before push, push before create_stack
assert.True(t, len(callOrder) >= 5, "expected at least 5 calls, got %d: %v", len(callOrder), callOrder)
deleteIdx := -1
firstPushIdx := -1
createIdx := -1
for i, c := range callOrder {
if c == "delete:old-stack-42" && deleteIdx == -1 {
if c == "unstack:7" && deleteIdx == -1 {
deleteIdx = i
}
if c == "push:b1" && firstPushIdx == -1 {
@@ -2109,8 +2162,8 @@ func TestSubmit_FetchesBeforePush(t *testing.T) {
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{}, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -2168,7 +2221,7 @@ func TestSubmit_UsesPRTemplate(t *testing.T) {
capturedBody = body
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -2228,7 +2281,7 @@ func TestSubmit_IgnoresSymlinkedPRTemplate(t *testing.T) {
capturedBody = body
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -2274,7 +2327,7 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) {
capturedBody = body
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
},
CreateStackFn: func([]int) (int, error) { return 1, nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
}
cmd := SubmitCmd(cfg)
@@ -2288,91 +2341,6 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) {
assert.Contains(t, capturedBody, feedbackURL)
}
func TestSubmit_PreflightCheck_PAT_BailsOut(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
pushed := false
mock := newSubmitMock(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error {
pushed = true
return nil
}
restore := git.SetOps(mock)
defer restore()
listStacksCalled := false
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
listStacksCalled = true
return nil, nil
},
}
// Simulate a classic PAT — the pre-flight check should abort.
setTestTokenForHost(cfg, "ghp_classic_pat_token")
cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.ErrorIs(t, err, ErrStacksUnavailable)
assert.Contains(t, output, "Personal access tokens are not supported by gh stack")
assert.Contains(t, output, "gh auth login")
assert.False(t, pushed, "should not push when using a PAT")
assert.False(t, listStacksCalled, "should not call ListStacks when PAT detected")
}
func TestSubmit_PreflightCheck_FinegrainedPAT_BailsOut(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1"},
{Branch: "b2"},
},
}
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
mock := newSubmitMock(tmpDir, "b1")
restore := git.SetOps(mock)
defer restore()
cfg, _, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{}
setTestTokenForHost(cfg, "github_pat_11AABBCC_xxxx")
cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.ErrorIs(t, err, ErrStacksUnavailable)
assert.Contains(t, output, "Personal access tokens are not supported by gh stack")
}
func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
@@ -2418,8 +2386,8 @@ func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) {
disabledAutoMergePRIDs = append(disabledAutoMergePRIDs, prID)
return nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -2470,8 +2438,8 @@ func TestSubmit_DisableAutoMergeFailure_ContinuesWithWarning(t *testing.T) {
DisableAutoMergeFn: func(prID string) error {
return fmt.Errorf("permission denied")
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
@@ -2522,8 +2490,8 @@ func TestSubmit_NoAutoMerge_SkipsDisable(t *testing.T) {
t.Fatal("DisableAutoMerge should not be called when auto-merge is not enabled")
return nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
return 42, nil
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 42, Number: 42}, nil
},
}
+99 -48
View File
@@ -1757,13 +1757,13 @@ func TestSync_CreatesRemoteStackWhenPRsExist(t *testing.T) {
listCalls++
return nil, nil
},
CreateStackFn: func(prNumbers []int) (int, error) {
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
createdWith = prNumbers
return 7, nil
return &github.RemoteStack{ID: 7, Number: 7}, nil
},
UpdateStackFn: func(string, []int) error {
t.Fatal("UpdateStack should not be called when no remote stack exists")
return nil
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack should not be called when no remote stack exists")
return nil, nil
},
}
@@ -1796,15 +1796,15 @@ func TestSync_AdoptsExistingEqualRemoteStack(t *testing.T) {
ghMock := &github.MockClient{
FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}),
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack should not be called when the remote stack matches")
return 0, nil
return nil, nil
},
UpdateStackFn: func(string, []int) error {
t.Fatal("UpdateStack should not be called when the remote stack matches")
return nil
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack should not be called when the remote stack matches")
return nil, nil
},
}
@@ -1829,28 +1829,32 @@ func TestSync_UpdatesPartialRemoteStack(t *testing.T) {
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)
var updatedID string
var updatedWith []int
var updatedNumber int
var addedWith []int
ghMock := &github.MockClient{
FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102, "b3": 103}),
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack should not be called when a matching stack exists")
return 0, nil
return nil, nil
},
UpdateStackFn: func(stackID string, prNumbers []int) error {
updatedID = stackID
updatedWith = prNumbers
return nil
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
assert.Equal(t, 9, stackNumber)
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102}}, nil
},
AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) {
updatedNumber = stackNumber
addedWith = prNumbers
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil
},
}
output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock)
assert.Equal(t, "9", updatedID)
assert.Equal(t, []int{101, 102, 103}, updatedWith)
assert.Equal(t, 9, updatedNumber)
assert.Equal(t, []int{103}, addedWith)
assert.Contains(t, output, "Stack updated on GitHub with 3 PRs")
assert.Contains(t, output, "Stack synced")
assert.NotContains(t, output, "Branches synced")
@@ -1878,9 +1882,9 @@ func TestSync_FewerThanTwoPRs_BranchesSynced(t *testing.T) {
listCalled = true
return nil, nil
},
CreateStackFn: func([]int) (int, error) {
CreateStackFn: func([]int) (*github.RemoteStack, error) {
createCalled = true
return 0, nil
return &github.RemoteStack{}, nil
},
}
@@ -1905,8 +1909,8 @@ func TestSync_StacksUnavailable_BranchesSynced(t *testing.T) {
ghMock := &github.MockClient{
FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}),
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
CreateStackFn: func([]int) (int, error) {
return 0, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
CreateStackFn: func([]int) (*github.RemoteStack, error) {
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}
@@ -1932,18 +1936,18 @@ func TestSync_PRsSpanMultipleStacks_BranchesSynced(t *testing.T) {
FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}),
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{
{ID: 9, PullRequests: []int{101}},
{ID: 10, PullRequests: []int{102}},
{ID: 9, Number: 9, PullRequests: []int{101}},
{ID: 10, Number: 10, PullRequests: []int{102}},
}, nil
},
CreateStackFn: func([]int) (int, error) { createCalled = true; return 0, nil },
UpdateStackFn: func(string, []int) error { updateCalled = true; return nil },
CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true; return nil, nil },
AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true; return nil, nil },
}
output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock)
assert.False(t, createCalled, "CreateStack should not be called on divergence")
assert.False(t, updateCalled, "UpdateStack should not be called on divergence")
assert.False(t, updateCalled, "AddToStack should not be called on divergence")
assert.Contains(t, output, "multiple stacks")
assert.NotContains(t, output, "submitting", "divergence guidance should be command-neutral, not submit-specific")
assert.Contains(t, output, "Branches synced")
@@ -2041,7 +2045,11 @@ func TestSync_RemoteAhead_PullsNewBranches(t *testing.T) {
ghMock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103, 104, 105}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103, 104, 105}}}, nil
},
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
assert.Equal(t, 9, stackNumber)
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103, 104, 105}}, nil
},
FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3", 104: "b4", 105: "b5"}),
}
@@ -2089,7 +2097,11 @@ func TestSync_RemoteAhead_QueuedBranchNotPushed(t *testing.T) {
ghMock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil
},
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
assert.Equal(t, 9, stackNumber)
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
branch := map[int]string{101: "b1", 102: "b2", 103: "b3"}[n]
@@ -2143,7 +2155,7 @@ func TestSync_RemoteAhead_DuplicateBranchAborts(t *testing.T) {
ghMock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil
},
FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3"}),
}
@@ -2207,7 +2219,11 @@ func TestSync_RemoteInSync_NoPull(t *testing.T) {
ghMock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil
},
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
assert.Equal(t, 9, stackNumber)
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102}}, nil
},
FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2"}),
}
@@ -2240,7 +2256,10 @@ func divergentStack(t *testing.T, tmpDir string) {
func divergentRemoteMock() *github.MockClient {
return &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 104}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 104}}}, nil
},
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 104}}, nil
},
FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3", 104: "b4"}),
}
@@ -2260,9 +2279,18 @@ func TestSync_Divergent_NonInteractive_Aborts(t *testing.T) {
mock := newSyncMockNoRebase(tmpDir, "b1")
mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil }
mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil }
ghMock.CreateStackFn = func([]int) (int, error) { t.Fatal("CreateStack must not be called"); return 0, nil }
ghMock.UpdateStackFn = func(string, []int) error { t.Fatal("UpdateStack must not be called"); return nil }
ghMock.DeleteStackFn = func(string) error { t.Fatal("DeleteStack must not be called"); return nil }
ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack must not be called")
return nil, nil
}
ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack must not be called")
return nil, nil
}
ghMock.UnstackFn = func(int) (*github.RemoteStack, bool, error) {
t.Fatal("Unstack must not be called")
return nil, false, nil
}
output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock })
require.NoError(t, err)
@@ -2407,12 +2435,22 @@ func TestSync_Divergent_DeleteRemote(t *testing.T) {
divergentStack(t, tmpDir)
deleted := false
var deletedID string
var deletedNumber int
var pushed bool
ghMock := divergentRemoteMock()
ghMock.DeleteStackFn = func(id string) error { deleted = true; deletedID = id; return nil }
ghMock.CreateStackFn = func([]int) (int, error) { t.Fatal("CreateStack must not be called"); return 0, nil }
ghMock.UpdateStackFn = func(string, []int) error { t.Fatal("UpdateStack must not be called"); return nil }
ghMock.UnstackFn = func(number int) (*github.RemoteStack, bool, error) {
deleted = true
deletedNumber = number
return nil, true, nil
}
ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack must not be called")
return nil, nil
}
ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack must not be called")
return nil, nil
}
mock := newSyncMockNoRebase(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil }
@@ -2424,7 +2462,7 @@ func TestSync_Divergent_DeleteRemote(t *testing.T) {
require.NoError(t, err)
assert.True(t, deleted, "remote stack should be deleted")
assert.Equal(t, "9", deletedID)
assert.Equal(t, 9, deletedNumber)
assert.False(t, pushed, "sync should stop after deleting the remote stack")
assert.Contains(t, output, "Deleted the stack on GitHub")
assert.Contains(t, output, "gh stack submit")
@@ -2443,9 +2481,18 @@ func TestSync_Divergent_Cancel(t *testing.T) {
divergentStack(t, tmpDir)
ghMock := divergentRemoteMock()
ghMock.DeleteStackFn = func(string) error { t.Fatal("DeleteStack must not be called"); return nil }
ghMock.CreateStackFn = func([]int) (int, error) { t.Fatal("CreateStack must not be called"); return 0, nil }
ghMock.UpdateStackFn = func(string, []int) error { t.Fatal("UpdateStack must not be called"); return nil }
ghMock.UnstackFn = func(int) (*github.RemoteStack, bool, error) {
t.Fatal("Unstack must not be called")
return nil, false, nil
}
ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) {
t.Fatal("CreateStack must not be called")
return nil, nil
}
ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) {
t.Fatal("AddToStack must not be called")
return nil, nil
}
var pushed bool
mock := newSyncMockNoRebase(tmpDir, "b1")
mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil }
@@ -2490,7 +2537,11 @@ func TestSync_MergedBranchPruned_NoFalseDivergence(t *testing.T) {
ghMock := &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102, 103}}}, nil
return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil
},
GetStackFn: func(stackNumber int) (*github.RemoteStack, error) {
assert.Equal(t, 9, stackNumber)
return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil
},
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
branch := map[int]string{101: "b1", 102: "b2", 103: "b3"}[n]
+26 -71
View File
@@ -2,11 +2,9 @@ package cmd
import (
"errors"
"fmt"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/github/gh-stack/internal/config"
"github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/modify"
"github.com/github/gh-stack/internal/stack"
"github.com/spf13/cobra"
@@ -55,11 +53,13 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
sf := result.StackFile
s := result.Stack
// Delete the stack on GitHub first (unless --local).
// Only proceed with local deletion after the remote operation succeeds.
// Unstack on GitHub first (unless --local). The server decides which PRs
// can be unstacked; PRs that are queued for merge or have auto-merge enabled
// are left in place and the stack is kept. Local tracking is only removed
// when the remote stack is fully dissolved.
if !opts.local {
if s.ID == "" {
cfg.Warningf("Stack has no remote ID — skipping server-side deletion")
if s.ID == "" && s.Number == 0 {
cfg.Warningf("Stack has no remote ID — skipping server-side unstack")
} else {
client, err := cfg.GitHubClient()
if err != nil {
@@ -67,36 +67,43 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
return ErrAPIFailure
}
blocked, err := shouldBlockUnstackDelete(client, s)
number, err := ensureStackNumber(client, s)
if err != nil {
cfg.Errorf("failed to check pull request states before unstack: %s", err)
cfg.Errorf("failed to look up stack on GitHub: %s", err)
return ErrAPIFailure
}
if blocked {
cfg.Errorf("Unstacking not allowed. Pull requests that are queued for merge, are merging, or are already merged will remain in the stack.")
return ErrInvalidArgs
}
if err := client.DeleteStack(s.ID); err != nil {
if number == 0 {
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
} else if _, dissolved, err := client.Unstack(number); err != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case 404:
// Stack already deleted on GitHub — treat as success.
// Stack already gone on GitHub — treat as success.
cfg.Warningf("Stack not found on GitHub — continuing with local unstack")
case 422:
cfg.Errorf("Cannot delete stack on GitHub: %s", httpErr.Message)
return ErrAPIFailure
// The server refused: every PR is queued for merge or has
// auto-merge enabled, so nothing can be unstacked.
cfg.Errorf("Unstacking not allowed: %s", httpErr.Message)
return ErrInvalidArgs
default:
cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message)
return ErrAPIFailure
}
} else {
cfg.Errorf("Failed to delete stack on GitHub: %v", err)
cfg.Errorf("Failed to unstack on GitHub: %v", err)
return ErrAPIFailure
}
} else if !dissolved {
// Some PRs (queued for merge or with auto-merge enabled) remain
// stacked on GitHub, so the stack still exists. Keep local
// tracking so it continues to reflect the remote stack.
cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub")
cfg.Printf("The stack was left in place — local tracking is unchanged")
return nil
} else {
cfg.Successf("Stack deleted on GitHub")
cfg.Successf("Stack removed on GitHub")
}
}
}
@@ -117,55 +124,3 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error {
return nil
}
func shouldBlockUnstackDelete(client github.ClientOps, s *stack.Stack) (bool, error) {
if s == nil || len(s.Branches) == 0 {
return false, nil
}
eligible := 0
ineligible := 0
for _, b := range s.Branches {
// Respect stored merged status when available in local stack metadata.
if b.PullRequest != nil && b.PullRequest.Merged {
ineligible++
continue
}
var (
pr *github.PullRequest
err error
)
if b.PullRequest != nil && b.PullRequest.Number > 0 {
pr, err = client.FindPRByNumber(b.PullRequest.Number)
if err != nil {
return false, fmt.Errorf("checking PR #%d for branch %s: %w", b.PullRequest.Number, b.Branch, err)
}
} else {
pr, err = client.FindPRForBranch(b.Branch)
if err != nil {
return false, fmt.Errorf("checking PR for branch %s: %w", b.Branch, err)
}
}
// If the PR no longer exists (or branch has no open PR), do not block unstacking.
if pr == nil {
eligible++
continue
}
switch {
case pr.State == "MERGED":
ineligible++
case pr.IsQueued():
ineligible++
case pr.IsAutoMergeEnabled():
ineligible++
default:
eligible++
}
}
return ineligible > 0 && eligible == 0, nil
}
+104 -110
View File
@@ -37,6 +37,7 @@ func TestUnstack_RemovesStack(t *testing.T) {
s1 := stack.Stack{
ID: "42",
Number: 42,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
}
@@ -46,12 +47,12 @@ func TestUnstack_RemovesStack(t *testing.T) {
}
writeTwoStacks(t, gitDir, s1, s2)
var deletedStackID string
var unstackedNumber int
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
DeleteStackFn: func(stackID string) error {
deletedStackID = stackID
return nil
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
unstackedNumber = n
return nil, true, nil // dissolved
},
}
err := runUnstack(cfg, &unstackOptions{})
@@ -59,8 +60,8 @@ func TestUnstack_RemovesStack(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, output, "Stack removed from local tracking")
assert.Contains(t, output, "Stack deleted on GitHub")
assert.Equal(t, "42", deletedStackID)
assert.Contains(t, output, "Stack removed on GitHub")
assert.Equal(t, 42, unstackedNumber)
sf, err := stack.Load(gitDir)
require.NoError(t, err)
@@ -88,7 +89,7 @@ func TestUnstack_Local(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, output, "Stack removed")
// With --local, the GitHub API should NOT be called.
assert.NotContains(t, output, "Stack deleted on GitHub")
assert.NotContains(t, output, "Stack removed on GitHub")
sf, err := stack.Load(gitDir)
require.NoError(t, err)
@@ -103,7 +104,7 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) {
})
defer restore()
// Stack with no ID (never synced to GitHub)
// Stack with no ID/Number (never synced to GitHub)
writeStackFile(t, gitDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}},
@@ -112,9 +113,9 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) {
apiCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
DeleteStackFn: func(stackID string) error {
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
apiCalled = true
return nil
return nil, true, nil
},
}
err := runUnstack(cfg, &unstackOptions{})
@@ -124,7 +125,49 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) {
assert.False(t, apiCalled, "API should not be called when stack has no ID")
assert.Contains(t, output, "no remote ID")
assert.Contains(t, output, "Stack removed from local tracking")
assert.NotContains(t, output, "Stack deleted on GitHub")
assert.NotContains(t, output, "Stack removed on GitHub")
}
func TestUnstack_ResolvesNumberFromID(t *testing.T) {
// A local stack that predates the Number field (only ID stored) resolves
// its stack number from the remote list before unstacking.
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
},
})
var unstackedNumber int
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
ListStacksFn: func() ([]github.RemoteStack, error) {
return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil
},
UnstackFn: func(n int) (*github.RemoteStack, bool, error) {
unstackedNumber = n
return nil, true, nil
},
}
err := runUnstack(cfg, &unstackOptions{})
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.Equal(t, 7, unstackedNumber, "should resolve the stack number from the internal ID")
assert.Contains(t, output, "Stack removed from local tracking")
sf, err := stack.Load(gitDir)
require.NoError(t, err)
assert.Empty(t, sf.Stacks)
}
func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) {
@@ -136,8 +179,9 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) {
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
@@ -146,14 +190,14 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
DeleteStackFn: func(stackID string) error {
return &api.HTTPError{StatusCode: 404, Message: "Not Found"}
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
},
}
err := runUnstack(cfg, &unstackOptions{})
output := collectOutput(cfg, outR, errR)
// 404 means already deleted — should succeed and remove locally
// 404 means already gone — should succeed and remove locally
require.NoError(t, err)
assert.Contains(t, output, "continuing with local unstack")
assert.Contains(t, output, "Stack removed from local tracking")
@@ -163,7 +207,7 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) {
assert.Empty(t, sf.Stacks)
}
func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) {
func TestUnstack_ServerError_StopsLocalDeletion(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -172,8 +216,9 @@ func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) {
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
@@ -182,15 +227,15 @@ func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) {
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
DeleteStackFn: func(stackID string) error {
return &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"}
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
return nil, false, &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"}
},
}
err := runUnstack(cfg, &unstackOptions{})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
assert.Contains(t, output, "Failed to delete stack on GitHub (HTTP 409)")
assert.Contains(t, output, "Failed to unstack on GitHub (HTTP 409)")
// Should NOT remove locally when remote fails
assert.NotContains(t, output, "Stack removed from local tracking")
@@ -234,7 +279,10 @@ func TestUnstack_RemovesCorrectStackByPointer(t *testing.T) {
assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "should keep the OTHER stack intact")
}
func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) {
func TestUnstack_AllLocked_ServerRejects(t *testing.T) {
// Every PR is queued for merge or has auto-merge enabled. The server
// (not the client) rejects the unstack with a 422; the command surfaces the
// error and leaves local tracking in place.
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -243,30 +291,21 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) {
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}},
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
},
})
deleteCalled := false
unstackCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
switch number {
case 101:
return &github.PullRequest{Number: 101, State: "MERGED"}, nil
case 102:
return &github.PullRequest{Number: 102, State: "OPEN", MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"}}, nil
default:
return nil, nil
}
},
DeleteStackFn: func(stackID string) error {
deleteCalled = true
return nil
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
unstackCalled = true
return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"}
},
}
@@ -274,7 +313,7 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) {
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrInvalidArgs)
assert.False(t, deleteCalled, "DeleteStack should not be called when all PRs are ineligible")
assert.True(t, unstackCalled, "the server decides eligibility, so Unstack is called")
assert.Contains(t, output, "Unstacking not allowed")
assert.NotContains(t, output, "Stack removed from local tracking")
@@ -283,7 +322,9 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) {
require.Len(t, sf.Stacks, 1)
}
func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) {
func TestUnstack_PartialUnstack_KeepsLocalTracking(t *testing.T) {
// Some PRs (queued for merge / auto-merge) remain stacked, so the server
// returns the surviving stack (dissolved=false). Local tracking is kept.
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -292,30 +333,19 @@ func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) {
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
ID: "99",
Number: 99,
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
},
})
deleteCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
switch number {
case 101:
return &github.PullRequest{Number: 101, State: "MERGED"}, nil
case 102:
return &github.PullRequest{Number: 102, State: "OPEN"}, nil
default:
return nil, nil
}
},
DeleteStackFn: func(stackID string) error {
deleteCalled = true
return nil
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{102}}, false, nil
},
}
@@ -323,16 +353,19 @@ func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) {
output := collectOutput(cfg, outR, errR)
require.NoError(t, err)
assert.True(t, deleteCalled, "DeleteStack should be called when at least one PR is eligible")
assert.Contains(t, output, "Stack deleted on GitHub")
assert.Contains(t, output, "Stack removed from local tracking")
assert.Contains(t, output, "remain stacked on GitHub")
assert.Contains(t, output, "local tracking is unchanged")
assert.NotContains(t, output, "Stack removed from local tracking")
// The stack still exists remotely, so local tracking is preserved.
sf, loadErr := stack.Load(gitDir)
require.NoError(t, loadErr)
assert.Empty(t, sf.Stacks)
require.Len(t, sf.Stacks, 1)
}
func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) {
func TestUnstack_NumberLookupFailure_StopsDeletion(t *testing.T) {
// Resolving the stack number from its ID fails (list API error), so the
// command aborts without touching local tracking.
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
@@ -346,15 +379,15 @@ func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) {
Branches: []stack.BranchRef{{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}},
})
deleteCalled := false
unstackCalled := false
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
return nil, errors.New("graphql timeout")
ListStacksFn: func() ([]github.RemoteStack, error) {
return nil, errors.New("network error")
},
DeleteStackFn: func(stackID string) error {
deleteCalled = true
return nil
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
unstackCalled = true
return nil, true, nil
},
}
@@ -362,47 +395,8 @@ func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) {
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
assert.False(t, deleteCalled, "DeleteStack should not be called if preflight fails")
assert.Contains(t, output, "failed to check pull request states before unstack")
assert.NotContains(t, output, "Stack removed from local tracking")
sf, loadErr := stack.Load(gitDir)
require.NoError(t, loadErr)
require.Len(t, sf.Stacks, 1)
}
func TestUnstack_API422_ShowsInformativeErrorAndStopsLocalDeletion(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
})
defer restore()
writeStackFile(t, gitDir, stack.Stack{
ID: "99",
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}},
},
})
cfg, outR, errR := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
return &github.PullRequest{Number: number, State: "OPEN"}, nil
},
DeleteStackFn: func(stackID string) error {
return &api.HTTPError{StatusCode: 422, Message: "some pull requests cannot be removed from stack"}
},
}
err := runUnstack(cfg, &unstackOptions{})
output := collectOutput(cfg, outR, errR)
assert.ErrorIs(t, err, ErrAPIFailure)
assert.Contains(t, output, "Cannot delete stack on GitHub")
assert.Contains(t, output, "cannot be removed")
assert.False(t, unstackCalled, "Unstack should not be called if number lookup fails")
assert.Contains(t, output, "failed to look up stack on GitHub")
assert.NotContains(t, output, "Stack removed from local tracking")
sf, loadErr := stack.Load(gitDir)
+54 -14
View File
@@ -72,16 +72,49 @@ func printInterrupt(cfg *config.Config) {
cfg.Infof("Received interrupt, aborting operation")
}
// warnStacksUnavailableOrPAT prints an appropriate warning when a stacks API
// call returns 404. If the token is a PAT the message focuses on the auth
// issue; otherwise it falls back to the generic "not enabled" message.
func warnStacksUnavailableOrPAT(cfg *config.Config) {
if cfg.WarnIfPAT() {
return
}
// warnStacksUnavailable prints a warning when a stacks API call returns 404,
// indicating stacked PRs are not enabled for the repository.
func warnStacksUnavailable(cfg *config.Config) {
cfg.Warningf("Stacked PRs are not enabled for this repository")
}
// stackNumberByID resolves an internal stack ID (as stored in the local stack
// file) to its human-facing stack number by consulting the remote stack list.
// Returns ok=false when no remote stack matches the ID (e.g. it was deleted).
func stackNumberByID(client github.ClientOps, id string) (number int, ok bool, err error) {
if id == "" {
return 0, false, nil
}
stacks, err := client.ListStacks()
if err != nil {
return 0, false, err
}
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == id {
return rs.Number, true, nil
}
}
return 0, false, nil
}
// ensureStackNumber returns the stack number for s, resolving and caching it
// from the remote stack list by internal ID when the local model predates the
// Number field (older stack files stored only the ID). Returns 0 when the stack
// number can't be determined.
func ensureStackNumber(client github.ClientOps, s *stack.Stack) (int, error) {
if s.Number != 0 {
return s.Number, nil
}
number, found, err := stackNumberByID(client, s.ID)
if err != nil {
return 0, err
}
if found {
s.Number = number
}
return number, nil
}
// inputWithPrefill prompts the user for text input with the given prefill
// already editable in the input field. Unlike survey.Input's Default (which
// shows in parentheses), this places the prefill text directly in the
@@ -531,7 +564,7 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string
var remotePRNumbers []int
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == s.ID {
remotePRNumbers = rs.PullRequests
remotePRNumbers = rs.PRNumbers()
break
}
}
@@ -1248,7 +1281,7 @@ func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stac
found := false
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == s.ID {
remotePRNumbers = rs.PullRequests
remotePRNumbers = rs.PRNumbers()
found = true
break
}
@@ -1506,6 +1539,7 @@ func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stac
trunk := s.Trunk.Branch
remoteStackID := s.ID
remoteStackNumber := s.Number
oldBranches := s.BranchNames()
removeLocalStack(sf, s)
@@ -1520,7 +1554,7 @@ func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stac
}
}
newStack, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID)
newStack, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStackNumber)
if err != nil {
return res, err
}
@@ -1584,19 +1618,25 @@ func nearestBranchAfterReplace(oldBranches []string, currentBranch string, newSt
func resolveDivergenceDeleteRemote(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) (remoteReconcileResult, error) {
res := remoteReconcileResult{stop: true}
if err := client.DeleteStack(s.ID); err != nil {
number, err := ensureStackNumber(client, s)
if err != nil || number == 0 {
cfg.Warningf("Remote stack already deleted")
} else if _, dissolved, unstackErr := client.Unstack(number); unstackErr != nil {
var httpErr *api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
if errors.As(unstackErr, &httpErr) && httpErr.StatusCode == 404 {
cfg.Warningf("Remote stack already deleted")
} else {
cfg.Errorf("failed to delete remote stack: %v", err)
cfg.Errorf("failed to delete remote stack: %v", unstackErr)
return res, ErrAPIFailure
}
} else {
} else if dissolved {
cfg.Successf("Deleted the stack on GitHub")
} else {
cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub")
}
s.ID = ""
s.Number = 0
if err := stack.Save(gitDir, sf); err != nil {
return res, handleSaveError(cfg, err)
}
+4 -22
View File
@@ -691,39 +691,21 @@ func TestStackNeedsRebase_SkipsMergedBranches(t *testing.T) {
assert.False(t, stackNeedsRebase(s), "should skip merged branches and find stack up to date")
}
// setTestTokenForHost sets cfg.TokenForHostFn to return the given token for
// any host. Also sets RepoOverride so tests don't depend on real git context.
func setTestTokenForHost(cfg *config.Config, token string) {
cfg.TokenForHostFn = func(string) (string, string) { return token, "test" }
// setTestRepo sets RepoOverride so tests don't depend on real git context.
func setTestRepo(cfg *config.Config) {
cfg.RepoOverride = &repository.Repository{Host: "github.com", Owner: "o", Name: "r"}
}
func TestWarnStacksUnavailableOrPAT_ShowsPATMessage(t *testing.T) {
func TestWarnStacksUnavailable_ShowsNotEnabled(t *testing.T) {
cfg, _, errR := config.NewTestConfig()
setTestTokenForHost(cfg, "github_pat_fine_grained")
warnStacksUnavailableOrPAT(cfg)
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.Contains(t, output, "Personal access tokens are not supported by gh stack")
assert.NotContains(t, output, "Stacked PRs are not enabled")
}
func TestWarnStacksUnavailableOrPAT_ShowsNotEnabledForOAuth(t *testing.T) {
cfg, _, errR := config.NewTestConfig()
setTestTokenForHost(cfg, "gho_oauth_token")
warnStacksUnavailableOrPAT(cfg)
warnStacksUnavailable(cfg)
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.Contains(t, output, "Stacked PRs are not enabled for this repository")
assert.NotContains(t, output, "Personal access tokens")
}
func TestEnsureLocalTrunk_AlreadyExists(t *testing.T) {
-65
View File
@@ -1,65 +0,0 @@
package config
import (
"strings"
"github.com/cli/go-gh/v2/pkg/auth"
)
// tokenForHost returns the auth token for the given host, using the
// test override if set or falling back to the real auth.TokenForHost.
func (cfg *Config) tokenForHost(host string) (string, string) {
if cfg.TokenForHostFn != nil {
return cfg.TokenForHostFn(host)
}
return auth.TokenForHost(host)
}
// IsPersonalAccessToken reports whether the active token for the current
// repository's host is a personal access token (classic or fine-grained)
// rather than an OAuth token from `gh auth login`.
//
// Token prefix conventions:
//
// gho_ → OAuth token (supported)
// ghs_ → GitHub App installation token (supported)
// ghp_ → Classic personal access token (NOT supported)
// github_pat_ → Fine-grained personal access token (NOT supported)
func (cfg *Config) IsPersonalAccessToken() bool {
host := cfg.RepoHost()
if host == "" {
return false
}
return cfg.isPersonalAccessTokenForHost(host)
}
// isPersonalAccessTokenForHost checks the token prefix for the given host.
func (cfg *Config) isPersonalAccessTokenForHost(host string) bool {
token, _ := cfg.tokenForHost(host)
if token == "" {
return false
}
return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "github_pat_")
}
// RepoHost returns the GitHub host for the current repository, or an empty
// string if it cannot be determined (e.g. not inside a git repo).
func (cfg *Config) RepoHost() string {
repo, err := cfg.Repo()
if err != nil {
return ""
}
return repo.Host
}
// WarnIfPAT checks whether the active token is a personal access token and,
// if so, prints a warning explaining that PATs are not supported by gh stack.
// Returns true when a PAT is detected.
func (cfg *Config) WarnIfPAT() bool {
if !cfg.IsPersonalAccessToken() {
return false
}
cfg.Warningf("Personal access tokens are not supported by gh stack during private preview")
cfg.Printf(" Run %s to authenticate with OAuth instead.", cfg.ColorCyan("gh auth login"))
return true
}
-69
View File
@@ -1,69 +0,0 @@
package config
import (
"io"
"testing"
"github.com/cli/go-gh/v2/pkg/repository"
"github.com/stretchr/testify/assert"
)
// testRepo is a fake repository used in tests to avoid depending on the
// real git repo context (which may not exist in CI).
var testRepo = &repository.Repository{Host: "github.com", Owner: "o", Name: "r"}
func TestIsPersonalAccessToken(t *testing.T) {
tests := []struct {
name string
token string
want bool
}{
{"oauth token", "gho_abc123", false},
{"app installation token", "ghs_abc123", false},
{"classic PAT", "ghp_abc123", true},
{"fine-grained PAT", "github_pat_abc123", true},
{"empty token", "", false},
{"unknown prefix", "some_other_token", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
TokenForHostFn: func(string) (string, string) { return tt.token, "test" },
}
got := cfg.isPersonalAccessTokenForHost("github.com")
assert.Equal(t, tt.want, got)
})
}
}
func TestWarnIfPAT_DetectsPAT(t *testing.T) {
cfg, _, errR := NewTestConfig()
cfg.RepoOverride = testRepo
cfg.TokenForHostFn = func(string) (string, string) { return "ghp_classic_pat_token", "test" }
result := cfg.WarnIfPAT()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.True(t, result)
assert.Contains(t, output, "Personal access tokens are not supported by gh stack")
assert.Contains(t, output, "gh auth login")
}
func TestWarnIfPAT_IgnoresOAuth(t *testing.T) {
cfg, _, errR := NewTestConfig()
cfg.RepoOverride = testRepo
cfg.TokenForHostFn = func(string) (string, string) { return "gho_oauth_token", "test" }
result := cfg.WarnIfPAT()
cfg.Err.Close()
errOut, _ := io.ReadAll(errR)
output := string(errOut)
assert.False(t, result)
assert.Empty(t, output)
}
-5
View File
@@ -47,11 +47,6 @@ type Config struct {
// terminal. Used in tests to simulate text input prompts.
InputFn func(prompt, defaultValue string) (string, error)
// TokenForHostFn, when non-nil, is called instead of auth.TokenForHost
// to retrieve the auth token for a given GitHub host. Used in tests to
// simulate different token types (OAuth vs PAT).
TokenForHostFn func(host string) (string, string)
// RepoOverride, when non-nil, is returned by Repo() instead of
// calling repository.Current(). Used in tests to avoid depending on
// the real git repo context.
+5 -3
View File
@@ -12,9 +12,11 @@ type ClientOps interface {
MarkPRReadyForReview(prID string) error
DisableAutoMerge(prID string) error
ListStacks() ([]RemoteStack, error)
CreateStack(prNumbers []int) (int, error)
UpdateStack(stackID string, prNumbers []int) error
DeleteStack(stackID string) error
FindStackForPR(prNumber int) (*RemoteStack, error)
GetStack(stackNumber int) (*RemoteStack, error)
CreateStack(prNumbers []int) (*RemoteStack, error)
AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error)
Unstack(stackNumber int) (*RemoteStack, bool, error)
}
// Compile-time check that Client satisfies ClientOps.
+158 -43
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math"
"net/http"
"github.com/cli/go-gh/v2/pkg/api"
graphql "github.com/cli/shurcooL-graphql"
@@ -399,16 +400,96 @@ func toGraphQLInt(n int) (graphql.Int, error) {
return graphql.Int(n), nil
}
type RemoteStack struct {
ID int `json:"id"`
PullRequests []int `json:"pull_requests"`
// RemoteStackBase describes the base ref (and optionally SHA) of a stack.
type RemoteStackBase struct {
Ref string `json:"ref"`
Sha string `json:"sha,omitempty"`
}
// ListStacks returns all stacks in the repository.
// Returns an empty slice if no stacks exist.
// A 404 response indicates stacked PRs are not enabled for this repository.
// RemoteStackPRHead describes the head ref of a pull request in a stack.
type RemoteStackPRHead struct {
Ref string `json:"ref"`
Sha string `json:"sha"`
}
// RemoteStackPR is a pull request entry within a remote stack, as returned by
// the Stacks REST API list/detail endpoints.
type RemoteStackPR struct {
Number int `json:"number"`
State string `json:"state"` // open, closed
Draft bool `json:"draft"`
MergedAt *string `json:"merged_at"`
Head RemoteStackPRHead `json:"head"`
}
// IsMerged reports whether the pull request has been merged.
func (p RemoteStackPR) IsMerged() bool {
return p.MergedAt != nil && *p.MergedAt != ""
}
// RemoteStack represents a stack of pull requests as returned by the public
// Stacks REST API (GET/POST /repos/{owner}/{repo}/stacks...). ID is the
// internal identifier; Number is the human-facing stack number shown in the
// github.com UI and used to address the stack in API paths.
//
// The API returns pull_requests as an array of objects; UnmarshalJSON flattens
// them to the ordered PullRequests numbers (bottom to top) and preserves the
// full entries in PRDetails for callers that need head refs or PR state.
type RemoteStack struct {
ID int `json:"id"`
Number int `json:"number"`
NodeID string `json:"node_id"`
URL string `json:"url"`
Base RemoteStackBase `json:"base"`
Open bool `json:"open"`
CreatedAt string `json:"created_at"`
PullRequests []int `json:"-"`
PRDetails []RemoteStackPR `json:"-"`
}
// UnmarshalJSON decodes the Stacks REST API representation, deriving the
// ordered PullRequests numbers from the pull_requests objects.
func (s *RemoteStack) UnmarshalJSON(data []byte) error {
type wire struct {
ID int `json:"id"`
Number int `json:"number"`
NodeID string `json:"node_id"`
URL string `json:"url"`
Base RemoteStackBase `json:"base"`
Open bool `json:"open"`
CreatedAt string `json:"created_at"`
PullRequests []RemoteStackPR `json:"pull_requests"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
s.ID = w.ID
s.Number = w.Number
s.NodeID = w.NodeID
s.URL = w.URL
s.Base = w.Base
s.Open = w.Open
s.CreatedAt = w.CreatedAt
s.PRDetails = w.PullRequests
s.PullRequests = make([]int, len(w.PullRequests))
for i, p := range w.PullRequests {
s.PullRequests[i] = p.Number
}
return nil
}
// PRNumbers returns the ordered pull request numbers in the stack, from bottom
// to top.
func (s *RemoteStack) PRNumbers() []int {
return s.PullRequests
}
// ListStacks returns all stacks in the repository, ordered by stack number
// (descending). Returns an empty slice if no stacks exist. A 404 response
// indicates stacked PRs are not enabled for this repository.
func (c *Client) ListStacks() ([]RemoteStack, error) {
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo)
path := fmt.Sprintf("repos/%s/%s/stacks", c.owner, c.repo)
var stacks []RemoteStack
if err := c.rest.Get(path, &stacks); err != nil {
return nil, err
@@ -419,58 +500,92 @@ func (c *Client) ListStacks() ([]RemoteStack, error) {
return stacks, nil
}
// FindStackForPR returns the stack that contains the given pull request number,
// using the list endpoint's server-side pull_request filter. Returns nil
// (without error) when the PR is not part of any stack.
func (c *Client) FindStackForPR(prNumber int) (*RemoteStack, error) {
path := fmt.Sprintf("repos/%s/%s/stacks?pull_request=%d", c.owner, c.repo, prNumber)
var stacks []RemoteStack
if err := c.rest.Get(path, &stacks); err != nil {
return nil, err
}
if len(stacks) == 0 {
return nil, nil
}
return &stacks[0], nil
}
// GetStack fetches a single stack by its stack number.
func (c *Client) GetStack(stackNumber int) (*RemoteStack, error) {
path := fmt.Sprintf("repos/%s/%s/stacks/%d", c.owner, c.repo, stackNumber)
var rs RemoteStack
if err := c.rest.Get(path, &rs); err != nil {
return nil, err
}
return &rs, nil
}
// CreateStack creates a stack on GitHub from an ordered list of PR numbers.
// The PR numbers must be ordered from bottom to top of the stack and must
// form a valid base-to-head chain. Returns the server-assigned stack ID.
func (c *Client) CreateStack(prNumbers []int) (int, error) {
// The PR numbers must be ordered from bottom to top of the stack (at least two)
// and must form a valid base-to-head chain. Returns the created stack.
func (c *Client) CreateStack(prNumbers []int) (*RemoteStack, error) {
type createStackRequest struct {
PullRequestNumbers []int `json:"pull_request_numbers"`
PullRequests []int `json:"pull_requests"`
}
body, err := json.Marshal(createStackRequest{PullRequestNumbers: prNumbers})
body, err := json.Marshal(createStackRequest{PullRequests: prNumbers})
if err != nil {
return 0, fmt.Errorf("marshaling request: %w", err)
return nil, fmt.Errorf("marshaling request: %w", err)
}
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo)
var response struct {
ID int `json:"id"`
path := fmt.Sprintf("repos/%s/%s/stacks", c.owner, c.repo)
var rs RemoteStack
if err := c.rest.Post(path, bytes.NewReader(body), &rs); err != nil {
return nil, err
}
if err := c.rest.Post(path, bytes.NewReader(body), &response); err != nil {
return 0, err
}
return response.ID, nil
return &rs, nil
}
// UpdateStack adds pull requests to an existing stack on GitHub.
// The stack is identified by stackID. The full list of PR numbers in the
// updated stack must be provided, including existing and new PRs, ordered
// from bottom to top.
func (c *Client) UpdateStack(stackID string, prNumbers []int) error {
type updateStackRequest struct {
PullRequestNumbers []int `json:"pull_request_numbers"`
// AddToStack appends pull requests to the top of an existing stack. Only the
// new PR numbers (the delta) should be provided, ordered from the current top
// of the stack upward. Returns the updated stack.
func (c *Client) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) {
type addToStackRequest struct {
PullRequests []int `json:"pull_requests"`
}
body, err := json.Marshal(updateStackRequest{PullRequestNumbers: prNumbers})
body, err := json.Marshal(addToStackRequest{PullRequests: prNumbers})
if err != nil {
return fmt.Errorf("marshaling request: %w", err)
return nil, fmt.Errorf("marshaling request: %w", err)
}
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID)
path := fmt.Sprintf("repos/%s/%s/stacks/%d/add", c.owner, c.repo, stackNumber)
var rs RemoteStack
if err := c.rest.Post(path, bytes.NewReader(body), &rs); err != nil {
return nil, err
}
return &rs, nil
}
var response struct {
ID int `json:"id"`
// Unstack removes unlocked pull requests from a stack. The server leaves PRs
// that cannot be unstacked (queued for merge or with auto-merge enabled) in
// place. When PRs remain, the updated stack is returned with dissolved=false;
// when none remain the stack is destroyed and dissolved=true (HTTP 204).
func (c *Client) Unstack(stackNumber int) (rs *RemoteStack, dissolved bool, err error) {
path := fmt.Sprintf("repos/%s/%s/stacks/%d/unstack", c.owner, c.repo, stackNumber)
resp, err := c.rest.Request(http.MethodPost, path, nil)
if err != nil {
return nil, false, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
return nil, true, nil
}
return c.rest.Put(path, bytes.NewReader(body), &response)
}
// DeleteStack deletes a stack on GitHub.
// The stack is identified by stackID. Returns nil on success (204).
func (c *Client) DeleteStack(stackID string) error {
path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID)
return c.rest.Delete(path, nil)
var remaining RemoteStack
if decErr := json.NewDecoder(resp.Body).Decode(&remaining); decErr != nil {
return nil, false, fmt.Errorf("decoding unstack response: %w", decErr)
}
return &remaining, false, nil
}
+68
View File
@@ -1,10 +1,12 @@
package github
import (
"encoding/json"
"testing"
graphql "github.com/cli/shurcooL-graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRURL(t *testing.T) {
@@ -80,3 +82,69 @@ func TestToGraphQLInt(t *testing.T) {
assert.Error(t, err)
})
}
// TestRemoteStack_UnmarshalJSON verifies the custom decoder that flattens the
// Stacks REST API's pull_requests object array into ordered PullRequests
// numbers while preserving the full entries in PRDetails. This is the only path
// that populates those fields from the wire, so a shape regression here would
// silently break every stack endpoint.
func TestRemoteStack_UnmarshalJSON(t *testing.T) {
payload := `{
"id": 6154,
"number": 360,
"node_id": "S_kwABCD",
"url": "https://api.github.com/repos/o/r/stacks/360",
"base": {"ref": "main", "sha": "basesha"},
"open": true,
"created_at": "2026-01-01T00:00:00Z",
"pull_requests": [
{"number": 12, "state": "open", "draft": true, "merged_at": null, "head": {"ref": "feat-1", "sha": "sha1"}},
{"number": 15, "state": "closed", "draft": false, "merged_at": "2026-01-02T00:00:00Z", "head": {"ref": "feat-2", "sha": "sha2"}}
]
}`
var s RemoteStack
require.NoError(t, json.Unmarshal([]byte(payload), &s))
// Top-level metadata.
assert.Equal(t, 6154, s.ID)
assert.Equal(t, 360, s.Number)
assert.Equal(t, "S_kwABCD", s.NodeID)
assert.Equal(t, "https://api.github.com/repos/o/r/stacks/360", s.URL)
assert.Equal(t, "main", s.Base.Ref)
assert.Equal(t, "basesha", s.Base.Sha)
assert.True(t, s.Open)
assert.Equal(t, "2026-01-01T00:00:00Z", s.CreatedAt)
// Ordered PR numbers (bottom to top) derived from the object array.
assert.Equal(t, []int{12, 15}, s.PullRequests)
assert.Equal(t, []int{12, 15}, s.PRNumbers())
// Full PR entries preserved in PRDetails, including nullable merged_at.
require.Len(t, s.PRDetails, 2)
assert.Equal(t, 12, s.PRDetails[0].Number)
assert.Equal(t, "open", s.PRDetails[0].State)
assert.True(t, s.PRDetails[0].Draft)
assert.Nil(t, s.PRDetails[0].MergedAt)
assert.False(t, s.PRDetails[0].IsMerged())
assert.Equal(t, "feat-1", s.PRDetails[0].Head.Ref)
assert.Equal(t, "sha1", s.PRDetails[0].Head.Sha)
assert.Equal(t, 15, s.PRDetails[1].Number)
assert.Equal(t, "closed", s.PRDetails[1].State)
require.NotNil(t, s.PRDetails[1].MergedAt)
assert.Equal(t, "2026-01-02T00:00:00Z", *s.PRDetails[1].MergedAt)
assert.True(t, s.PRDetails[1].IsMerged())
assert.Equal(t, "feat-2", s.PRDetails[1].Head.Ref)
}
// TestRemoteStack_UnmarshalJSON_EmptyPRs ensures a stack with no pull_requests
// decodes to empty (non-nil) slices rather than panicking.
func TestRemoteStack_UnmarshalJSON_EmptyPRs(t *testing.T) {
var s RemoteStack
require.NoError(t, json.Unmarshal([]byte(`{"id": 1, "number": 2, "pull_requests": []}`), &s))
assert.Equal(t, 1, s.ID)
assert.Equal(t, 2, s.Number)
assert.Empty(t, s.PullRequests)
assert.Empty(t, s.PRDetails)
}
+29 -13
View File
@@ -12,9 +12,11 @@ type MockClient struct {
MarkPRReadyForReviewFn func(string) error
DisableAutoMergeFn func(string) error
ListStacksFn func() ([]RemoteStack, error)
CreateStackFn func([]int) (int, error)
UpdateStackFn func(string, []int) error
DeleteStackFn func(string) error
FindStackForPRFn func(int) (*RemoteStack, error)
GetStackFn func(int) (*RemoteStack, error)
CreateStackFn func([]int) (*RemoteStack, error)
AddToStackFn func(int, []int) (*RemoteStack, error)
UnstackFn func(int) (*RemoteStack, bool, error)
}
// Compile-time check that MockClient satisfies ClientOps.
@@ -76,23 +78,37 @@ func (m *MockClient) ListStacks() ([]RemoteStack, error) {
return nil, nil
}
func (m *MockClient) CreateStack(prNumbers []int) (int, error) {
func (m *MockClient) FindStackForPR(prNumber int) (*RemoteStack, error) {
if m.FindStackForPRFn != nil {
return m.FindStackForPRFn(prNumber)
}
return nil, nil
}
func (m *MockClient) GetStack(stackNumber int) (*RemoteStack, error) {
if m.GetStackFn != nil {
return m.GetStackFn(stackNumber)
}
return &RemoteStack{}, nil
}
func (m *MockClient) CreateStack(prNumbers []int) (*RemoteStack, error) {
if m.CreateStackFn != nil {
return m.CreateStackFn(prNumbers)
}
return 0, nil
return &RemoteStack{}, nil
}
func (m *MockClient) UpdateStack(stackID string, prNumbers []int) error {
if m.UpdateStackFn != nil {
return m.UpdateStackFn(stackID, prNumbers)
func (m *MockClient) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) {
if m.AddToStackFn != nil {
return m.AddToStackFn(stackNumber, prNumbers)
}
return nil
return &RemoteStack{}, nil
}
func (m *MockClient) DeleteStack(stackID string) error {
if m.DeleteStackFn != nil {
return m.DeleteStackFn(stackID)
func (m *MockClient) Unstack(stackNumber int) (*RemoteStack, bool, error) {
if m.UnstackFn != nil {
return m.UnstackFn(stackNumber)
}
return nil
return nil, false, nil
}
+5 -1
View File
@@ -28,7 +28,11 @@
"properties": {
"id": {
"type": "string",
"description": "Identifier for this stack, populated from the API when available."
"description": "Global identifier for this stack, populated from the API when available."
},
"number": {
"type": "integer",
"description": "Repo-scoped number identifying this stack, displayed in the GitHub UI. Used as the primary way to reference a stack."
},
"prefix": {
"type": "string",
+1
View File
@@ -43,6 +43,7 @@ type BranchRef struct {
// Stack represents a single stack of branches.
type Stack struct {
ID string `json:"id,omitempty"`
Number int `json:"number,omitempty"`
Prefix string `json:"prefix,omitempty"`
Numbered bool `json:"numbered,omitempty"`
Trunk BranchRef `json:"trunk"`
+2
View File
@@ -235,6 +235,7 @@ func TestLoad_Save_RoundTrip(t *testing.T) {
Stacks: []Stack{
{
ID: "s1",
Number: 7,
Prefix: "feat",
Trunk: BranchRef{Branch: "main", Head: "abc123"},
Branches: []BranchRef{
@@ -256,6 +257,7 @@ func TestLoad_Save_RoundTrip(t *testing.T) {
s := loaded.Stacks[0]
assert.Equal(t, "s1", s.ID)
assert.Equal(t, 7, s.Number)
assert.Equal(t, "feat", s.Prefix)
assert.Equal(t, "main", s.Trunk.Branch)
assert.Equal(t, "abc123", s.Trunk.Head)