Skip to content

feat(autoland): land into branches without a merge queue - #79

Merged
micahjsmith merged 1 commit into
micahjsmith/stack/11from
micahjsmith/stack/12
Oct 13, 2026
Merged

micahjsmith merged 1 commit into
micahjsmith/stack/11from
micahjsmith/stack/12

Conversation

@micahjsmith

@micahjsmith micahjsmith commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Stacked PRs:


Summary

stack-pr autoland refused to run unless autoland.merge_queue=true, because it only knew how to land through the GitHub merge queue. That rules out dogfooding it on repos like this one, which have no queue. This adds a direct-merge mode.

Choosing the mode. The mode is worked out once per run, before anything lands, and stored in AutolandOptions.merge_mode (a new MergeMode enum: QUEUE / DIRECT, in autoland/merge_mode.py):

  • autoland.merge_queue is now an optional override. Setting it to true or false forces that mode and skips the GitHub lookup. Users who already set merge_queue=true see no change.
  • If it is unset (the new default), autoland asks GitHub whether the target branch has a merge queue (has_merge_queue).
  • If GitHub can't say and nothing is configured, autoland stops before doing anything and tells you which config to set.
  • A direct run prints Merging directly into <target> (no merge queue) at the start, including in --dry-run, --resume and --replan. A run whose queue was auto-detected prints the queue version of that line. A run configured with merge_queue=true prints nothing extra, so its output stays byte-identical.

One mode for both merge paths. The land step now calls Waits.merge_and_wait (renamed from enqueue_and_wait), which picks a path from opts.merge_mode:

  • Queue mode uses the unchanged enqueue_and_wait.
  • Direct mode uses the new merge_directly.

The GitHub stack merge reads the same mode. It sends direct_merge + squash in direct mode, and in that mode it never treats "not in the queue" as a boot.

What direct mode does:

  • Waits for approval and checks the same way queue mode does. Then waits until GitHub reports the PR mergeable (CLEAN / UNSTABLE / HAS_HOOKS). The queue-only "UNKNOWN means already queued" check is skipped.
  • BEHIND means the target branch requires PRs to be up to date and this one isn't. autoland says once what to do (rebase and stack-pr submit, or update the branch on GitHub) and keeps polling with a short status line until the branch is updated. It never pushes to the PR on its own.
  • Squash-merges with gh pr merge --squash --match-head-commit <head>. Pinning the head means a push after the checks passed makes the merge fail, instead of landing code nobody checked.
  • Confirms the PR is MERGED, polling briefly in case GitHub reports the merge late.
  • If gh errors, autoland first checks whether the PR merged anyway (for example, the response was lost). A real failure is retried up to max_queue_retries times, re-checking approval and CI before each retry, the same as queue mode. A closed PR fails the step.

Same squash commit as stack-pr land. The subject/body logic is moved out of cli.land_pr into cli.squash_commit_message, and land_pr now uses it. Direct-mode autoland reads the PR's head commit message from GitHub and builds the squash commit with this helper: the stack-info trailer is removed and (#N) is added to the subject. The queue path is unchanged, since the queue uses its own merge settings. Stack merges in direct mode go through the merge-async API, which doesn't accept a message, so they get GitHub's default squash message.

New state. A PR being merged directly is now PRState.MERGING (label "Merging", new StepTone.MERGING, drawn in cyan) instead of being mislabeled "In merge queue". Existing state values and STATE_VERSION are unchanged.

Queue mode is unchanged. All existing golden files pass without being regenerated. Queue-mode stack merges still ask GitHub whether the branch has a queue before choosing the request body, since merge_queue=true used to be required even on branches without one.

Docs: README (autoland description and requirements, merge_queue config, merging as a stack), CHANGELOG, and the autoland / --max-queue-retries help text are updated.

Test plan

  • New golden scenarios in tests/test_autoland_golden.py:
    • direct_one_at_a_time: no queue detected; two PRs land directly with a workflow step between them.
    • direct_merge_as_stack: a GitHub stack merge with direct_merge. The fake GitHub rejects a request body that doesn't match whether the branch has a queue.
    • direct_merge_retry: the first direct merge fails, then the retry succeeds.
    • merge_mode_unknown: the queue lookup fails and nothing is configured, so autoland exits early with the config hint.
    • The harness now handles runs that stop before writing a checkpoint.
  • New tests/test_autoland_merge_mode.py, using FakeServices:
    • Resolving the mode: the config override (true/false/unset), auto-detection (true/false), and detection failure.
    • The direct-merge wait: uses the stack-pr land squash message pinned to the head commit; queue mode still enqueues; a failed merge is retried after re-checking; gives up once retries run out; a merge that errored but went through; GitHub reporting the merge late; a closed PR; a BEHIND branch.
    • A direct stack merge isn't mistaken for a boot.
  • tests/test_autoland.py: the merge-queue gate test (test_run_autoland_requires_merge_queue) is replaced by two tests of run_autoland with no merge_queue config, using a fake GitHub and with the landing flows stubbed out so no real land can run:
    • The target has no queue, so the run goes ahead in direct mode and says so.
    • GitHub can't say, so autoland exits 1 with the config hint before any flow runs.
  • Manual: autoland a real stack on a repo without a merge queue (e.g. this stack on micahjsmith/stack-pr)

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com

## Summary

