Skip to content

Commit b689de9

Browse files
committed
hide merge method step for merge queue
1 parent f3ffc3f commit b689de9

11 files changed

Lines changed: 375 additions & 60 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
1717

1818
- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
1919
- `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable.
20-
- `internal/github/`: `ClientOps` interface (17 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`), which routes to a direct merge or the base branch's merge queue automatically.
20+
- `internal/github/`: `ClientOps` interface (18 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`), which routes to a direct merge or the base branch's merge queue automatically.
2121
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
2222
- `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking.
2323
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ internal/
3535
gitops.go # Ops interface (52 methods)
3636
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
3737
github/ # github.ClientOps interface + real Client
38-
client_interface.go # ClientOps interface (17 methods)
38+
client_interface.go # ClientOps interface (18 methods)
3939
mock_client.go # MockClient. Uses function-pointer fields for testing.
4040
stack/ # stack file (.git/gh-stack) management, JSON schema, locking
4141
schema.json # JSON Schema for the stack file format

cmd/merge.go

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -127,25 +127,44 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error {
127127
return explainNothingToMerge(cfg, remoteStack, blocker)
128128
}
129129

130-
mergeCfg, err := client.RepoMergeConfig()
131-
if err != nil {
132-
cfg.Errorf("failed to fetch repository merge settings: %s", err)
133-
return ErrAPIFailure
134-
}
135-
allowed := mergeCfg.AllowedMethods()
136-
if len(allowed) == 0 {
137-
cfg.Errorf("this repository does not allow any merge methods")
138-
return ErrAPIFailure
139-
}
140-
if method != "" && !mergeCfg.Allows(method) {
141-
cfg.Errorf("this repository does not allow %s merges", method)
142-
return ErrInvalidArgs
143-
}
144-
145130
base := remoteStack.Base.Ref
146131

132+
// Detect whether the base branch merges through a merge queue so the wizard
133+
// can skip the merge-method step and enqueue instead of merging directly.
134+
usesMergeQueue := baseBranchUsesMergeQueue(client, base)
135+
136+
var mergeCfg *github.RepoMergeConfig
137+
var allowed []string
138+
if usesMergeQueue {
139+
// The queue picks the merge method from its own configuration, so a
140+
// requested method does not apply.
141+
if method != "" {
142+
cfg.Warningf("the base branch %q uses a merge queue; ignoring the merge method", base)
143+
method = ""
144+
}
145+
} else {
146+
mergeCfg, err = client.RepoMergeConfig()
147+
if err != nil {
148+
cfg.Errorf("failed to fetch repository merge settings: %s", err)
149+
return ErrAPIFailure
150+
}
151+
allowed = mergeCfg.AllowedMethods()
152+
if len(allowed) == 0 {
153+
cfg.Errorf("this repository does not allow any merge methods")
154+
return ErrAPIFailure
155+
}
156+
if method != "" && !mergeCfg.Allows(method) {
157+
cfg.Errorf("this repository does not allow %s merges", method)
158+
return ErrInvalidArgs
159+
}
160+
}
161+
147162
if cfg.IsInteractive() && !opts.yes {
148-
return runMergeInteractive(cfg, client, remoteStack.Number, base, candidates, allowed, mergeCfg.DefaultMethod, method, preselectIndex, opts)
163+
defaultMethod := ""
164+
if mergeCfg != nil {
165+
defaultMethod = mergeCfg.DefaultMethod
166+
}
167+
return runMergeInteractive(cfg, client, remoteStack.Number, base, candidates, allowed, defaultMethod, method, preselectIndex, usesMergeQueue, opts)
149168
}
150169

151170
// Non-interactive (or --yes): merge the whole stack (or up to the given PR)
@@ -162,13 +181,13 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error {
162181
}
163182
targetPR = candidates[len(candidates)-1].Number
164183
}
165-
if method == "" {
184+
if !usesMergeQueue && method == "" {
166185
method = mergeCfg.DefaultMethod
167186
if !mergeCfg.Allows(method) {
168187
method = allowed[0]
169188
}
170189
}
171-
return runMergeHeadless(cfg, client, base, candidates, targetPR, method, opts)
190+
return runMergeHeadless(cfg, client, base, candidates, targetPR, method, usesMergeQueue, opts)
172191
}
173192

174193
// resolveMergeStack determines the remote stack (and any explicitly targeted PR)
@@ -275,7 +294,7 @@ func resolveActiveRemoteStack(cfg *config.Config, client github.ClientOps) (*git
275294
return rs, nil
276295
}
277296

278-
func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumber int, base string, candidates []mergeview.PRItem, allowed []string, viewerDefault, methodFlag string, preselectIndex int, opts *mergeOptions) error {
297+
func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumber int, base string, candidates []mergeview.PRItem, allowed []string, viewerDefault, methodFlag string, preselectIndex int, usesMergeQueue bool, opts *mergeOptions) error {
279298
defaultMethod := viewerDefault
280299
if methodFlag != "" {
281300
defaultMethod = methodFlag
@@ -303,6 +322,7 @@ func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumbe
303322
AllowedMethods: allowed,
304323
DefaultMethod: defaultMethod,
305324
PreselectTopIndex: preselectIndex,
325+
UsesMergeQueue: usesMergeQueue,
306326
Submit: submit,
307327
Poll: poll,
308328
PollInterval: opts.pollInterval,
@@ -343,11 +363,15 @@ func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumbe
343363
}
344364
}
345365

346-
func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string, candidates []mergeview.PRItem, targetPR int, method string, opts *mergeOptions) error {
366+
func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string, candidates []mergeview.PRItem, targetPR int, method string, usesMergeQueue bool, opts *mergeOptions) error {
347367
nums := numbersUpTo(candidates, targetPR)
348368
list := prNumberList(nums)
349369

350-
cfg.Printf("Merging %s into %s via %s...", list, base, method)
370+
if usesMergeQueue {
371+
cfg.Printf("Adding %s to the merge queue for %s...", list, base)
372+
} else {
373+
cfg.Printf("Merging %s into %s via %s...", list, base, method)
374+
}
351375

352376
res, err := client.MergeStackAsync(targetPR, method)
353377
if err != nil {
@@ -414,6 +438,19 @@ func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string,
414438
return ErrAPIFailure
415439
}
416440

441+
// baseBranchUsesMergeQueue reports whether the stack's base branch merges through
442+
// a merge queue. Detection only tailors the wizard (skipping the method step and
443+
// switching to enqueue wording), so a lookup failure falls back to the
444+
// direct-merge flow: the async merge always sends merge_action "default" and the
445+
// server still routes to the queue when the branch requires one.
446+
func baseBranchUsesMergeQueue(client github.ClientOps, base string) bool {
447+
uses, err := client.BaseBranchUsesMergeQueue(base)
448+
if err != nil {
449+
return false
450+
}
451+
return uses
452+
}
453+
417454
// mergeFuncs returns submit/poll closures that adapt the GitHub client to the
418455
// mergeview injection points.
419456
func mergeFuncs(client github.ClientOps) (mergeview.SubmitFunc, mergeview.PollFunc) {

cmd/merge_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,91 @@ func TestRunMerge_StackNumberArg(t *testing.T) {
131131
assert.Contains(t, output, "Merged #10, #11 into main")
132132
}
133133

134+
func TestRunMerge_MergeQueue_Headless(t *testing.T) {
135+
gotMethod := "unset"
136+
cfg, outR, errR := config.NewTestConfig()
137+
cfg.GitHubClientOverride = &github.MockClient{
138+
GetStackFn: func(n int) (*github.RemoteStack, error) {
139+
return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil
140+
},
141+
BaseBranchUsesMergeQueueFn: func(base string) (bool, error) {
142+
assert.Equal(t, "main", base)
143+
return true, nil
144+
},
145+
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
146+
gotMethod = method
147+
return &github.AsyncMergeResult{
148+
Status: github.AsyncMergeStatusEnqueued,
149+
Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."},
150+
}, nil
151+
},
152+
}
153+
154+
err := runMerge(cfg, fastOptions(), []string{"7"})
155+
output := collectOutput(cfg, outR, errR)
156+
157+
require.NoError(t, err)
158+
assert.Equal(t, "", gotMethod, "a merge queue picks the method; none is sent")
159+
assert.Contains(t, output, "merge queue")
160+
}
161+
162+
func TestRunMerge_MergeQueue_IgnoresMethodFlag(t *testing.T) {
163+
gotMethod := "unset"
164+
cfg, outR, errR := config.NewTestConfig()
165+
cfg.GitHubClientOverride = &github.MockClient{
166+
GetStackFn: func(n int) (*github.RemoteStack, error) {
167+
return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil
168+
},
169+
BaseBranchUsesMergeQueueFn: func(base string) (bool, error) { return true, nil },
170+
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
171+
gotMethod = method
172+
return &github.AsyncMergeResult{
173+
Status: github.AsyncMergeStatusEnqueued,
174+
Details: github.AsyncMergeDetails{Message: "queued"},
175+
}, nil
176+
},
177+
}
178+
179+
opts := fastOptions()
180+
opts.squash = true
181+
err := runMerge(cfg, opts, []string{"7"})
182+
output := collectOutput(cfg, outR, errR)
183+
184+
require.NoError(t, err)
185+
assert.Equal(t, "", gotMethod, "the requested method is ignored under a merge queue")
186+
assert.Contains(t, output, "ignoring the merge method")
187+
}
188+
189+
func TestRunMerge_MergeQueueDetectionError_FallsBackToDirect(t *testing.T) {
190+
gotMethod := "unset"
191+
cfg, outR, errR := config.NewTestConfig()
192+
cfg.GitHubClientOverride = &github.MockClient{
193+
GetStackFn: func(n int) (*github.RemoteStack, error) {
194+
return remoteStack(7, "main", openStackPR(10, "a"), openStackPR(11, "b")), nil
195+
},
196+
BaseBranchUsesMergeQueueFn: func(base string) (bool, error) {
197+
return false, errors.New("boom")
198+
},
199+
RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) {
200+
return &github.RepoMergeConfig{MergeAllowed: true, DefaultMethod: "merge"}, nil
201+
},
202+
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
203+
gotMethod = method
204+
return &github.AsyncMergeResult{
205+
Status: github.AsyncMergeStatusMerged,
206+
Details: github.AsyncMergeDetails{SHA: "abc1234"},
207+
}, nil
208+
},
209+
}
210+
211+
err := runMerge(cfg, fastOptions(), []string{"7"})
212+
output := collectOutput(cfg, outR, errR)
213+
214+
require.NoError(t, err)
215+
assert.Equal(t, "merge", gotMethod, "detection failure falls back to a direct merge with a method")
216+
assert.Contains(t, output, "Merged")
217+
}
218+
134219
func TestRunMerge_PRNumberArg(t *testing.T) {
135220
var gotPR int
136221
cfg, outR, errR := config.NewTestConfig()

internal/github/client_interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type ClientOps interface {
2121
MergeStackAsync(prNumber int, method string) (*AsyncMergeResult, error)
2222
GetAsyncMergeResult(prNumber int, uuid string) (*AsyncMergeResult, error)
2323
PRTitles(numbers []int) (map[int]string, error)
24+
BaseBranchUsesMergeQueue(baseRef string) (bool, error)
2425
}
2526

2627
// Compile-time check that Client satisfies ClientOps.

internal/github/merge_async.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,53 @@ func (c *Client) RepoMergeConfig() (*RepoMergeConfig, error) {
145145
}, nil
146146
}
147147

