Skip to content

Commit 5847fdd

Browse files
committed
update to support merge queue
1 parent f0ec7d5 commit 5847fdd

15 files changed

Lines changed: 139 additions & 177 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 (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`), with `BaseBranchPolicy` (GraphQL) gating merge-queue branches.
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.
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: 2 additions & 2 deletions
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 (18 methods)
38+
client_interface.go # ClientOps interface (17 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
@@ -109,7 +109,7 @@ if errors.As(err, &exitErr) { ... }
109109
### Key interfaces
110110

111111
- **`git.Ops`** (`internal/git/gitops.go`): 52 methods wrapping git CLI calls. The production implementation uses `cli/go-gh`'s `client.Command()` via `run()` and `runSilent()` helpers. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable.
112-
- **`github.ClientOps`** (`internal/github/client_interface.go`): 18 methods for GitHub API (PRs, stacks, merges). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL allowed methods + viewer default), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`); `BaseBranchPolicy` (GraphQL) reports whether the base branch requires a merge queue (unsupported). Injected via `cfg.GitHubClientOverride` in tests.
112+
- **`github.ClientOps`** (`internal/github/client_interface.go`): 17 methods for GitHub API (PRs, stacks, merges). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL: allowed merge methods + viewer's default), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`). Injected via `cfg.GitHubClientOverride` in tests.
113113
- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`).
114114

115115
### Stack file

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,9 +453,9 @@ With no argument, the current active local stack is used. Pass a stack number to
453453

454454
In an interactive terminal, a short wizard walks you through three steps — choose which PRs to merge, pick the merge method, and confirm. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting, using your last-used merge method unless one is specified.
455455

456-
Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Admin bypass is not supported** for stacked PR merges at this time.
456+
Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Bypassing merge requirements is not supported** for stacked PR merges.
457457

458-
Note that this command does not work with merge queues. If the stack's base branch uses a merge queue, use `gh pr merge` instead.
458+
If the base branch uses a merge queue, the stack is added to the queue and merges once the queue processes it; otherwise it's merged directly.
459459

460460
| Flag | Description |
461461
|------|-------------|

cmd/merge.go

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ prompting, using your last-used merge method unless one is specified.
6363
6464
Only basic pull request state is checked before merging (open and not a draft);
6565
GitHub evaluates branch protection and repository rules when the merge runs, so
66-
any such failure is reported back to you.
66+
any such failure is reported back to you. Bypassing merge requirements with admin
67+
privileges is not supported for stacks.
6768
68-
If the base branch uses a merge queue, this command isn't supported (it merges
69-
directly, not through the queue); use "gh pr merge" or the web UI instead.`,
69+
If the base branch uses a merge queue, the stack is added to the queue and merges
70+
once the queue processes it; otherwise it is merged directly.`,
7071
Example: ` # Merge the current stack (interactive picker)
7172
$ gh stack merge
7273
@@ -143,18 +144,6 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error {
143144

144145
base := remoteStack.Base.Ref
145146

146-
// Merge-queue and admin-bypass policy for the stack's base branch. The async
147-
// stack merge cannot use a merge queue, so bail out early (before any
148-
// prompting) when the base requires one.
149-
policy, err := client.BaseBranchPolicy(base)
150-
if err != nil {
151-
cfg.Errorf("failed to check base branch merge settings: %s", err)
152-
return ErrAPIFailure
153-
}
154-
if policy.RequiresMergeQueue {
155-
return explainMergeQueueUnsupported(cfg, base)
156-
}
157-
158147
if cfg.IsInteractive() && !opts.yes {
159148
return runMergeInteractive(cfg, client, remoteStack.Number, base, candidates, allowed, mergeCfg.DefaultMethod, method, preselectIndex, opts)
160149
}
@@ -328,6 +317,9 @@ func runMergeInteractive(cfg *config.Config, client github.ClientOps, stackNumbe
328317
case out.Merged:
329318
mergedSuccess(cfg, prNumberList(out.MergedPRs), base, out.SHA)
330319
return nil
320+
case out.Enqueued:
321+
enqueuedSuccess(cfg, prNumberList(out.MergedPRs), base)
322+
return nil
331323
case out.Failed:
332324
cfg.Errorf("merge failed: %s", out.Message)
333325
cfg.Printf("Stack merges are atomic, so nothing was merged.")
@@ -362,6 +354,10 @@ func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string,
362354
mergedSuccess(cfg, list, base, res.Details.SHA)
363355
return nil
364356
}
357+
if res.IsEnqueued() {
358+
enqueuedSuccess(cfg, list, base)
359+
return nil
360+
}
365361
if res.IsFailed() {
366362
cfg.Errorf("merge failed: %s", res.Details.Message)
367363
cfg.Printf("Stack merges are atomic, so nothing was merged.")
@@ -394,6 +390,10 @@ func runMergeHeadless(cfg *config.Config, client github.ClientOps, base string,
394390
mergedSuccess(cfg, list, base, status.Details.SHA)
395391
return nil
396392
}
393+
if status.IsEnqueued() {
394+
enqueuedSuccess(cfg, list, base)
395+
return nil
396+
}
397397
if status.IsFailed() {
398398
cfg.Errorf("merge failed: %s", status.Details.Message)
399399
cfg.Printf("Stack merges are atomic, so nothing was merged.")
@@ -430,6 +430,8 @@ func toMergeStatus(res *github.AsyncMergeResult) mergeview.MergeStatus {
430430
switch {
431431
case res.IsMerged():
432432
status = mergeview.StatusMerged
433+
case res.IsEnqueued():
434+
status = mergeview.StatusEnqueued
433435
case res.IsFailed():
434436
status = mergeview.StatusFailed
435437
}
@@ -504,15 +506,6 @@ func warnAsyncMergeUnavailable(cfg *config.Config) {
504506
cfg.Warningf("Async stack merge is not available for this repository")
505507
}
506508

507-
// explainMergeQueueUnsupported reports that the stack's base branch merges
508-
// through a merge queue, which the async stack merge cannot use, and points the
509-
// user to the merge queue (via `gh pr merge` or the web UI) instead.
510-
func explainMergeQueueUnsupported(cfg *config.Config, base string) error {
511-
cfg.Errorf("the base branch %q requires a merge queue, which \"gh stack merge\" does not support", base)
512-
cfg.Printf("Merge this stack using `%q` or from the GitHub web UI instead.", "gh pr merge")
513-
return ErrSilent
514-
}
515-
516509
// mergeFailureExit maps a merge failure message to an exit code: rebase/merge
517510
// conflicts get ErrConflict, everything else ErrAPIFailure.
518511
func mergeFailureExit(message string) error {
@@ -635,6 +628,13 @@ func mergedSuccess(cfg *config.Config, list, base, sha string) {
635628
cfg.Successf("Merged %s into %s", list, base)
636629
}
637630

631+
// enqueuedSuccess prints the success line when the base branch uses a merge
632+
// queue: the stack was added to the queue and will merge once it's processed.
633+
func enqueuedSuccess(cfg *config.Config, list, base string) {
634+
cfg.Successf("Added %s to the merge queue for %s", list, base)
635+
cfg.Printf("They will merge once the queue processes them.")
636+
}
637+
638638
func isNotFound(err error) bool {
639639
var httpErr *api.HTTPError
640640
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound

cmd/merge_test.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -347,28 +347,43 @@ func TestRunMerge_AlreadyMergedOnSubmit(t *testing.T) {
347347
assert.Contains(t, output, "Merged #1, #2 into main")
348348
}
349349

350-
func TestRunMerge_MergeQueueRequired(t *testing.T) {
350+
func TestRunMerge_Enqueued(t *testing.T) {
351351
cfg, outR, errR := config.NewTestConfig()
352352
cfg.GitHubClientOverride = &github.MockClient{
353353
GetStackFn: func(n int) (*github.RemoteStack, error) {
354354
return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil
355355
},
356-
BaseBranchPolicyFn: func(base string) (*github.BaseBranchPolicy, error) {
357-
assert.Equal(t, "main", base)
358-
return &github.BaseBranchPolicy{RequiresMergeQueue: true}, nil
356+
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
357+
return &github.AsyncMergeResult{Status: github.AsyncMergeStatusPending, Details: github.AsyncMergeDetails{UUID: "u"}}, nil
358+
},
359+
GetAsyncMergeResultFn: func(pr int, uuid string) (*github.AsyncMergeResult, error) {
360+
return &github.AsyncMergeResult{Status: github.AsyncMergeStatusEnqueued, Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."}}, nil
361+
},
362+
}
363+
364+
err := runMerge(cfg, fastOptions(), []string{"7"})
365+
output := collectOutput(cfg, outR, errR)
366+
367+
require.NoError(t, err)
368+
assert.Contains(t, output, "Added #1, #2 to the merge queue for main")
369+
}
370+
371+
func TestRunMerge_EnqueuedOnSubmit(t *testing.T) {
372+
cfg, outR, errR := config.NewTestConfig()
373+
cfg.GitHubClientOverride = &github.MockClient{
374+
GetStackFn: func(n int) (*github.RemoteStack, error) {
375+
return remoteStack(7, "main", openStackPR(1, "b1"), openStackPR(2, "b2")), nil
359376
},
360377
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
361-
t.Fatal("merge must not be attempted when the base requires a merge queue")
362-
return nil, nil
378+
return &github.AsyncMergeResult{Status: github.AsyncMergeStatusEnqueued, Details: github.AsyncMergeDetails{Message: "Pull request was added to the merge queue."}}, nil
363379
},
364380
}
365381

366382
err := runMerge(cfg, fastOptions(), []string{"7"})
367383
output := collectOutput(cfg, outR, errR)
368384

369-
assert.ErrorIs(t, err, ErrSilent)
370-
assert.Contains(t, output, "merge queue")
371-
assert.Contains(t, output, "web UI")
385+
require.NoError(t, err)
386+
assert.Contains(t, output, "Added #1, #2 to the merge queue for main")
372387
}
373388

374389
func TestRunMerge_AsyncMergeUnavailable(t *testing.T) {

docs/src/content/docs/guides/workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ gh stack merge --yes --squash
143143

144144
In an interactive terminal, a short wizard lets you choose how far up the stack to merge, pick the merge method (only the ones your repository allows, defaulting to your last-used method), and confirm — then shows live progress. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting. After merging, run `gh stack sync` to update your local branches.
145145

146-
The exception is a trunk that uses a merge queue: `gh stack merge` merges directly rather than through the queue, so it isn't supported there. Use `gh pr merge` or the GitHub web UI to merge through the queue instead.
146+
If the base branch uses a merge queue, `gh stack merge` adds the stack to the queue instead of merging directly — it merges once the queue processes it.
147147

148148
:::note[Admin bypass not supported]
149149
Stack merges currently do not support admin bypass merging.

docs/src/content/docs/reference/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,9 @@ With no argument, the current active local stack is used. Pass a stack number to
474474

475475
In an interactive terminal, a short wizard walks you through three steps — choose which PRs to merge, pick the merge method, and confirm. In a non-interactive terminal, or with `--yes`, the whole stack (or everything up to the given PR) is merged without prompting, using your last-used merge method unless one is specified.
476476

477-
Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Admin bypass is not supported** for stacked PR merges at this time.
477+
Only basic pull request state is checked before merging (open and not a draft); GitHub evaluates branch protection and repository rules when the merge runs, so any such failure is reported back to you. **Bypassing merge requirements is not supported** for stacked PR merges.
478478

479-
Note that this command does not work with merge queues. If the stack's base branch uses a merge queue, use `gh pr merge` instead.
479+
If the base branch uses a merge queue, the stack is added to the queue and merges once the queue processes it; otherwise it's merged directly.
480480

481481
| Flag | Description |
482482
|------|-------------|

internal/github/client_interface.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ 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-
BaseBranchPolicy(baseRef string) (*BaseBranchPolicy, error)
2524
}
2625

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

0 commit comments

Comments
 (0)