`stack-pr autoland` refused to run unless `autoland.merge_queue=true`, because it only knew how to land through the GitHub merge queue. That rules out dogfooding it on repos like this one, which have no queue. This adds a direct-merge mode.

**Choosing the mode.** The mode is worked out once per run, before anything lands, and stored in `AutolandOptions.merge_mode` (a new `MergeMode` enum: `QUEUE` / `DIRECT`, in `autoland/merge_mode.py`):
- `autoland.merge_queue` is now an optional override. Setting it to `true` or `false` forces that mode and skips the GitHub lookup. Users who already set `merge_queue=true` see no change.
- If it is unset (the new default), autoland asks GitHub whether the target branch has a merge queue (`has_merge_queue`).
- If GitHub can't say and nothing is configured, autoland stops before doing anything and tells you which config to set.
- A direct run prints `Merging directly into <target> (no merge queue)` at the start, including in `--dry-run`, `--resume` and `--replan`. A run whose queue was auto-detected prints the queue version of that line. A run configured with `merge_queue=true` prints nothing extra, so its output stays byte-identical.

**One mode for both merge paths.** The land step now calls `Waits.merge_and_wait` (renamed from `enqueue_and_wait`), which picks a path from `opts.merge_mode`:
- Queue mode uses the unchanged `enqueue_and_wait`.
- Direct mode uses the new `merge_directly`.

The GitHub stack merge reads the same mode. It sends `direct_merge` + squash in direct mode, and in that mode it never treats "not in the queue" as a boot.

**What direct mode does:**
- Waits for approval and checks the same way queue mode does. Then waits until GitHub reports the PR mergeable (`CLEAN` / `UNSTABLE` / `HAS_HOOKS`). The queue-only "UNKNOWN means already queued" check is skipped.
- `BEHIND` means the target branch requires PRs to be up to date and this one isn't. autoland says once what to do (rebase and `stack-pr submit`, or update the branch on GitHub) and keeps polling with a short status line until the branch is updated. It never pushes to the PR on its own.
- Squash-merges with `gh pr merge --squash --match-head-commit <head>`. Pinning the head means a push after the checks passed makes the merge fail, instead of landing code nobody checked.
- Confirms the PR is `MERGED`, polling briefly in case GitHub reports the merge late.
- If `gh` errors, autoland first checks whether the PR merged anyway (for example, the response was lost). A real failure is retried up to `max_queue_retries` times, re-checking approval and CI before each retry, the same as queue mode. A closed PR fails the step.

**Same squash commit as `stack-pr land`.** The subject/body logic is moved out of `cli.land_pr` into `cli.squash_commit_message`, and `land_pr` now uses it. Direct-mode autoland reads the PR's head commit message from GitHub and builds the squash commit with this helper: the stack-info trailer is removed and ` (#N)` is added to the subject. The queue path is unchanged, since the queue uses its own merge settings. Stack merges in direct mode go through the merge-async API, which doesn't accept a message, so they get GitHub's default squash message.

**New state.** A PR being merged directly is now `PRState.MERGING` (label "Merging", new `StepTone.MERGING`, drawn in cyan) instead of being mislabeled "In merge queue". Existing state values and `STATE_VERSION` are unchanged.

**Queue mode is unchanged.** All existing golden files pass without being regenerated. Queue-mode stack merges still ask GitHub whether the branch has a queue before choosing the request body, since `merge_queue=true` used to be required even on branches without one.

Docs: README (autoland description and requirements, `merge_queue` config, merging as a stack), CHANGELOG, and the `autoland` / `--max-queue-retries` help text are updated.

## Test plan

- [x] New golden scenarios in `tests/test_autoland_golden.py`:
  - `direct_one_at_a_time`: no queue detected; two PRs land directly with a workflow step between them.
  - `direct_merge_as_stack`: a GitHub stack merge with `direct_merge`. The fake GitHub rejects a request body that doesn't match whether the branch has a queue.
  - `direct_merge_retry`: the first direct merge fails, then the retry succeeds.
  - `merge_mode_unknown`: the queue lookup fails and nothing is configured, so autoland exits early with the config hint.
  - The harness now handles runs that stop before writing a checkpoint.
- [x] New `tests/test_autoland_merge_mode.py`, using FakeServices:
  - Resolving the mode: the config override (true/false/unset), auto-detection (true/false), and detection failure.
  - The direct-merge wait: uses the `stack-pr land` squash message pinned to the head commit; queue mode still enqueues; a failed merge is retried after re-checking; gives up once retries run out; a merge that errored but went through; GitHub reporting the merge late; a closed PR; a `BEHIND` branch.
  - A direct stack merge isn't mistaken for a boot.
- [x] `tests/test_autoland.py`: the merge-queue gate test (`test_run_autoland_requires_merge_queue`) is replaced by two tests of `run_autoland` with no `merge_queue` config, using a fake GitHub and with the landing flows stubbed out so no real land can run:
  - The target has no queue, so the run goes ahead in direct mode and says so.
  - GitHub can't say, so autoland exits 1 with the config hint before any flow runs.
- [ ] Manual: autoland a real stack on a repo without a merge queue (e.g. this stack on micahjsmith/stack-pr)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
stack-info: PR: #79, branch: micahjsmith/stack/12
@micahjsmith
micahjsmith added this pull request to stack #80 October 13, 2026 00:52
@micahjsmith
micahjsmith merged commit dd991e2 into main Oct 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant