diff --git a/src/stack_pr/autoland/__init__.py b/src/stack_pr/autoland/__init__.py index 002d164..0f553c4 100644 --- a/src/stack_pr/autoland/__init__.py +++ b/src/stack_pr/autoland/__init__.py @@ -17,7 +17,8 @@ - ``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 - module per type, each owning its plan syntax, persistence, and progress. + module per type, each owning its plan syntax, persistence, progress, view, + and execution; plus StepContext/StepResult, what a step executes with. - ``model``: the stack, plan steps, and LandingContext, and their (de)serialization. - ``state``: the checkpoint (``--resume``) and the per-branch lock. @@ -29,6 +30,8 @@ - ``display``: rendering a plan's progress. - ``waits``: waiting for approval, checks, the merge queue, and workflows. - ``native_stack``: merging a run of land steps as one GitHub stack. +- ``services``: the tools a step uses as it executes (console, GitHub, the + waits, the repo), and their production bindings. - ``engine``: executing a plan. - ``status``: the ``--status`` report. - ``commands``: the subparser, and the fresh / resume / replan flows. diff --git a/src/stack_pr/autoland/commands.py b/src/stack_pr/autoland/commands.py index 9d91ba5..2c2ea7d 100644 --- a/src/stack_pr/autoland/commands.py +++ b/src/stack_pr/autoland/commands.py @@ -27,6 +27,7 @@ parse_plan, plan_from_file, ) +from stack_pr.autoland.services import Services from stack_pr.autoland.state import AutolandCheckpointer, AutolandLock, _state_path from stack_pr.autoland.status import show_status from stack_pr.autoland.worktree import Worktree @@ -428,7 +429,9 @@ def _run_fresh(common: cli.CommonArgs, opts: AutolandOptions) -> None: checkpointer, worktree, opts, - success=engine.execute_plan(ctx, common, opts, checkpointer), + success=engine.execute_plan( + ctx, common, opts, checkpointer, services=Services.production() + ), ) finally: if worktree is not None: @@ -501,7 +504,9 @@ def _run_resume(common: cli.CommonArgs, opts: AutolandOptions) -> None: checkpointer, worktree, opts, - success=engine.execute_plan(ctx, common, opts, checkpointer), + success=engine.execute_plan( + ctx, common, opts, checkpointer, services=Services.production() + ), ) finally: if worktree is not None: @@ -723,7 +728,9 @@ def _replan(common: cli.CommonArgs, opts: AutolandOptions, state_path: Path) -> checkpointer, worktree, opts, - success=engine.execute_plan(ctx, common, opts, checkpointer), + success=engine.execute_plan( + ctx, common, opts, checkpointer, services=Services.production() + ), ) finally: if worktree is not None: diff --git a/src/stack_pr/autoland/display.py b/src/stack_pr/autoland/display.py index 199e9b6..ecff426 100644 --- a/src/stack_pr/autoland/display.py +++ b/src/stack_pr/autoland/display.py @@ -311,13 +311,3 @@ def _describe_step(step: PlanStep, ctx: LandingContext) -> str: differently. """ return step.view(ctx.stack, is_next=False, assumed_completed=False).description - - -def _next_steps_lines( - plan: list[PlanStep], from_index: int, ctx: LandingContext -) -> list[str]: - """Numbered, markup-safe descriptions of the plan steps after `from_index`.""" - return [ - f" {n}. {_escape_markup(_describe_step(s, ctx))}" - for n, s in enumerate(plan[from_index + 1 :], 1) - ] diff --git a/src/stack_pr/autoland/engine.py b/src/stack_pr/autoland/engine.py index 6fcc125..0d75bac 100644 --- a/src/stack_pr/autoland/engine.py +++ b/src/stack_pr/autoland/engine.py @@ -1,15 +1,24 @@ -"""Executing a landing plan step by step.""" +"""Executing a landing plan step by step. + +The engine owns the run: it walks the plan from ``ctx.current_step``, +checkpoints before each step, hands each step a ``StepContext``, records the +run-level facts a step reports in its ``StepResult``, and stops on an abort. +What a step does is up to the step (``AutolandStep.execute``). +""" from __future__ import annotations +import functools + from stack_pr import cli -from stack_pr.autoland import runtime, waits -from stack_pr.autoland.display import _escape_markup, _next_steps_lines -from stack_pr.autoland.model import ConfirmStep, LandingContext, LandStep, WorkflowStep +from stack_pr.autoland.display import _describe_step +from stack_pr.autoland.model import LandingContext from stack_pr.autoland.native_stack import land_as_native_stack, native_stack_run from stack_pr.autoland.options import AutolandOptions from stack_pr.autoland.plan import landed_prefix_end +from stack_pr.autoland.services import Services from stack_pr.autoland.state import AutolandCheckpointer +from stack_pr.autoland.steps import StepContext def execute_plan( @@ -17,169 +26,61 @@ def execute_plan( common: cli.CommonArgs, opts: AutolandOptions, checkpointer: AutolandCheckpointer, + *, + services: Services | None = None, ) -> bool: """Execute the landing plan from ctx.current_step. Returns True on success.""" - # A workflow checkpoint after the already-landed prefix of a plan should - # wait for the code of the last PR that prefix landed. Only that last one - # matters, so don't pay for a fetch + lookup on each of the others. - last_prelanded = landed_prefix_end(ctx.plan) - # The last step of a run whose stack merge fell through; the rest of that - # run lands one PR at a time rather than retrying the stack merge. - unstacked_through = -1 + if services is None: + services = Services.production() + prefix_end = landed_prefix_end(ctx.plan) + native_stack = _NativeStackMerge(ctx, common, opts, checkpointer) + + def upcoming(index: int) -> list[str]: + return [_describe_step(s, ctx) for s in ctx.plan[index + 1 :]] + + def landed_sha() -> str: + if not ctx.last_landed_sha: + _record_landed_sha(ctx, services.repo.landed_sha(common)) + return ctx.last_landed_sha for step_idx in range(ctx.current_step, len(ctx.plan)): step = ctx.plan[step_idx] ctx.current_step = step_idx checkpointer.save(ctx) - if isinstance(step, LandStep) and step.already_landed: - runtime.console.print( - f"\n[green]PR #{step.pr_number} already landed, skipping[/green]" - ) - if step_idx == last_prelanded: - waits._refresh_last_landed_sha(ctx, common, step.pr_number) - - elif isinstance(step, LandStep): - entry = ctx.stack[step.entry_index] - ctx.current_index = step.entry_index - - run_entries = ( - native_stack_run(ctx, step_idx) - if opts.merge_as_stack and step_idx > unstacked_through - else [] - ) - if run_entries: - result = land_as_native_stack( - run_entries, ctx=ctx, common=common, opts=opts - ) - if result.abort_reason: - return _abort(ctx, checkpointer, result.abort_reason) - if not result.landed: - unstacked_through = step_idx + len(run_entries) - 1 - checkpointer.save(ctx) - - if entry.is_merged(): - runtime.console.print( - f"\n[green]PR #{entry.pr_number} already merged, skipping[/green]" - ) - waits._refresh_last_landed_sha(ctx, common, entry.pr_number) - continue - - runtime.console.print( - f"\n{'=' * 60}\n[bold]Step {step_idx + 1}/{len(ctx.plan)}: " - f"Landing PR #{entry.pr_number} — {entry.title}[/bold]\n{'=' * 60}" + abort_reason = native_stack.before_step(step_idx) + if abort_reason: + return _abort(ctx, checkpointer, abort_reason) + + result = step.execute( + StepContext( + index=step_idx, + total=len(ctx.plan), + run=ctx, + opts=opts, + common=common, + services=services, + checkpoint=lambda: checkpointer.save(ctx), + upcoming=functools.partial(upcoming, step_idx), + landed_sha=landed_sha, + ends_landed_prefix=step_idx == prefix_end, ) - - if not waits.wait_for_approval(entry, opts=opts, ctx=ctx): - return _abort( - ctx, - checkpointer, - f"PR #{entry.pr_number} approval wait was aborted", - ) - - if entry.is_merged(): - waits._refresh_last_landed_sha(ctx, common, entry.pr_number) - else: - if not waits.wait_for_checks(entry, opts=opts, ctx=ctx): - return _abort( - ctx, - checkpointer, - f"PR #{entry.pr_number} checks failed after retries", - ) - - if entry.is_merged(): - waits._refresh_last_landed_sha(ctx, common, entry.pr_number) - else: - if not waits.enqueue_and_wait(entry, opts=opts, ctx=ctx): - return _abort( - ctx, - checkpointer, - f"PR #{entry.pr_number} failed to merge", - ) - waits._refresh_last_landed_sha(ctx, common, entry.pr_number) - - # Rebase + resubmit whenever commits remain above the one we just - # landed — not only when more *land steps* follow. On a partial - # land, the PRs we're leaving open still need their bases rebased - # onto the newly-landed commit. - has_commits_above = step.entry_index < len(ctx.stack) - 1 - if has_commits_above: - try: - waits.rebase_and_resubmit(common) - except Exception as e: # noqa: BLE001 - report any resubmit failure - return _abort( - ctx, - checkpointer, - f"Rebase failed after merging #{entry.pr_number}: {e}", - ) - - elif isinstance(step, WorkflowStep): - # "succeeded" before the loop reaches it means --replan carried the - # result over from the run this one replaced. - if step.state in ("skipped", "succeeded"): - continue - runtime.console.print( - f"\n{'=' * 60}\n[bold]Step {step_idx + 1}/{len(ctx.plan)}: " - f"Workflow checkpoint — {step.workflow}[/bold]\n{'=' * 60}" - ) - if not ctx.last_landed_sha: - waits._refresh_last_landed_sha(ctx, common) - if not waits.wait_for_workflow(step, opts=opts, common=common, ctx=ctx): - return _abort( - ctx, - checkpointer, - f"Workflow {step.workflow} failed or timed out", - ) - - elif isinstance(step, ConfirmStep): - if step.confirmed: - continue - question = ( - f'Confirm "{_escape_markup(step.condition)}" is complete — ' - "ready to proceed?" - if step.condition - else "Ready to proceed?" - ) - lines = [ - f"\n{'=' * 60}", - ( - f"[bold yellow]Step {step_idx + 1}/{len(ctx.plan)}: " - "Manual confirmation required[/bold yellow]" - ), - f"{'=' * 60}\n", - f"[bold]{question}[/bold]\n", - ] - next_lines = _next_steps_lines(ctx.plan, step_idx, ctx) - if next_lines: - lines.append("[bold]Next steps:[/bold]") - lines.extend(next_lines) - else: - lines.append("[dim]This is the final step in the plan.[/dim]") - runtime.console.print("\n".join(lines)) - while True: - try: - answer = runtime.console.input( - "[yellow]Type y/Y then Enter to continue " - "(Ctrl+C to abort): [/yellow]" - ).strip() - except EOFError: - return _abort( - ctx, - checkpointer, - "Confirm step received EOF — cannot confirm in " - "non-interactive mode", - ) - if answer in ("y", "Y"): - break - runtime.console.print("[dim]Type 'y' or 'Y' to confirm.[/dim]") - step.confirmed = True - runtime.console.print("[green]Confirmed[/green]") + ) + _record_landed_sha(ctx, result.landed_sha) + if result.aborted: + return _abort(ctx, checkpointer, result.abort_reason) ctx.current_step = len(ctx.plan) checkpointer.save(ctx) return True +def _record_landed_sha(ctx: LandingContext, sha: str) -> None: + # An unknown SHA ("") keeps the last one known. + if sha: + ctx.last_landed_sha = sha + + def _abort( ctx: LandingContext, checkpointer: AutolandCheckpointer, reason: str ) -> bool: @@ -187,3 +88,50 @@ def _abort( ctx.aborted = True checkpointer.save(ctx) return False + + +class _NativeStackMerge: + """TEMPORARY: merge a run of consecutive land steps as one GitHub stack. + + With ``--merge-as-stack``, before each step the engine asks whether a run + of two or more open land steps starts there, and if so lands the run with + one stack merge (``native_stack.land_as_native_stack``). The land steps + then find their PRs merged and only skip. A run whose stack merge fell + through is landed one step at a time instead, without retrying the merge. + + This lives in the engine, not in ``LandStep``, because steps are + independent of each other. It is a stopgap: a plan-optimization pass that + groups land steps into one execution unit replaces it. + """ + + def __init__( + self, + ctx: LandingContext, + common: cli.CommonArgs, + opts: AutolandOptions, + checkpointer: AutolandCheckpointer, + ) -> None: + self._ctx = ctx + self._common = common + self._opts = opts + self._checkpointer = checkpointer + # The last step of a run whose stack merge fell through. + self._unstacked_through = -1 + + def before_step(self, step_idx: int) -> str: + """Stack-merge the run starting at *step_idx*, if any. Returns the + reason the plan must stop, or "".""" + if not self._opts.merge_as_stack or step_idx <= self._unstacked_through: + return "" + run_entries = native_stack_run(self._ctx, step_idx) + if not run_entries: + return "" + result = land_as_native_stack( + run_entries, ctx=self._ctx, common=self._common, opts=self._opts + ) + if result.abort_reason: + return result.abort_reason + if not result.landed: + self._unstacked_through = step_idx + len(run_entries) - 1 + self._checkpointer.save(self._ctx) + return "" diff --git a/src/stack_pr/autoland/services.py b/src/stack_pr/autoland/services.py new file mode 100644 index 0000000..12d3eaa --- /dev/null +++ b/src/stack_pr/autoland/services.py @@ -0,0 +1,182 @@ +"""The tools a plan step may use while it executes, injected through ``Services``. + +A step reaches the console, GitHub, the wait loops, and the local repo only +through the ``Services`` in its ``StepContext``, never through module globals, +so a step can be run in isolation against fakes. + +``Services.production()`` binds these to today's implementations. Each one +looks its target up through the module attribute *at call time* (``runtime.console``, +``gh.github``, ``waits.wait_for_approval``, ...) rather than holding on to the +object it found at construction, so that tests which patch those attributes +keep intercepting every call a step makes. Once tests inject fake services +directly, the delegation can bind the objects themselves. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol, cast + +from stack_pr.autoland import display, gh, runtime, waits +from stack_pr.autoland.model import LandingContext + +if TYPE_CHECKING: + from stack_pr import cli + from stack_pr.autoland.model import StackEntry, WorkflowStep + from stack_pr.autoland.options import AutolandOptions + + +class UI(Protocol): + """Where a step talks to the user.""" + + def print(self, *objects: object) -> None: + """Show console markup (``[bold]...[/bold]``) to the user.""" + + def input(self, prompt: str) -> str: + """Ask the user for a line. Raises EOFError when there is no input.""" + + def escape(self, text: str) -> str: + """*text* made safe to embed in markup, so it shows literally.""" + + +class Waits(Protocol): + """The long-running waits of landing a PR or checking a workflow. + + Each returns whether it succeeded; False also when the run was aborted. + The waits record their progress on the entry or step they are given. + """ + + def wait_for_approval( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: ... + + def wait_for_checks( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: ... + + def enqueue_and_wait( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: ... + + def wait_for_workflow( + self, + step: WorkflowStep, + target_sha: str, + *, + opts: AutolandOptions, + common: cli.CommonArgs, + ctx: LandingContext, + ) -> bool: + """Wait for a successful run of the step's workflow at or after + *target_sha*.""" + + +class Repo(Protocol): + """The local clone and the stack-pr operations on it.""" + + def landed_sha(self, common: cli.CommonArgs, pr_number: int | None = None) -> str: + """The commit of the code landed so far: *pr_number*'s merge commit, + or else the head of the target branch. "" if it can't be found.""" + + def rebase_and_resubmit(self, common: cli.CommonArgs) -> None: + """Rebase the local stack onto the target and re-submit it. Raises on + failure.""" + + +@dataclass(frozen=True) +class Services: + """Everything a step may use beyond its ``StepContext``'s run state.""" + + github: gh.GitHub + ui: UI + waits: Waits + repo: Repo + # Sleep for some seconds, surviving system sleep; returns the time the + # machine spent suspended. + sleep: Callable[[int], float] + + @classmethod + def production(cls) -> Services: + """The real console, GitHub client, wait loops, and repo.""" + return cls( + github=cast("gh.GitHub", _LiveGitHub()), + ui=_ConsoleUI(), + waits=_ModuleWaits(), + repo=_GitRepo(), + sleep=_resilient_sleep, + ) + + +# --------------------------------------------------------------------------- +# Production implementations: thin delegates resolved at call time +# --------------------------------------------------------------------------- + + +class _LiveGitHub: + """Forwards every attribute to whatever ``gh.github`` is when it is used.""" + + def __getattr__(self, name: str) -> object: + return getattr(gh.github, name) + + +def _resilient_sleep(seconds: int) -> float: + return runtime.resilient_sleep(seconds) + + +class _ConsoleUI: + def print(self, *objects: object) -> None: + runtime.console.print(*objects) + + def input(self, prompt: str) -> str: + return runtime.console.input(prompt) + + def escape(self, text: str) -> str: + return display._escape_markup(text) + + +class _ModuleWaits: + def wait_for_approval( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: + return waits.wait_for_approval(entry, opts=opts, ctx=ctx) + + def wait_for_checks( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: + return waits.wait_for_checks(entry, opts=opts, ctx=ctx) + + def enqueue_and_wait( + self, entry: StackEntry, *, opts: AutolandOptions, ctx: LandingContext + ) -> bool: + return waits.enqueue_and_wait(entry, opts=opts, ctx=ctx) + + def wait_for_workflow( + self, + step: WorkflowStep, + target_sha: str, + *, + opts: AutolandOptions, + common: cli.CommonArgs, + ctx: LandingContext, + ) -> bool: + return waits.wait_for_workflow( + step, opts=opts, common=common, ctx=ctx, target_sha=target_sha + ) + + +class _GitRepo: + def landed_sha(self, common: cli.CommonArgs, pr_number: int | None = None) -> str: + # Goes through waits._refresh_last_landed_sha, which records into a + # context, so that tests patching it keep intercepting the lookup. A + # scratch context keeps the run's own state untouched: the engine + # records what the step reports. + scratch = LandingContext() + if pr_number is None: + waits._refresh_last_landed_sha(scratch, common) + else: + waits._refresh_last_landed_sha(scratch, common, pr_number) + return scratch.last_landed_sha + + def rebase_and_resubmit(self, common: cli.CommonArgs) -> None: + waits.rebase_and_resubmit(common) diff --git a/src/stack_pr/autoland/steps/__init__.py b/src/stack_pr/autoland/steps/__init__.py index 38273a8..296ea2f 100644 --- a/src/stack_pr/autoland/steps/__init__.py +++ b/src/stack_pr/autoland/steps/__init__.py @@ -16,6 +16,7 @@ step_from_dict, ) from stack_pr.autoland.steps.confirm import ConfirmStep +from stack_pr.autoland.steps.execution import StepContext, StepResult from stack_pr.autoland.steps.land import LandStep from stack_pr.autoland.steps.view import StepOutcome, StepTone, StepView from stack_pr.autoland.steps.workflow import WorkflowStep @@ -27,8 +28,10 @@ "LandStep", "PlanParser", "ReplanKey", + "StepContext", "StepOutcome", "StepRegistry", + "StepResult", "StepTone", "StepView", "WorkflowStep", diff --git a/src/stack_pr/autoland/steps/base.py b/src/stack_pr/autoland/steps/base.py index 05f5c9c..fe403fb 100644 --- a/src/stack_pr/autoland/steps/base.py +++ b/src/stack_pr/autoland/steps/base.py @@ -2,7 +2,8 @@ A step type owns everything that differs by kind of step: how it is spelled in a plan file, how it is stored in the state file, how it counts toward a run's -progress, and what it reports about itself for display (a ``StepView``). Code +progress, what it reports about itself for display (a ``StepView``), and how +it executes (given a ``StepContext``, returning a ``StepResult``). Code that walks a plan calls these methods instead of switching on the step's type, so adding a step type means adding a class here rather than a branch in every module that handles plans. @@ -18,6 +19,7 @@ if TYPE_CHECKING: from stack_pr.autoland.model import StackEntry + from stack_pr.autoland.steps.execution import StepContext, StepResult from stack_pr.autoland.steps.view import StepView # A workflow or confirm step's identity for replanning: its keyword, its text, @@ -133,6 +135,16 @@ def view( prefix (see ``plan.is_assumed_completed``). """ + # --- execution ----------------------------------------------------------- + + @abstractmethod + def execute(self, sc: StepContext) -> StepResult: + """Do the step's work, or nothing if it is already done. + + A step is independent of every other: it sees the run only through + *sc*, and uses only ``sc.services`` to reach the outside world. + """ + class StepRegistry: """The step types, looked up by state-file kind or by plan keyword.""" diff --git a/src/stack_pr/autoland/steps/confirm.py b/src/stack_pr/autoland/steps/confirm.py index a25eacd..e9c1aec 100644 --- a/src/stack_pr/autoland/steps/confirm.py +++ b/src/stack_pr/autoland/steps/confirm.py @@ -11,6 +11,7 @@ ReplanKey, register_step, ) +from stack_pr.autoland.steps.execution import StepContext, StepResult from stack_pr.autoland.steps.view import StepOutcome, StepTone, StepView if TYPE_CHECKING: @@ -87,3 +88,41 @@ def view( ), data={"condition": self.condition}, ) + + def execute(self, sc: StepContext) -> StepResult: + """Show what comes next, and wait for the user to type y or Y.""" + if self.confirmed: + return StepResult.done() + ui = sc.services.ui + question = ( + f'Confirm "{ui.escape(self.condition)}" is complete — ready to proceed?' + if self.condition + else "Ready to proceed?" + ) + lines = [ + sc.banner("Manual confirmation required", style="bold yellow") + "\n", + f"[bold]{question}[/bold]\n", + ] + upcoming = sc.upcoming() + if upcoming: + lines.append("[bold]Next steps:[/bold]") + lines.extend(f" {n}. {ui.escape(d)}" for n, d in enumerate(upcoming, 1)) + else: + lines.append("[dim]This is the final step in the plan.[/dim]") + ui.print("\n".join(lines)) + while True: + try: + answer = ui.input( + "[yellow]Type y/Y then Enter to continue (Ctrl+C to abort): " + "[/yellow]" + ).strip() + except EOFError: + return StepResult.abort( + "Confirm step received EOF — cannot confirm in non-interactive mode" + ) + if answer in ("y", "Y"): + break + ui.print("[dim]Type 'y' or 'Y' to confirm.[/dim]") + self.confirmed = True + ui.print("[green]Confirmed[/green]") + return StepResult.done() diff --git a/src/stack_pr/autoland/steps/execution.py b/src/stack_pr/autoland/steps/execution.py new file mode 100644 index 0000000..b4e3630 --- /dev/null +++ b/src/stack_pr/autoland/steps/execution.py @@ -0,0 +1,83 @@ +"""What a step is given when it executes (``StepContext``), and what it reports +back (``StepResult``). + +The engine owns the run: it walks the plan, checkpoints, records run-level +facts, and stops on an abort. A step owns only its own work. It reads the run +through its ``StepContext``, uses the tools in ``StepContext.services``, may +update its own fields (and the stack entry it lands), and reports the outcome +and any run-level facts in its ``StepResult``, which the engine records. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from stack_pr import cli + from stack_pr.autoland.model import LandingContext + from stack_pr.autoland.options import AutolandOptions + from stack_pr.autoland.services import Services + + +@dataclass(frozen=True) +class StepResult: + """How a step's execution ended. + + ``abort_reason`` is empty when the step succeeded (or had nothing to do), + and otherwise says why the run has to stop. ``landed_sha`` is the commit of + the code the step landed, or "" if it landed nothing or the commit could + not be found; the engine records it as the run's last landed SHA. + """ + + abort_reason: str = "" + landed_sha: str = "" + + @property + def aborted(self) -> bool: + return bool(self.abort_reason) + + @classmethod + def done(cls, *, landed_sha: str = "") -> StepResult: + return cls(landed_sha=landed_sha) + + @classmethod + def abort(cls, reason: str, *, landed_sha: str = "") -> StepResult: + if not reason: + raise ValueError("an aborted step needs a reason") + return cls(abort_reason=reason, landed_sha=landed_sha) + + +@dataclass(frozen=True) +class StepContext: + """What a step executing at plan position ``index`` may see and use.""" + + # The step's 0-based position in the plan, and the plan's length. + index: int + total: int + # The run's state. A step may update its own fields and the stack entry it + # lands, but reports run-level facts through its StepResult instead. + run: LandingContext + opts: AutolandOptions + common: cli.CommonArgs + services: Services + # Save the run's state now, so a resume picks up from here. + checkpoint: Callable[[], None] + # One-line descriptions of the steps after this one, in plan order. + upcoming: Callable[[], list[str]] + # The commit of the code landed so far: the last SHA a step reported, or, + # if none has been, the head of the target branch (which the engine then + # records as the run's landed SHA). "" if it can't be found. + landed_sha: Callable[[], str] + # Whether this step is the last of the plan's already-landed prefix (see + # ``plan.landed_prefix_end``), the one whose landed code later steps follow. + ends_landed_prefix: bool = False + + def banner(self, title: str, style: str = "bold") -> str: + """The markup heading this step's output: "Step i/n: