Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int
}
}

// Disable auto-merge before adding this PR to a stack. A PR with
// auto-merge enabled would merge on its own, breaking the stack.
if pr.IsAutoMergeEnabled() {
if err := client.DisableAutoMerge(pr.ID); err != nil {
cfg.Warningf("failed to disable auto-merge for PR %s: %v",
cfg.PRLink(pr.Number, pr.URL), err)
} else {
cfg.Warningf("Disabled auto-merge for PR %s (incompatible with stacked PRs)",
cfg.PRLink(pr.Number, pr.URL))
}
}

if pr.BaseRefName != baseBranch {
if s.ID != "" {
// Stack API owns base relationships — can't update directly.
Expand Down
163 changes: 163 additions & 0 deletions cmd/submit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1800,3 +1800,166 @@ func TestSubmit_PreflightCheck_FinegrainedPAT_BailsOut(t *testing.T) {
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"},
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) (int, error) {
return 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) (int, error) {
return 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) (int, error) {
return 42, nil
},
}

cmd := SubmitCmd(cfg)
cmd.SetArgs([]string{"--auto"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

assert.NoError(t, err)
}
1 change: 1 addition & 0 deletions internal/github/client_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type ClientOps interface {
CreatePR(base, head, title, body string, draft bool) (*PullRequest, error)
UpdatePRBase(number int, base string) error
MarkPRReadyForReview(prID string) error
DisableAutoMerge(prID string) error
ListStacks() ([]RemoteStack, error)
CreateStack(prNumbers []int) (int, error)
UpdateStack(stackID string, prNumbers []int) error
Expand Down
27 changes: 27 additions & 0 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ func (c *Client) MarkPRReadyForReview(prID string) error {
return nil
}

// DisableAutoMerge disables auto-merge on a pull request.
func (c *Client) DisableAutoMerge(prID string) error {
var mutation struct {
DisablePullRequestAutoMerge struct {
PullRequest struct {
ID string
}
} `graphql:"disablePullRequestAutoMerge(input: $input)"`
}

type DisablePullRequestAutoMergeInput struct {
PullRequestID string `json:"pullRequestId"`
}

variables := map[string]interface{}{
"input": DisablePullRequestAutoMergeInput{
PullRequestID: prID,
},
}

if err := c.gql.Mutate("DisablePullRequestAutoMerge", &mutation, variables); err != nil {
return fmt.Errorf("disabling auto-merge: %w", err)
}

return nil
}

func (c *Client) repositoryID() (string, error) {
var query struct {
Repository struct {
Expand Down
8 changes: 8 additions & 0 deletions internal/github/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type MockClient struct {
CreatePRFn func(string, string, string, string, bool) (*PullRequest, error)
UpdatePRBaseFn func(int, string) error
MarkPRReadyForReviewFn func(string) error
DisableAutoMergeFn func(string) error
ListStacksFn func() ([]RemoteStack, error)
CreateStackFn func([]int) (int, error)
UpdateStackFn func(string, []int) error
Expand Down Expand Up @@ -61,6 +62,13 @@ func (m *MockClient) MarkPRReadyForReview(prID string) error {
return nil
}

func (m *MockClient) DisableAutoMerge(prID string) error {
if m.DisableAutoMergeFn != nil {
return m.DisableAutoMergeFn(prID)
}
return nil
}

func (m *MockClient) ListStacks() ([]RemoteStack, error) {
if m.ListStacksFn != nil {
return m.ListStacksFn()
Expand Down
Loading