mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
a82dc3ef1d
* 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
2592 lines
79 KiB
Go
2592 lines
79 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/cli/go-gh/v2/pkg/api"
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/github"
|
|
"github.com/github/gh-stack/internal/modify"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/github/gh-stack/internal/tui/submitview"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGeneratePRBody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
commitBody string
|
|
templateContent string
|
|
wantContains []string
|
|
wantNotContains []string
|
|
}{
|
|
{
|
|
name: "empty commit body no template",
|
|
commitBody: "",
|
|
wantContains: []string{
|
|
"GitHub Stacks CLI",
|
|
feedbackURL,
|
|
"<sub>",
|
|
},
|
|
},
|
|
{
|
|
name: "with commit body no template",
|
|
commitBody: "This is a detailed description\nof the change.",
|
|
wantContains: []string{
|
|
"This is a detailed description\nof the change.",
|
|
"GitHub Stacks CLI",
|
|
"<sub>",
|
|
},
|
|
},
|
|
{
|
|
name: "with template",
|
|
commitBody: "some commit body",
|
|
templateContent: "## Description\n\nFill in details.",
|
|
wantContains: []string{
|
|
"## Description",
|
|
"Fill in details.",
|
|
},
|
|
wantNotContains: []string{
|
|
"GitHub Stacks CLI",
|
|
feedbackURL,
|
|
"some commit body",
|
|
},
|
|
},
|
|
{
|
|
name: "template replaces footer",
|
|
templateContent: "Template body only",
|
|
wantContains: []string{"Template body only"},
|
|
wantNotContains: []string{"<sub>"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := generatePRBody(tt.commitBody, tt.templateContent)
|
|
for _, want := range tt.wantContains {
|
|
assert.Contains(t, got, want)
|
|
}
|
|
for _, notWant := range tt.wantNotContains {
|
|
assert.NotContains(t, got, notWant)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newSubmitMock creates a MockOps pre-configured for submit tests.
|
|
func newSubmitMock(tmpDir string, currentBranch string) *git.MockOps {
|
|
return &git.MockOps{
|
|
GitDirFn: func() (string, error) { return tmpDir, nil },
|
|
RootDirFn: func() (string, error) { return tmpDir, nil },
|
|
CurrentBranchFn: func() (string, error) { return currentBranch, nil },
|
|
ResolveRemoteFn: func(string) (string, error) { return "origin", nil },
|
|
PushFn: func(string, []string, bool, bool) error { return nil },
|
|
}
|
|
}
|
|
|
|
func TestSubmit_CreatesPRsAndStack(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)
|
|
|
|
var pushCalls []pushCall
|
|
var createdPRs []string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
|
|
return nil
|
|
}
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
prCounter := 100
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
return nil, nil // No existing PR
|
|
},
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
createdPRs = append(createdPRs, head)
|
|
prCounter++
|
|
return &github.PullRequest{
|
|
Number: prCounter,
|
|
ID: fmt.Sprintf("PR_%d", prCounter),
|
|
URL: fmt.Sprintf("https://github.com/owner/repo/pull/%d", prCounter),
|
|
}, nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
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.NoError(t, err)
|
|
|
|
// Branches should be pushed (sequentially, one per branch)
|
|
require.Len(t, pushCalls, 2)
|
|
assert.Equal(t, "origin", pushCalls[0].remote)
|
|
assert.Equal(t, []string{"b1"}, pushCalls[0].branches)
|
|
assert.Equal(t, []string{"b2"}, pushCalls[1].branches)
|
|
|
|
// PRs should be created
|
|
assert.Equal(t, []string{"b1", "b2"}, createdPRs)
|
|
|
|
// Stack should be created
|
|
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
|
|
assert.Contains(t, output, "Pushed and synced 2 branches")
|
|
}
|
|
|
|
func TestSubmit_DefaultDraft(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var createdDraft bool
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
createdDraft = draft
|
|
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.True(t, createdDraft, "PRs should be created as drafts by default")
|
|
}
|
|
|
|
func TestSubmit_OpenFlag(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var createdDraft bool
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
createdDraft = draft
|
|
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto", "--open"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.False(t, createdDraft, "PRs should not be created as drafts when --open is set")
|
|
}
|
|
|
|
func TestSubmit_OpenFlag_ConvertsDraftPRs(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, ID: "PR_10"}},
|
|
{Branch: "b2"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var markedReady []string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10", HeadRefName: "b1", BaseRefName: "main",
|
|
IsDraft: true, URL: "https://github.com/o/r/pull/10",
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
return &github.PullRequest{
|
|
Number: 11, ID: "PR_11", URL: "https://github.com/o/r/pull/11",
|
|
}, nil
|
|
},
|
|
MarkPRReadyForReviewFn: func(prID string) error {
|
|
markedReady = append(markedReady, prID)
|
|
return nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto", "--open"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"PR_10"}, markedReady, "existing draft PR should be marked ready")
|
|
assert.Contains(t, output, "Marked PR")
|
|
}
|
|
|
|
func TestSubmit_PushFailure(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error {
|
|
return fmt.Errorf("remote rejected")
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{}
|
|
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, ErrSilent)
|
|
assert.Contains(t, output, "failed to push")
|
|
}
|
|
|
|
func TestSubmit_SkipsMergedBranches(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
|
|
{Branch: "b2"},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3, Merged: true}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var pushCalls []pushCall
|
|
|
|
mock := newSubmitMock(tmpDir, "b2")
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
|
|
return nil
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
// Only return an OPEN PR for the active branch (b2).
|
|
// Merged branches (b1, b3) should have no open PR.
|
|
if branch == "b2" {
|
|
return &github.PullRequest{Number: 2, URL: "https://github.com/owner/repo/pull/2", State: "OPEN"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
|
|
assert.NoError(t, err)
|
|
require.Len(t, pushCalls, 1)
|
|
assert.Equal(t, []string{"b2"}, pushCalls[0].branches)
|
|
}
|
|
|
|
// TestSubmit_ForksWhenRemoteStackFullyMerged covers the case where every PR
|
|
// officially part of the stack on GitHub has merged and the user has added new
|
|
// branches on top. Submit should lift the new branches into a fresh stack rooted
|
|
// at the trunk and create a new stack on GitHub, leaving the merged stack alone.
|
|
func TestSubmit_ForksWhenRemoteStackFullyMerged(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
branchesExist bool // do the merged branches still exist locally?
|
|
wantStackCount int
|
|
}{
|
|
{name: "removes old stack when merged branches are gone", branchesExist: false, wantStackCount: 1},
|
|
{name: "keeps old stack when merged branches still exist", branchesExist: true, wantStackCount: 2},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := stack.Stack{
|
|
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}},
|
|
{Branch: "b3"},
|
|
{Branch: "b4"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var pushCalls []pushCall
|
|
var createdPRs []string
|
|
var createStackPRs []int
|
|
|
|
mock := newSubmitMock(tmpDir, "b4")
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
|
|
return nil
|
|
}
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
mock.MergeBaseFn = func(a, b string) (string, error) { return "basesha", nil }
|
|
mock.RevParseFn = func(ref string) (string, error) { return "sha-" + ref, nil }
|
|
mock.BranchExistsFn = func(string) bool { return tt.branchesExist }
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
prCounter := 100
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 42, PullRequests: []int{1, 2}}}, nil
|
|
},
|
|
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
|
|
switch n {
|
|
case 1:
|
|
return &github.PullRequest{Number: 1, HeadRefName: "b1", State: "MERGED", Merged: true}, nil
|
|
case 2:
|
|
return &github.PullRequest{Number: 2, HeadRefName: "b2", State: "MERGED", Merged: true}, nil
|
|
}
|
|
return &github.PullRequest{Number: n, State: "OPEN"}, nil
|
|
},
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
createdPRs = append(createdPRs, head)
|
|
prCounter++
|
|
return &github.PullRequest{
|
|
Number: prCounter,
|
|
ID: fmt.Sprintf("PR_%d", prCounter),
|
|
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter),
|
|
HeadRefName: head,
|
|
}, nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
createStackPRs = prNumbers
|
|
return &github.RemoteStack{ID: 99, Number: 99}, nil
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Only the new branches are pushed; merged ones are left behind.
|
|
require.Len(t, pushCalls, 2)
|
|
assert.Equal(t, []string{"b3"}, pushCalls[0].branches)
|
|
assert.Equal(t, []string{"b4"}, pushCalls[1].branches)
|
|
|
|
// Fork messaging.
|
|
assert.Contains(t, output, "Every PR in this stack has already been merged")
|
|
assert.Contains(t, output, "starting a new stack")
|
|
|
|
// PRs are created for the new branches and grouped into a new stack.
|
|
assert.Equal(t, []string{"b3", "b4"}, createdPRs)
|
|
assert.Equal(t, []int{101, 102}, createStackPRs)
|
|
|
|
// The local stack file is split: the new branches form their own
|
|
// stack rooted at the trunk with the freshly created remote ID.
|
|
reloaded, err := stack.Load(tmpDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, reloaded.Stacks, tt.wantStackCount)
|
|
|
|
forked := reloaded.FindAllStacksForBranch("b4")
|
|
require.Len(t, forked, 1)
|
|
assert.Equal(t, []string{"b3", "b4"}, forked[0].BranchNames())
|
|
assert.Equal(t, "99", forked[0].ID)
|
|
assert.Equal(t, "main", forked[0].Trunk.Branch)
|
|
|
|
oldStack := reloaded.FindAllStacksForBranch("b1")
|
|
if tt.branchesExist {
|
|
require.Len(t, oldStack, 1)
|
|
assert.Equal(t, []string{"b1", "b2"}, oldStack[0].BranchNames())
|
|
assert.Equal(t, "42", oldStack[0].ID)
|
|
} else {
|
|
assert.Empty(t, oldStack)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSubmit_NoForkWhenRemoteStackHasOpenPR verifies that a normal partially
|
|
// merged stack (the remote stack still has an open PR) is NOT forked — that is
|
|
// the everyday bottom-up merge flow and must keep working as before.
|
|
func TestSubmit_NoForkWhenRemoteStackHasOpenPR(t *testing.T) {
|
|
s := stack.Stack{
|
|
ID: "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}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
|
|
{Branch: "b4"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
var pushCalls []pushCall
|
|
|
|
mock := newSubmitMock(tmpDir, "b4")
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
|
|
return nil
|
|
}
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
mock.MergeBaseFn = func(a, b string) (string, error) { return "basesha", nil }
|
|
mock.RevParseFn = func(ref string) (string, error) { return "sha-" + ref, nil }
|
|
mock.BranchExistsFn = func(string) bool { return true }
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
prCounter := 100
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{1, 2, 3}}}, nil
|
|
},
|
|
FindPRByNumberFn: func(n int) (*github.PullRequest, error) {
|
|
switch n {
|
|
case 1:
|
|
return &github.PullRequest{Number: 1, HeadRefName: "b1", State: "MERGED", Merged: true}, nil
|
|
case 2:
|
|
return &github.PullRequest{Number: 2, HeadRefName: "b2", State: "MERGED", Merged: true}, nil
|
|
case 3:
|
|
return &github.PullRequest{Number: 3, HeadRefName: "b3", State: "OPEN"}, nil
|
|
}
|
|
return &github.PullRequest{Number: n, State: "OPEN"}, nil
|
|
},
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
prCounter++
|
|
return &github.PullRequest{
|
|
Number: prCounter,
|
|
ID: fmt.Sprintf("PR_%d", prCounter),
|
|
URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter),
|
|
HeadRefName: head,
|
|
}, nil
|
|
},
|
|
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 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/stacks/42/add"},
|
|
}
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
require.NoError(t, err)
|
|
|
|
// No fork happened.
|
|
assert.NotContains(t, output, "starting a new stack")
|
|
// The broken-chain update is explained calmly, not as a scary failure.
|
|
assert.Contains(t, output, "Merged PRs have left the stack")
|
|
assert.NotContains(t, output, "Failed to update stack")
|
|
|
|
// The local stack file is untouched: still a single stack with all branches.
|
|
reloaded, err := stack.Load(tmpDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, reloaded.Stacks, 1)
|
|
assert.Equal(t, []string{"b1", "b2", "b3", "b4"}, reloaded.Stacks[0].BranchNames())
|
|
}
|
|
|
|
// TestUpdateStack_BrokenChainAfterMerge verifies the "must form a stack" 422 is
|
|
// reported calmly when merged branches are present, but still warns otherwise.
|
|
func TestUpdateStack_BrokenChainAfterMerge(t *testing.T) {
|
|
mustFormErr := func() error {
|
|
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/stacks/42/add"},
|
|
}
|
|
}
|
|
|
|
t.Run("merged branches present is reported calmly", func(t *testing.T) {
|
|
s := &stack.Stack{
|
|
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{
|
|
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()
|
|
out, _ := io.ReadAll(errR)
|
|
output := string(out)
|
|
assert.Contains(t, output, "Merged PRs have left the stack")
|
|
assert.NotContains(t, output, "Failed to update stack")
|
|
})
|
|
|
|
t.Run("no merged branches still warns", func(t *testing.T) {
|
|
s := &stack.Stack{
|
|
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{
|
|
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()
|
|
out, _ := io.ReadAll(errR)
|
|
output := string(out)
|
|
assert.Contains(t, output, "Failed to update stack")
|
|
})
|
|
}
|
|
|
|
func TestSubmit_DefaultPRTitleBody(t *testing.T) {
|
|
t.Run("single_commit", func(t *testing.T) {
|
|
restore := git.SetOps(&git.MockOps{
|
|
LogRangeFn: func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{
|
|
{Subject: "Add login page", Body: "Implements the OAuth flow"},
|
|
}, nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
title, body := defaultPRTitleBody("main", "feat-login")
|
|
assert.Equal(t, "Add login page", title)
|
|
assert.Equal(t, "Implements the OAuth flow", body)
|
|
})
|
|
|
|
t.Run("multiple_commits", func(t *testing.T) {
|
|
restore := git.SetOps(&git.MockOps{
|
|
LogRangeFn: func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{
|
|
{Subject: "First commit"},
|
|
{Subject: "Second commit"},
|
|
}, nil
|
|
},
|
|
})
|
|
defer restore()
|
|
|
|
title, body := defaultPRTitleBody("main", "my-feature")
|
|
assert.Equal(t, "my feature", title)
|
|
assert.Equal(t, "", body)
|
|
})
|
|
}
|
|
|
|
func TestSubmit_Humanize(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"my-branch", "my branch"},
|
|
{"my_branch", "my branch"},
|
|
{"nobranch", "nobranch"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, humanize(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSyncStack_NewStack_CreateSuccess(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
var gotNumbers []int
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
gotNumbers = prNumbers
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Equal(t, []int{10, 11}, gotNumbers)
|
|
assert.Equal(t, "42", s.ID)
|
|
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
|
|
}
|
|
|
|
func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) {
|
|
s := &stack.Stack{
|
|
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}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
var gotStackNumber int
|
|
var gotNumbers []int
|
|
createCalled := false
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 0, Number: 0}, nil
|
|
},
|
|
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 &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11, 12}}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.False(t, createCalled, "CreateStack should not be called when s.ID is set")
|
|
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",
|
|
Number: 99,
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
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/stacks/99/add"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "Failed to update stack")
|
|
}
|
|
|
|
func TestSyncStack_ExistingStack_Update404(t *testing.T) {
|
|
s := &stack.Stack{
|
|
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}},
|
|
},
|
|
}
|
|
|
|
var createCalled bool
|
|
mock := &github.MockClient{
|
|
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/stacks/99/add"},
|
|
}
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 55, Number: 55}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.True(t, createCalled, "should fall through to CreateStack after 404")
|
|
assert.Equal(t, "55", s.ID, "should set new stack ID from create response")
|
|
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
|
|
}
|
|
|
|
func TestSyncStack_AlreadyStacked_OurStack(t *testing.T) {
|
|
// All our PRs are listed as "already stacked" — this is our stack, show up-to-date.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
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/stacks"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "Stack with 2 PRs is up to date")
|
|
assert.NotContains(t, output, "different stack")
|
|
}
|
|
|
|
func TestSyncStack_AlreadyStacked_DifferentStack(t *testing.T) {
|
|
// Only a subset of our PRs are listed — they're in a different stack.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
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/stacks"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "different stack")
|
|
assert.NotContains(t, output, "up to date")
|
|
}
|
|
|
|
func TestSyncStack_AdoptsExistingRemoteStack_ExactMatch(t *testing.T) {
|
|
// The stack exists on GitHub but isn't recorded locally (s.ID == "").
|
|
// All local PRs match the remote stack exactly — adopt the ID without
|
|
// creating or updating anything.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
var createCalled, updateCalled bool
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 0, Number: 0}, nil
|
|
},
|
|
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
|
|
updateCalled = true
|
|
return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11}}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.False(t, createCalled, "should not create when the stack already exists on GitHub")
|
|
assert.False(t, updateCalled, "should not update when local matches remote exactly")
|
|
assert.Equal(t, "77", s.ID, "should adopt the remote stack ID into local tracking")
|
|
assert.Contains(t, output, "Linked to the existing stack on GitHub")
|
|
assert.Contains(t, output, "up to date")
|
|
}
|
|
|
|
func TestSyncStack_AdoptsExistingRemoteStack_AddsNewPR(t *testing.T) {
|
|
// Two of our three PRs already form a remote stack; the third was added
|
|
// locally on top. Adopt the remote ID and update the stack to include the
|
|
// new PR at the top.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
var createCalled bool
|
|
var gotStackNumber int
|
|
var gotNumbers []int
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 0, Number: 0}, nil
|
|
},
|
|
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 &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
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, 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")
|
|
}
|
|
|
|
func TestSyncStack_RemoteStackHasExtraPRs_Refuses(t *testing.T) {
|
|
// The remote stack contains a PR we aren't tracking locally. Syncing to
|
|
// match local would drop it, so refuse and warn instead.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
var createCalled, updateCalled bool
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 0, Number: 0}, nil
|
|
},
|
|
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
|
|
updateCalled = true
|
|
return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.False(t, createCalled, "should not create over an existing stack")
|
|
assert.False(t, updateCalled, "should not drop remote-only PRs")
|
|
assert.Equal(t, "", s.ID, "should not adopt a divergent remote stack")
|
|
assert.Contains(t, output, "#12")
|
|
assert.Contains(t, output, "not in your local stack")
|
|
}
|
|
|
|
func TestSyncStack_PRsSpanMultipleRemoteStacks_Warns(t *testing.T) {
|
|
// Our PRs are split across two remote stacks — an unresolvable divergence.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
var createCalled, updateCalled bool
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{
|
|
{ID: 1, Number: 1, PullRequests: []int{10}},
|
|
{ID: 2, Number: 2, PullRequests: []int{11}},
|
|
}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 0, Number: 0}, nil
|
|
},
|
|
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
|
|
updateCalled = true
|
|
return &github.RemoteStack{ID: 1, Number: 1}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.False(t, createCalled, "should not create when PRs span multiple stacks")
|
|
assert.False(t, updateCalled, "should not update when PRs span multiple stacks")
|
|
assert.Equal(t, "", s.ID)
|
|
assert.Contains(t, output, "multiple stacks")
|
|
}
|
|
|
|
func TestSyncStack_ListStacksError_FallsThroughToCreate(t *testing.T) {
|
|
// If we can't inspect remote stacks, fall back to the create path rather
|
|
// than blocking the submit.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
var createCalled bool
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return nil, fmt.Errorf("network down")
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 88, Number: 88}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.True(t, createCalled, "should fall through to CreateStack when ListStacks fails")
|
|
assert.Equal(t, "88", s.ID)
|
|
assert.Contains(t, output, "Stack created on GitHub with 2 PRs")
|
|
}
|
|
|
|
func TestSyncStack_AlreadyPartOfAStack_FallbackPhrasing(t *testing.T) {
|
|
// Fallback path: ListStacks returns no match (so adoption is skipped), but
|
|
// the create endpoint still rejects with the server's "already part of a
|
|
// stack" phrasing (no PR numbers). The message must be actionable rather
|
|
// than the raw "Could not create stack".
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
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/stacks"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "already part of a")
|
|
assert.Contains(t, output, "gh stack checkout")
|
|
assert.NotContains(t, output, "Could not create stack")
|
|
}
|
|
|
|
func TestSyncStack_InvalidChain_422(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
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/stacks"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "must form a stack")
|
|
assert.Contains(t, output, "base branch must match")
|
|
}
|
|
|
|
func TestSyncStack_NotAvailable(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
return nil, &api.HTTPError{
|
|
StatusCode: 404,
|
|
Message: "Not Found",
|
|
RequestURL: &url.URL{Path: "/repos/o/r/stacks"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
|
|
cfg.Err.Close()
|
|
errOut, _ := io.ReadAll(errR)
|
|
output := string(errOut)
|
|
|
|
assert.Contains(t, output, "not enabled")
|
|
}
|
|
|
|
func TestSyncStack_SkippedForSinglePR(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
},
|
|
}
|
|
|
|
createCalled := false
|
|
updateCalled := false
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
AddToStackFn: func(int, []int) (*github.RemoteStack, error) {
|
|
updateCalled = true
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
cfg.Err.Close()
|
|
|
|
assert.False(t, createCalled, "CreateStack should not be called with fewer than 2 PRs")
|
|
assert.False(t, updateCalled, "UpdateStack should not be called with fewer than 2 PRs")
|
|
}
|
|
|
|
func TestSyncStack_IncludesMergedBranches(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
var gotNumbers []int
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
gotNumbers = prNumbers
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
cfg.Err.Close()
|
|
|
|
assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should include merged PRs to keep API in sync")
|
|
}
|
|
|
|
func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2"}, // no PR — skipped
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
var gotNumbers []int
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
gotNumbers = prNumbers
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
syncStack(cfg, mock, s)
|
|
cfg.Err.Close()
|
|
|
|
assert.Equal(t, []int{10, 12}, gotNumbers, "should skip branches without PRs")
|
|
}
|
|
|
|
func TestSubmit_UpdatesBaseBranch(t *testing.T) {
|
|
// b1's PR has base "main" but it should be "main" (correct).
|
|
// b2's PR has base "main" but it should be "b1" (wrong — needs update).
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
var updatedPRs []struct {
|
|
number int
|
|
base string
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
}, nil
|
|
case "b2":
|
|
return &github.PullRequest{
|
|
Number: 11, ID: "PR_11",
|
|
URL: "https://github.com/owner/repo/pull/11",
|
|
BaseRefName: "main", HeadRefName: "b2", // wrong base
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
UpdatePRBaseFn: func(number int, base string) error {
|
|
updatedPRs = append(updatedPRs, struct {
|
|
number int
|
|
base string
|
|
}{number, base})
|
|
return nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
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.NoError(t, err)
|
|
// b1's base is "main" which is correct — no update.
|
|
// b2's base is "main" but should be "b1" — should be updated.
|
|
require.Len(t, updatedPRs, 1)
|
|
assert.Equal(t, 11, updatedPRs[0].number)
|
|
assert.Equal(t, "b1", updatedPRs[0].base)
|
|
assert.Contains(t, output, "Updated base branch for PR")
|
|
}
|
|
|
|
func TestSubmit_SkipsBaseUpdateWhenStacked(t *testing.T) {
|
|
// Stack already exists (s.ID is set), so base updates should be skipped.
|
|
s := stack.Stack{
|
|
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}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
updateCalled := false
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
}, nil
|
|
case "b2":
|
|
return &github.PullRequest{
|
|
Number: 11, ID: "PR_11",
|
|
URL: "https://github.com/owner/repo/pull/11",
|
|
BaseRefName: "main", HeadRefName: "b2", // wrong base
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
UpdatePRBaseFn: func(number int, base string) error {
|
|
updateCalled = true
|
|
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
|
|
},
|
|
}
|
|
|
|
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.NoError(t, err)
|
|
assert.False(t, updateCalled, "should not call UpdatePRBase when stack exists")
|
|
assert.Contains(t, output, "cannot update while stacked")
|
|
}
|
|
|
|
func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) {
|
|
// b1 has a PR, b2 does not, b3 has a PR with wrong base.
|
|
// Submit should create b2's PR and fix b3's base.
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2"},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
var createdPRs []string
|
|
var updatedBases []struct {
|
|
number int
|
|
base string
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
}, nil
|
|
case "b2":
|
|
return nil, nil // no PR
|
|
case "b3":
|
|
return &github.PullRequest{
|
|
Number: 12, ID: "PR_12",
|
|
URL: "https://github.com/owner/repo/pull/12",
|
|
BaseRefName: "main", HeadRefName: "b3", // wrong base — should be b2
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
createdPRs = append(createdPRs, head)
|
|
return &github.PullRequest{
|
|
Number: 11, ID: "PR_11",
|
|
URL: "https://github.com/owner/repo/pull/11",
|
|
}, nil
|
|
},
|
|
UpdatePRBaseFn: func(number int, base string) error {
|
|
updatedBases = append(updatedBases, struct {
|
|
number int
|
|
base string
|
|
}{number, base})
|
|
return nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
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.NoError(t, err)
|
|
|
|
// b2 should have been created
|
|
assert.Equal(t, []string{"b2"}, createdPRs)
|
|
assert.Contains(t, output, "Created PR")
|
|
|
|
// b3's base should have been updated from "main" to "b2"
|
|
require.Len(t, updatedBases, 1)
|
|
assert.Equal(t, 12, updatedBases[0].number)
|
|
assert.Equal(t, "b2", updatedBases[0].base)
|
|
assert.Contains(t, output, "Updated base branch for PR")
|
|
|
|
// Stack should be created with all 3 PRs
|
|
assert.Contains(t, output, "Stack created on GitHub with 3 PRs")
|
|
}
|
|
|
|
func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) {
|
|
s := stack.Stack{
|
|
// No ID — this is a new stack, so the pre-flight check will run.
|
|
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()
|
|
|
|
// Non-interactive config — should bail out immediately.
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
|
|
},
|
|
}
|
|
|
|
setTestRepo(cfg)
|
|
|
|
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, "Stacked PRs are not enabled for this repository")
|
|
assert.False(t, pushed, "should not push when stacks are unavailable")
|
|
}
|
|
|
|
func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(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()
|
|
|
|
// Force interactive mode; survey will fail on the pipe,
|
|
// which is treated as a decline — same as user saying "no".
|
|
inR, inW, _ := os.Pipe()
|
|
inW.Close()
|
|
defer inR.Close()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.In = inR
|
|
cfg.ForceInteractive = true
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"}
|
|
},
|
|
}
|
|
|
|
setTestRepo(cfg)
|
|
|
|
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, "Stacked PRs are not enabled for this repository")
|
|
assert.False(t, pushed, "should not push when user declines")
|
|
}
|
|
|
|
func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) {
|
|
// Verify that syncStack is not called when stacksAvailable is false.
|
|
// This is the core behavior enabling unstacked PR creation.
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}},
|
|
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}},
|
|
},
|
|
}
|
|
|
|
createCalled := false
|
|
mock := &github.MockClient{
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
createCalled = true
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
|
|
// When stacksAvailable=true, syncStack should be called.
|
|
syncStack(cfg, mock, s)
|
|
assert.True(t, createCalled, "syncStack should call CreateStack when invoked")
|
|
|
|
// When stacksAvailable=false, the caller (runSubmit) skips syncStack
|
|
// entirely — verified by the submit_test integration tests above.
|
|
// Here we just confirm the contract: if syncStack is NOT called,
|
|
// CreateStack is NOT called.
|
|
createCalled = false
|
|
// (not calling syncStack)
|
|
assert.False(t, createCalled, "CreateStack should not be called when syncStack is skipped")
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
}
|
|
|
|
func TestSubmit_PreflightCheck_EmptyList_Proceeds(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
|
|
}
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{}, nil
|
|
},
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
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) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 99, Number: 99}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
|
|
assert.NoError(t, err)
|
|
assert.True(t, pushed, "should proceed with push when ListStacks succeeds")
|
|
}
|
|
|
|
func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) {
|
|
s := stack.Stack{
|
|
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}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
listStacksCallCount := 0
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
listStacksCallCount++
|
|
return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{10, 11}}}, nil
|
|
},
|
|
FindPRByNumberFn: func(number int) (*github.PullRequest, error) {
|
|
switch number {
|
|
case 10:
|
|
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10", HeadRefName: "b1", State: "OPEN"}, nil
|
|
case 11:
|
|
return &github.PullRequest{Number: 11, URL: "https://github.com/o/r/pull/11", HeadRefName: "b2", State: "OPEN"}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) {
|
|
return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, 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)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
|
|
assert.NoError(t, err)
|
|
// ListStacks is called by syncStackPRs (remote sync), but NOT by the
|
|
// preflight check. Two syncStackPRs calls happen in submit (before and
|
|
// after PR creation), so expect exactly 2 ListStacks calls.
|
|
assert.Equal(t, 2, listStacksCallCount, "ListStacks should only be called by syncStackPRs, not by the preflight check")
|
|
}
|
|
|
|
// --- Modify + Submit integration tests ---
|
|
|
|
func saveModifyState(t *testing.T, gitDir string, state *modify.StateFile) {
|
|
t.Helper()
|
|
require.NoError(t, modify.SaveState(gitDir, state))
|
|
}
|
|
|
|
func newPendingSubmitState(priorStackID string) *modify.StateFile {
|
|
return &modify.StateFile{
|
|
SchemaVersion: 1,
|
|
Phase: "pending_submit",
|
|
PriorRemoteStackID: priorStackID,
|
|
Snapshot: modify.Snapshot{StackMetadata: json.RawMessage(`{}`)},
|
|
}
|
|
}
|
|
|
|
func TestHandlePendingModify_DeletesOldStack(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
|
|
saveModifyState(t, gitDir, newPendingSubmitState("123"))
|
|
|
|
s := &stack.Stack{ID: "123", Number: 42, Trunk: stack.BranchRef{Branch: "main"}}
|
|
|
|
var unstackedNumber int
|
|
client := &github.MockClient{
|
|
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
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
err := handlePendingModify(cfg, client, s, gitDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 42, unstackedNumber)
|
|
assert.Equal(t, "", s.ID)
|
|
}
|
|
|
|
func TestHandlePendingModify_NoStateFile(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
// No state file on disk.
|
|
|
|
s := &stack.Stack{ID: "stack-123", Trunk: stack.BranchRef{Branch: "main"}}
|
|
|
|
deleteCalled := false
|
|
client := &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
deleteCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
err := handlePendingModify(cfg, client, s, gitDir)
|
|
assert.NoError(t, err)
|
|
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")
|
|
}
|
|
|
|
func TestHandlePendingModify_WrongPhase(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
|
|
state := &modify.StateFile{
|
|
SchemaVersion: 1,
|
|
Phase: "conflict",
|
|
Snapshot: modify.Snapshot{StackMetadata: json.RawMessage(`{}`)},
|
|
}
|
|
saveModifyState(t, gitDir, state)
|
|
|
|
s := &stack.Stack{ID: "stack-99", Trunk: stack.BranchRef{Branch: "main"}}
|
|
|
|
deleteCalled := false
|
|
client := &github.MockClient{
|
|
UnstackFn: func(int) (*github.RemoteStack, bool, error) {
|
|
deleteCalled = true
|
|
return nil, true, nil
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
err := handlePendingModify(cfg, client, s, gitDir)
|
|
assert.NoError(t, err)
|
|
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("456"))
|
|
|
|
s := &stack.Stack{ID: "456", Number: 43, Trunk: stack.BranchRef{Branch: "main"}}
|
|
|
|
client := &github.MockClient{
|
|
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")
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
err := handlePendingModify(cfg, client, s, gitDir)
|
|
assert.Error(t, err)
|
|
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("404"))
|
|
|
|
s := &stack.Stack{ID: "404", Number: 44, Trunk: stack.BranchRef{Branch: "main"}}
|
|
|
|
client := &github.MockClient{
|
|
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/stacks/44"},
|
|
}
|
|
},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
err := handlePendingModify(cfg, client, s, gitDir)
|
|
require.NoError(t, err, "404 should be treated as success (stack already deleted)")
|
|
assert.Equal(t, "", s.ID, "stack ID should be cleared after 404")
|
|
}
|
|
|
|
func TestClearPendingModifyState_ClearsFile(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
|
|
saveModifyState(t, gitDir, newPendingSubmitState("stack-789"))
|
|
require.True(t, modify.StateExists(gitDir), "precondition: state file should exist")
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
clearPendingModifyState(cfg, gitDir)
|
|
assert.False(t, modify.StateExists(gitDir), "state file should be removed")
|
|
}
|
|
|
|
func TestClearPendingModifyState_NoFile(t *testing.T) {
|
|
gitDir := t.TempDir()
|
|
// No state file on disk.
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
defer cfg.Out.Close()
|
|
defer cfg.Err.Close()
|
|
|
|
// Should not panic or error.
|
|
clearPendingModifyState(cfg, gitDir)
|
|
assert.False(t, modify.StateExists(gitDir))
|
|
}
|
|
|
|
func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) {
|
|
s := stack.Stack{
|
|
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}},
|
|
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
saveModifyState(t, tmpDir, newPendingSubmitState("42"))
|
|
|
|
// Track call ordering
|
|
var callOrder []string
|
|
var pushCalls []pushCall
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic})
|
|
callOrder = append(callOrder, fmt.Sprintf("push:%s", branches[0]))
|
|
return nil
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
var unstackedNumber int
|
|
var createdStackPRs []int
|
|
unstacked := false
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
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 {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
State: "OPEN",
|
|
}, nil
|
|
case "b2":
|
|
return &github.PullRequest{
|
|
Number: 11, ID: "PR_11",
|
|
URL: "https://github.com/owner/repo/pull/11",
|
|
BaseRefName: "b1", HeadRefName: "b2",
|
|
State: "OPEN",
|
|
}, nil
|
|
case "b3":
|
|
return &github.PullRequest{
|
|
Number: 12, ID: "PR_12",
|
|
URL: "https://github.com/owner/repo/pull/12",
|
|
BaseRefName: "b2", HeadRefName: "b3",
|
|
State: "OPEN",
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
createdStackPRs = prNumbers
|
|
callOrder = append(callOrder, "create_stack")
|
|
return &github.RemoteStack{ID: 99, Number: 99}, nil
|
|
},
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
// 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
|
|
},
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
|
|
assert.NoError(t, err)
|
|
|
|
// 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")
|
|
assert.Equal(t, []string{"b1"}, pushCalls[0].branches)
|
|
assert.Equal(t, []string{"b2"}, pushCalls[1].branches)
|
|
assert.Equal(t, []string{"b3"}, pushCalls[2].branches)
|
|
for _, pc := range pushCalls {
|
|
assert.False(t, pc.atomic, "sequential push should not use atomic mode")
|
|
}
|
|
|
|
// CreateStack called with all 3 PRs
|
|
assert.Equal(t, []int{10, 11, 12}, createdStackPRs)
|
|
|
|
// 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 == "unstack:7" && deleteIdx == -1 {
|
|
deleteIdx = i
|
|
}
|
|
if c == "push:b1" && firstPushIdx == -1 {
|
|
firstPushIdx = i
|
|
}
|
|
if c == "create_stack" && createIdx == -1 {
|
|
createIdx = i
|
|
}
|
|
}
|
|
assert.Greater(t, firstPushIdx, deleteIdx, "delete should happen before push")
|
|
assert.Greater(t, createIdx, firstPushIdx, "create_stack should happen after push")
|
|
|
|
// State file should be cleared
|
|
assert.False(t, modify.StateExists(tmpDir), "modify state file should be cleared after success")
|
|
}
|
|
|
|
func TestSubmit_FetchesBeforePush(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)
|
|
|
|
var callOrder []string
|
|
var fetchedBranches []string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.FetchBranchesFn = func(remote string, branches []string) error {
|
|
callOrder = append(callOrder, "fetch")
|
|
fetchedBranches = branches
|
|
assert.Equal(t, "origin", remote)
|
|
return nil
|
|
}
|
|
mock.PushFn = func(remote string, branches []string, force, atomic bool) error {
|
|
callOrder = append(callOrder, "push")
|
|
return nil
|
|
}
|
|
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
return &github.PullRequest{
|
|
Number: 1,
|
|
URL: "https://github.com/o/r/pull/1",
|
|
BaseRefName: "main",
|
|
HeadRefName: branch,
|
|
State: "OPEN",
|
|
}, nil
|
|
},
|
|
ListStacksFn: func() ([]github.RemoteStack, error) {
|
|
return []github.RemoteStack{}, nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
cfg.Err.Close()
|
|
_, _ = io.ReadAll(errR)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []string{"b1", "b2"}, fetchedBranches, "should fetch active branches")
|
|
// fetch must come before all pushes
|
|
require.True(t, len(callOrder) >= 3, "expected at least 3 calls (fetch + 2 pushes)")
|
|
assert.Equal(t, "fetch", callOrder[0], "fetch must happen before any push")
|
|
}
|
|
|
|
func TestSubmit_UsesPRTemplate(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
// Create a PR template in the repo root
|
|
ghDir := filepath.Join(tmpDir, ".github")
|
|
require.NoError(t, os.MkdirAll(ghDir, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(ghDir, "pull_request_template.md"),
|
|
[]byte("## What\n\nDescribe changes.\n\n## Why\n\nExplain motivation."),
|
|
0o644,
|
|
))
|
|
|
|
var capturedBody string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "add feature", Body: "detailed commit body"}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
capturedBody = body
|
|
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, capturedBody, "## What")
|
|
assert.Contains(t, capturedBody, "## Why")
|
|
assert.NotContains(t, capturedBody, "GitHub Stacks CLI", "footer should not be present when template is used")
|
|
assert.NotContains(t, capturedBody, feedbackURL)
|
|
}
|
|
|
|
// TestSubmit_IgnoresSymlinkedPRTemplate verifies that `gh stack submit --auto`
|
|
// does not follow a symlinked PR template. The non-interactive and
|
|
// interactive-prefill flows share the same pr.FindTemplate chokepoint, so they
|
|
// are covered transitively.
|
|
func TestSubmit_IgnoresSymlinkedPRTemplate(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
// The repo's PR template is a symlink to a file outside the repository;
|
|
// gh-stack must not follow it.
|
|
linked := filepath.Join(t.TempDir(), "linked.txt")
|
|
require.NoError(t, os.WriteFile(linked, []byte("LINKED_FILE_CONTENTS"), 0o600))
|
|
|
|
ghDir := filepath.Join(tmpDir, ".github")
|
|
require.NoError(t, os.MkdirAll(ghDir, 0o755))
|
|
if err := os.Symlink(linked, filepath.Join(ghDir, "pull_request_template.md")); err != nil {
|
|
t.Skipf("symlinks not supported on this platform: %v", err)
|
|
}
|
|
|
|
var capturedBody string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "add feature", Body: "detailed commit body"}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
capturedBody = body
|
|
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.NotContains(t, capturedBody, "LINKED_FILE_CONTENTS", "symlinked template contents must not be included in the PR body")
|
|
// The template was ignored, so the standard footer fallback is used.
|
|
assert.Contains(t, capturedBody, "GitHub Stacks CLI")
|
|
}
|
|
|
|
func TestSubmit_NoTemplate_UsesFooter(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
// No template file created
|
|
|
|
var capturedBody string
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.PushFn = func(string, []string, bool, bool) error { return nil }
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "fix bug"}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil },
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
capturedBody = body
|
|
return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil
|
|
},
|
|
CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil },
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template")
|
|
assert.Contains(t, capturedBody, feedbackURL)
|
|
}
|
|
|
|
func TestSubmit_DisablesAutoMergeOnExistingPR(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")
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit for " + head}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
var disabledAutoMergePRIDs []string
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
switch branch {
|
|
case "b1":
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
}, nil
|
|
case "b2":
|
|
return &github.PullRequest{
|
|
Number: 20, ID: "PR_20",
|
|
URL: "https://github.com/owner/repo/pull/20",
|
|
BaseRefName: "b1", HeadRefName: "b2",
|
|
AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"},
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
DisableAutoMergeFn: func(prID string) error {
|
|
disabledAutoMergePRIDs = append(disabledAutoMergePRIDs, prID)
|
|
return nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
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.NoError(t, err)
|
|
assert.Equal(t, []string{"PR_20"}, disabledAutoMergePRIDs)
|
|
assert.Contains(t, output, "Disabled auto-merge")
|
|
assert.Contains(t, output, "incompatible with stacked PRs")
|
|
}
|
|
|
|
func TestSubmit_DisableAutoMergeFailure_ContinuesWithWarning(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit"}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, errR := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"},
|
|
}, nil
|
|
},
|
|
DisableAutoMergeFn: func(prID string) error {
|
|
return fmt.Errorf("permission denied")
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
// Submit should succeed even if disable-auto-merge fails
|
|
assert.NoError(t, err)
|
|
assert.Contains(t, output, "failed to disable auto-merge")
|
|
assert.Contains(t, output, "permission denied")
|
|
}
|
|
|
|
func TestSubmit_NoAutoMerge_SkipsDisable(t *testing.T) {
|
|
s := stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{
|
|
{Branch: "b1"},
|
|
},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
writeStackFile(t, tmpDir, s)
|
|
|
|
mock := newSubmitMock(tmpDir, "b1")
|
|
mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) {
|
|
return []git.CommitInfo{{Subject: "commit"}}, nil
|
|
}
|
|
restore := git.SetOps(mock)
|
|
defer restore()
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
cfg.GitHubClientOverride = &github.MockClient{
|
|
FindPRForBranchFn: func(branch string) (*github.PullRequest, error) {
|
|
return &github.PullRequest{
|
|
Number: 10, ID: "PR_10",
|
|
URL: "https://github.com/owner/repo/pull/10",
|
|
BaseRefName: "main", HeadRefName: "b1",
|
|
}, nil
|
|
},
|
|
DisableAutoMergeFn: func(prID string) error {
|
|
t.Fatal("DisableAutoMerge should not be called when auto-merge is not enabled")
|
|
return nil
|
|
},
|
|
CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) {
|
|
return &github.RemoteStack{ID: 42, Number: 42}, nil
|
|
},
|
|
}
|
|
|
|
cmd := SubmitCmd(cfg)
|
|
cmd.SetArgs([]string{"--auto"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
// --- Per-PR draft override plumbing (interactive editor contract) ---
|
|
|
|
func TestCreatePR_UsesDraftOverride(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
}
|
|
|
|
var gotTitle, gotBody string
|
|
var gotDraft bool
|
|
cfg, _, _ := config.NewTestConfig()
|
|
client := &github.MockClient{
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
gotTitle, gotBody, gotDraft = title, body, draft
|
|
return &github.PullRequest{Number: 7, ID: "PR_7", URL: "https://github.com/o/r/pull/7"}, nil
|
|
},
|
|
}
|
|
|
|
drafts := map[string]*submitview.PRDraft{
|
|
"b1": {Branch: "b1", Include: true, Title: "Custom title", Body: "Custom body", Draft: true},
|
|
}
|
|
|
|
// --open would normally force ready; the override's Draft must win.
|
|
err := createPR(cfg, client, s, 0, "main", &submitOptions{open: true}, "", drafts)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "Custom title", gotTitle)
|
|
assert.Contains(t, gotBody, "Custom body")
|
|
assert.Contains(t, gotBody, "GitHub Stacks CLI", "footer is appended at submit time")
|
|
assert.True(t, gotDraft, "draft override should be honored over --open")
|
|
require.NotNil(t, s.Branches[0].PullRequest)
|
|
assert.Equal(t, 7, s.Branches[0].PullRequest.Number)
|
|
}
|
|
|
|
func TestCreatePR_DraftOverride_KeepsUserBodyOverTemplate(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
}
|
|
|
|
var gotBody string
|
|
cfg, _, _ := config.NewTestConfig()
|
|
client := &github.MockClient{
|
|
CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) {
|
|
gotBody = body
|
|
return &github.PullRequest{Number: 1, ID: "PR_1"}, nil
|
|
},
|
|
}
|
|
// The user edited the description in the TUI; the repo also has a template.
|
|
// The user's edits must win — the template was only the prefill.
|
|
drafts := map[string]*submitview.PRDraft{
|
|
"b1": {Branch: "b1", Include: true, Title: "T", Body: "My edited description"},
|
|
}
|
|
|
|
err := createPR(cfg, client, s, 0, "main", &submitOptions{}, "## Raw repo template", drafts)
|
|
require.NoError(t, err)
|
|
|
|
assert.Contains(t, gotBody, "My edited description", "the user's edited body is used")
|
|
assert.NotContains(t, gotBody, "Raw repo template", "the raw template does not override the user's edits")
|
|
assert.Contains(t, gotBody, "GitHub Stacks CLI", "the attribution footer is appended")
|
|
}
|
|
|
|
func TestEnsurePR_DeselectedNewBranchSkipsCreate(t *testing.T) {
|
|
s := &stack.Stack{
|
|
Trunk: stack.BranchRef{Branch: "main"},
|
|
Branches: []stack.BranchRef{{Branch: "b1"}},
|
|
}
|
|
|
|
cfg, _, _ := config.NewTestConfig()
|
|
client := &github.MockClient{
|
|
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
|
|
CreatePRFn: func(string, string, string, string, bool) (*github.PullRequest, error) {
|
|
t.Fatal("CreatePR must not be called for a deselected NEW branch")
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
drafts := map[string]*submitview.PRDraft{
|
|
"b1": {Branch: "b1", Include: false},
|
|
}
|
|
|
|
err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, s.Branches[0].PullRequest, "no PR should be recorded for a deselected branch")
|
|
}
|