Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Top of tree

* `autoland` now works on branches without a merge queue. It asks GitHub
whether the target branch has one when it starts; without one, it
squash-merges each PR itself once the PR is approved, passing, and
mergeable, with the same commit message `stack-pr land` would use (the
commit's message without the stack-info trailer, numbered with the PR),
pinned to the commit it checked. A failed merge is retried after approval
and CI are checked again (`max_queue_retries`), and a branch that has fallen
behind a target that requires it to be up to date is reported and waited
on. A PR being merged this way shows as `Merging` rather than `In merge
queue`. `autoland.merge_queue` is no longer required: unset (the new
default), the mode is detected; `true` or `false` forces it. Runs on
branches with a merge queue behave as before.
* `autoland` plans have a new step, `x <command>`, which runs a command once
the PRs before it have landed and stops the land unless it exits 0 — for
automating what would otherwise be a manual `c` step, such as provisioning.
Expand Down
53 changes: 36 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ This will:
```
3. Repeat for each PR in the stack

**Option C**: Using `stack-pr autoland` (if your repo uses GitHub merge queue):
**Option C**: Using `stack-pr autoland`:
```bash
stack-pr autoland
```
Expand Down Expand Up @@ -169,11 +169,11 @@ That's it!

`stack-pr` also has several other commands:

- `autoland` - land the whole stack automatically through the GitHub merge
queue: wait for approvals and CI (retrying flaky checks), enqueue each PR
bottom-to-top, rebase and re-submit the rest after each merge, and resume
cleanly after an interruption. Requires a repo that uses the GitHub merge
queue (see `autoland.merge_queue` below).
- `autoland` - land the whole stack automatically: wait for approvals and CI
(retrying flaky checks), merge each PR bottom-to-top — through the GitHub
merge queue where the target branch has one, or directly otherwise — rebase
and re-submit the rest after each merge, and resume cleanly after an
interruption (see `autoland.merge_queue` below).
- `config` - set configuration values in the config file. Similar to `git config`,
it takes a setting in the format `<section>.<key>=<value>` and updates the
config file (`.stack-pr.cfg` by default).
Expand Down Expand Up @@ -543,16 +543,31 @@ PR onto the right commit directly with `stack-pr adopt --commit <ref>`.

#### autoland

Land the entire stack automatically through the GitHub merge queue, one PR at a
time from the bottom up. Run it from the repo root while on the stack's head
branch. For each PR it waits for approval and CI (re-running flaky checks),
adds the PR to the merge queue (retrying if the PR is booted), and after each
Land the entire stack automatically, one PR at a time from the bottom up. Run
it from the repo root while on the stack's head branch. For each PR it waits
for approval and CI (re-running flaky checks), merges the PR, and after each
merge rebases and re-submits the rest of the stack. Progress is checkpointed so
an interrupted run can be resumed.

> **Note**: `autoland` currently supports only repositories that use the GitHub
> merge queue. On other repositories it fails fast with a "not implemented"
> error; use `stack-pr land` instead. (Direct-merge autoland is future work.)
How a PR is merged depends on the target branch:

- **With a merge queue**, the PR is added to the queue, and `autoland` waits for
it to merge, re-adding it (after checking approval and CI again) if it is
booted. The queue merges with its own configured method.
- **Without one**, `autoland` squash-merges the PR itself once GitHub reports it
mergeable, with the same commit message `stack-pr land` uses: the commit's
message without the stack-info trailer, with ` (#<PR>)` after the subject.
The merge is pinned to the commit `autoland` checked, so a push in between
fails it rather than landing unchecked code. A failed merge is retried (up to
`max_queue_retries` times) after checking approval and CI again. If the
branch must be up to date with the target before merging and has fallen
behind, `autoland` says so and waits for you to rebase and `stack-pr submit`
(or to update the branch on GitHub), then carries on.

`autoland` asks GitHub which applies when it starts, and prints `Merging
directly into <target> (no merge queue)` when there is no queue. Set
`autoland.merge_queue` to skip the question (see below). If GitHub can't
answer and the config doesn't say, `autoland` stops before doing anything.

Options:

Expand Down Expand Up @@ -644,7 +659,10 @@ max_queue_retries = 3
default_workflow = deploy.yaml
```

- `merge_queue` (default `false`): must be `true` to enable `autoland`.
- `merge_queue` (default unset): whether the target branch merges through a
merge queue. Unset, `autoland` asks GitHub at the start of each run; `true`
or `false` skips the question and forces that mode — for when GitHub can't
say (e.g. the token can't read the branch's rules), or to skip the lookup.
- `required_checks` (default empty): comma-separated CI check names that gate a
merge. When empty, all reported (non-skipped) checks must pass.
- `default_workflow` (default empty): when set, an interactive (`-i`) landing
Expand All @@ -668,9 +686,10 @@ instead of one PR at a time:
just as when landing them one at a time.
3. It makes one merge request on the top PR of the run, which merges it and
every PR below it — all enqueued together in the merge queue, where the
repo has one. Each PR still lands as its own commit (made with the merge
queue's configured method, or squashed when there is no queue), and every
PR is closed as merged.
repo has one, or merged directly otherwise. Each PR still lands as its own
commit (made with the merge queue's configured method, or squashed when
there is no queue, with GitHub's default squash message rather than the
one `stack-pr land` uses), and every PR is closed as merged.
4. It rebases and re-submits the rest of the stack once, rather than after
every PR.

Expand Down
6 changes: 4 additions & 2 deletions src/stack_pr/autoland/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""stack-pr autoland: land a whole stack through the GitHub merge queue.
"""stack-pr autoland: land a whole stack, through the merge queue or directly.

This package holds the full autoland engine. cli.py only wires up the
subparser and dispatches into `run_autoland` to keep cli.py small.
Expand All @@ -14,6 +14,7 @@

Modules, roughly bottom of the dependency graph first:

- ``merge_mode``: whether a run merges through the merge queue or directly.
- ``options``: AutolandOptions, resolved from flags, config, and defaults.
- ``runtime``: the console, sleep/wake resilience, and the retrying ``run``.
- ``steps``: the AutolandStep base class, the registry of step types, and one
Expand All @@ -29,7 +30,8 @@
- ``plan``: generating, formatting, parsing, and replanning landing plans.
- ``display``: rendering a plan's progress.
- ``repo``: the local clone (fetch, ancestry, landed SHAs, rebase + resubmit).
- ``waits``: waiting for approval, checks, the merge queue, and workflows.
- ``waits``: waiting for approval, checks, a merge (queued or direct), and
workflows.
- ``native_stack``: merging a run of land steps as one GitHub stack.
- ``services``: the tools a plan's execution uses (console, GitHub, sleep,
the repo, the waits), and their production bindings.
Expand Down
49 changes: 38 additions & 11 deletions src/stack_pr/autoland/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import configparser
import dataclasses
import json
import os
import signal
Expand All @@ -12,8 +13,13 @@
from pathlib import Path

from stack_pr import cli, git
from stack_pr.autoland import discovery, engine, runtime
from stack_pr.autoland import discovery, engine, gh, runtime
from stack_pr.autoland.display import _describe_step, _escape_markup, print_status
from stack_pr.autoland.merge_mode import (
MergeModeError,
describe_merge_mode,
resolve_merge_mode,
)
from stack_pr.autoland.model import LandingContext, LandStep, PlanStep, StackEntry
from stack_pr.autoland.options import AutolandOptions
from stack_pr.autoland.plan import (
Expand Down Expand Up @@ -42,7 +48,10 @@ def register_parser(
"""Register the `autoland` subparser. Called from cli.create_argparser."""
p = subparsers.add_parser(
"autoland",
help="Land the whole stack through the GitHub merge queue",
help=(
"Land the whole stack: through the GitHub merge queue where the "
"target branch has one, else by merging each PR directly"
),
parents=[common_parser],
)
p.add_argument(
Expand All @@ -60,7 +69,10 @@ def register_parser(
"--max-queue-retries",
type=int,
default=None,
help="Max retries after a merge-queue boot (config: autoland.max_queue_retries).",
help=(
"Max retries after a merge-queue boot or a failed direct merge "
"(config: autoland.max_queue_retries)."
),
)
p.add_argument(
"--poll-interval",
Expand Down Expand Up @@ -185,14 +197,6 @@ def run_autoland(
runtime.console.print("[red]-o/--output only applies to --status.[/red]")
sys.exit(1)

# Merge-queue is the only supported strategy for now. Fail early otherwise.
if not opts.merge_queue:
raise NotImplementedError(
"stack-pr autoland currently supports only repositories that use "
"the GitHub merge queue. Enable it with:\n"
" stack-pr config autoland.merge_queue=true"
)

# A file plan replaces the whole plan, so --count (which only shapes the
# generated default) and --resume (which restores the plan from a
# checkpoint) have nothing to act on.
Expand All @@ -216,6 +220,8 @@ def run_autoland(
)
sys.exit(1)

opts = _resolve_merge_mode(common, opts)

if opts.replan:
_run_replan(common, opts)
return
Expand All @@ -225,6 +231,27 @@ def run_autoland(
_run_fresh(common, opts)


def _resolve_merge_mode(
common: cli.CommonArgs, opts: AutolandOptions
) -> AutolandOptions:
"""*opts* with the run's merge mode resolved, before anything lands.

Exits if the mode can't be resolved. Says which mode the run uses, unless
it is the merge queue the config asked for, the one mode autoland used to
have.
"""
try:
mode = resolve_merge_mode(common.target, gh.github, configured=opts.merge_queue)
except MergeModeError as e:
runtime.console.print(f"[red]{_escape_markup(str(e))}[/red]")
sys.exit(1)
if not (mode.uses_queue and opts.merge_queue):
runtime.console.print(
f"[bold]{_escape_markup(describe_merge_mode(mode, common.target))}[/bold]"
)
return dataclasses.replace(opts, merge_mode=mode)


def _dispose_worktree(
worktree: Worktree | None, opts: AutolandOptions, *, succeeded: bool
) -> None:
Expand Down
1 change: 1 addition & 0 deletions src/stack_pr/autoland/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def _icon(outcome: StepOutcome) -> str:
StepTone.AWAITING_CHECKS: "yellow",
StepTone.AWAITING_USER: "yellow",
StepTone.IN_QUEUE: "cyan",
StepTone.MERGING: "cyan",
StepTone.AWAITING_WORKFLOW: "blue",
StepTone.RUNNING: "blue",
}
Expand Down
40 changes: 40 additions & 0 deletions src/stack_pr/autoland/gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,46 @@ def enqueue(self, pr_number: int) -> None:
["gh", "pr", "merge", str(pr_number), "--squash"], quiet=False, retries=0
)

def head_sha(self, pr_number: int) -> str:
"""The commit at the head of the PR's branch."""
sha = self._pr_view(pr_number, "headRefOid").get("headRefOid")
if not isinstance(sha, str) or not sha:
raise GitHubError(f"No head commit reported for PR #{pr_number}")
return sha

def commit_message(self, sha: str) -> str:
"""The full message of commit *sha*."""
data = self._api("GET", f"commits/{sha}")
commit = data.get("commit") if isinstance(data, dict) else None
message = commit.get("message") if isinstance(commit, dict) else None
if not isinstance(message, str) or not message:
raise GitHubError(f"No commit message reported for {sha}")
return message

def squash_merge(
self, pr_number: int, *, subject: str, body: str, head_sha: str
) -> None:
"""Squash-merge the PR straight into its base branch, as *subject* and
*body*. GitHub refuses if the PR's head is no longer *head_sha*."""
runtime.run(
[
"gh",
"pr",
"merge",
str(pr_number),
"--squash",
"--match-head-commit",
head_sha,
"--subject",
subject,
"--body-file",
"-",
],
quiet=False,
input_data=body.encode(),
retries=0,
)

# GitHub's native stacked PRs. A stack is an explicit server-side object —
# GitHub does not infer one from a chain of PR bases — and once a PR is in
# one, only the asynchronous merge API can merge it. See
Expand Down
57 changes: 57 additions & 0 deletions src/stack_pr/autoland/merge_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""How a run merges its PRs into the target branch: the merge queue, or directly.

The mode is resolved once per run, before anything lands, and carried in
``AutolandOptions.merge_mode``. Everything that merges reads it from there —
landing one PR at a time (``waits.merge_and_wait``) and merging a run as a
GitHub stack (``native_stack``) — so they always agree.
"""

from __future__ import annotations

from enum import Enum
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from stack_pr.autoland.gh import GitHub


class MergeMode(str, Enum):
QUEUE = "queue" # add each PR to the target branch's merge queue
DIRECT = "direct" # squash-merge each PR straight into the target branch

@property
def uses_queue(self) -> bool:
return self is MergeMode.QUEUE


class MergeModeError(Exception):
"""The merge mode could not be resolved."""


def resolve_merge_mode(
target: str, github: GitHub, *, configured: bool | None
) -> MergeMode:
"""The run's merge mode.

*configured* is ``autoland.merge_queue``: ``True`` or ``False`` forces the
mode, and ``None`` (unset) asks GitHub whether *target* has a merge queue.
Raises ``MergeModeError`` when GitHub can't say.
"""
if configured is not None:
return MergeMode.QUEUE if configured else MergeMode.DIRECT
has_queue = github.has_merge_queue(target)
if has_queue is None:
raise MergeModeError(
f"Could not tell whether {target} uses a merge queue. Say which "
"with one of:\n"
" stack-pr config autoland.merge_queue=true\n"
" stack-pr config autoland.merge_queue=false"
)
return MergeMode.QUEUE if has_queue else MergeMode.DIRECT


def describe_merge_mode(mode: MergeMode, target: str) -> str:
"""One line saying how the run merges, for the start of its output."""
if mode.uses_queue:
return f"Merging into {target} through its merge queue"
return f"Merging directly into {target} (no merge queue)"
Loading
Loading