148+
// BaseBranchUsesMergeQueue reports whether the given base branch merges through a
149+
// merge queue, detected via the branch's merge queue object or a MERGE_QUEUE
150+
// repository rule. It is used only to tailor the merge wizard (skipping the
151+
// merge-method step and switching to "enqueue" wording): the async stack merge
152+
// itself always sends merge_action "default", which lets the server route the
153+
// stack to the queue or a direct merge automatically.
154+
func (c *Client) BaseBranchUsesMergeQueue(baseRef string) (bool, error) {
155+
var query struct {
156+
Repository struct {
157+
MergeQueue *struct {
158+
ID string `graphql:"id"`
159+
} `graphql:"mergeQueue(branch: $branch)"`
160+
Ref *struct {
161+
Rules struct {
162+
Nodes []struct {
163+
Type string `graphql:"type"`
164+
} `graphql:"nodes"`
165+
} `graphql:"rules(first: 50)"`
166+
} `graphql:"ref(qualifiedName: $qualified)"`
167+
} `graphql:"repository(owner: $owner, name: $name)"`
168+
}
169+
170+
variables := map[string]interface{}{
171+
"owner": graphql.String(c.owner),
172+
"name": graphql.String(c.repo),
173+
"branch": graphql.String(baseRef),
174+
"qualified": graphql.String("refs/heads/" + baseRef),
175+
}
176+
177+
if err := c.gql.Query("BaseBranchMergeQueue", &query, variables); err != nil {
178+
return false, fmt.Errorf("querying base branch merge queue: %w", err)
179+
}
180+
181+
r := query.Repository
182+
if r.MergeQueue != nil {
183+
return true, nil
184+
}
185+
if r.Ref != nil {
186+
for _, node := range r.Ref.Rules.Nodes {
187+
if node.Type == "MERGE_QUEUE" {
188+
return true, nil
189+
}
190+
}
191+
}
192+
return false, nil
193+
}
194+
148195
// MergeStackAsync requests an asynchronous merge of the given pull request. For
149196
// a stacked PR this merges all members of the stack up to and including
150197
// prNumber. A blank method lets the server apply its default.

internal/github/mock_client.go

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,24 @@ package github
44
// Each field is an optional function that, when set, handles the corresponding
55
// ClientOps method call. When nil, a reasonable default is returned.
66
type MockClient struct {
7-
FindPRForBranchFn func(string) (*PullRequest, error)
8-
FindPRByNumberFn func(int) (*PullRequest, error)
9-
FindPRDetailsForBranchFn func(string) (*PRDetails, error)
10-
CreatePRFn func(string, string, string, string, bool) (*PullRequest, error)
11-
UpdatePRBaseFn func(int, string) error
12-
MarkPRReadyForReviewFn func(string) error
13-
DisableAutoMergeFn func(string) error
14-
ListStacksFn func() ([]RemoteStack, error)
15-
FindStackForPRFn func(int) (*RemoteStack, error)
16-
GetStackFn func(int) (*RemoteStack, error)
17-
CreateStackFn func([]int) (*RemoteStack, error)
18-
AddToStackFn func(int, []int) (*RemoteStack, error)
19-
UnstackFn func(int) (*RemoteStack, bool, error)
20-
RepoMergeConfigFn func() (*RepoMergeConfig, error)
21-
MergeStackAsyncFn func(int, string) (*AsyncMergeResult, error)
22-
GetAsyncMergeResultFn func(int, string) (*AsyncMergeResult, error)
23-
PRTitlesFn func([]int) (map[int]string, error)
7+
FindPRForBranchFn func(string) (*PullRequest, error)
8+
FindPRByNumberFn func(int) (*PullRequest, error)
9+
FindPRDetailsForBranchFn func(string) (*PRDetails, error)
10+
CreatePRFn func(string, string, string, string, bool) (*PullRequest, error)
11+
UpdatePRBaseFn func(int, string) error
12+
MarkPRReadyForReviewFn func(string) error
13+
DisableAutoMergeFn func(string) error
14+
ListStacksFn func() ([]RemoteStack, error)
15+
FindStackForPRFn func(int) (*RemoteStack, error)
16+
GetStackFn func(int) (*RemoteStack, error)
17+
CreateStackFn func([]int) (*RemoteStack, error)
18+
AddToStackFn func(int, []int) (*RemoteStack, error)
19+
UnstackFn func(int) (*RemoteStack, bool, error)
20+
RepoMergeConfigFn func() (*RepoMergeConfig, error)
21+
MergeStackAsyncFn func(int, string) (*AsyncMergeResult, error)
22+
GetAsyncMergeResultFn func(int, string) (*AsyncMergeResult, error)
23+
PRTitlesFn func([]int) (map[int]string, error)
24+
BaseBranchUsesMergeQueueFn func(string) (bool, error)
2425
}
2526

2627
// Compile-time check that MockClient satisfies ClientOps.
@@ -162,3 +163,10 @@ func (m *MockClient) PRTitles(numbers []int) (map[int]string, error) {
162163
}
163164
return map[int]string{}, nil
164165
}
166+
167+
func (m *MockClient) BaseBranchUsesMergeQueue(baseRef string) (bool, error) {
168+
if m.BaseBranchUsesMergeQueueFn != nil {
169+
return m.BaseBranchUsesMergeQueueFn(baseRef)
170+
}
171+
return false, nil
172+
}

0 commit comments

Comments
 (0)