Skip to content

refactor(autoland): inject services everywhere and test with fakes instead of patches - #74

Merged
micahjsmith merged 1 commit into
micahjsmith/stack/7from
micahjsmith/stack/8
Oct 12, 2026
Merged

micahjsmith merged 1 commit into
micahjsmith/stack/7from
micahjsmith/stack/8

Conversation

@micahjsmith

@micahjsmith micahjsmith commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Stacked PRs:


Summary

Phase 6 of the autoland step-abstraction stack. Services becomes the real seam: everything a plan's execution does goes through the Services it is given, and the autoland tests inject fakes there instead of patching module attributes.

What now takes Services

  • waits.py: wait_for_approval, wait_for_checks, wait_for_mergeable, enqueue_and_wait, wait_for_workflow, and their helpers (_poll_read, _refresh_review, _rewait_after_retry) take a keyword services and use its github, ui, and sleep instead of gh.github, runtime.console, and runtime.resilient_sleep. _Ancestry(common, services) asks services.repo for local ancestry and the fetch, and services.github for the compare API.
  • native_stack.py: land_as_native_stack(..., services=), _native_stack_for, and _await_native_stack_merge use services.github, services.ui, and services.sleep. The waits before a stack merge go through services.waits, and the landed-SHA lookup and rebase through services.repo. StackMergeUnit passes uc.services.
  • New autoland/repo.py: GitRepo(github, ui), the production Repo. It holds what used to be waits.rebase_and_resubmit, waits._refresh_last_landed_sha (now landed_sha, which returns the SHA instead of writing it into a context), and waits._local_is_ancestor (now is_ancestor), plus fetch_target. The commands they run are unchanged. Repo gains fetch_target and is_ancestor.
  • engine.print_execution_preview takes the ui to print to.
  • commands._run_fresh, _run_resume, and _replan take an optional keyword services (default Services.production()), which they use for the preview and pass to _land. This is the composition root, and tests inject there instead of patching Services.production.

The Services shape: a frozen dataclass of github: gh.GitHub, ui: UI, sleep: Callable[[int], float], repo: Repo, and waits: Waits. waits defaults to PollingWaits(self), the real wait loops polling through the same services. It always rebinds to the Services it belongs to, so dataclasses.replace(services, github=...) gives waits that poll the new client. Waits stays a service (now with wait_for_mergeable, which the stack merge needs) because it is still the right seam for step-level and stack-merge tests: they check what a step or a unit does when a wait succeeds or fails, without scripting a GitHub to get there.

Services.production() binds the real objects directly: gh.github, ConsoleUI(runtime.console), runtime.resilient_sleep, and GitRepo(gh.github, ui), as they are when it's called. The call-time delegates (_LiveGitHub, _ModuleWaits, _GitRepo, _resilient_sleep) are gone.

Module singletons that remain, and why

  • runtime.console: the command flows' own output and prompts (commands), --status (status, display.print_status), plan file/editor messages (plan), worktree messages, and the shell runner, which echoes each command ($ gh ...) from inside runtime.run. The real gh.GitHub client runs through runtime.run, so its echo stays there as well. resilient_sleep also prints its wake-up notice there.
  • gh.github: plan parsing (plan.parse_plan's merged-PR check, PR-URL resolution in steps/land.py) and stack discovery (discovery.enrich_stack). All of these run before a plan executes.
  • runtime.resilient_sleep, runtime.HAVE_RICH, display.GLYPHS: production defaults and display settings. The golden harness patches these, and Services.production() picks up the patched sleep and console when the run starts.

Test fakes (new tests/autoland_fakes.py), consolidating the fakes that were spread over four files:

  • FakeUI (scripted answers, recorded output), and FakeWaits (outcome functions per wait; records calls and entries, waited(kind), the workflow steps and target SHAs).
  • FakeRepo: landed SHAs from a table, counted rebases (optionally failing) and fetches, plus known ancestry and what a fetch adds to it.
  • FakeGitHub: an in-memory GitHub (PR states, reviews, checks, merge commits, the merge queue, workflow runs, compare results, GitHub stacks), merging _FakeNativeStackGitHub from test_autoland.py. It logs every call (calls, called(method)), and script(method, *replies) answers the next calls with values, exceptions, or callables before it goes back to its state.
  • ForbiddenGitHub / forbidden_sleep, for tests where a step must not use them directly.
  • FakeSleep(limit=None): records sleeps, and after limit of them raises StopPolling to end a wait that is still waiting.
  • FakeServices: a dataclass of the fakes, with .services. FakeServices() uses FakeWaits, and FakeServices.polling(...) runs the real wait loops over the fakes. github can also be a real gh.GitHub() for tests that fake gh at the subprocess boundary.
  • OPTS and stack() move there too.

Patch counts (lines with mocker.patch / mock.patch / monkeypatch.setattr, before → after):

  • tests/test_autoland.py: 102 → 34. What remains: the subprocess boundary (shell_commands.run_with_retry via one _shell helper, subprocess.run, RETRY_DELAY, stack_pr.git.run_shell_command), stack-pr cli functions (deduce_base, command_submit, get_stack, get_current_branch_name), plan parsing and discovery outside the injected path (gh.github.owner_repo / pr_state, discovery.*), the command-flow seams (commands.Worktree / AutolandLock / _unpushed_changes, engine.execute_plan), and console/display singletons (runtime.console, HAVE_RICH, display.GLYPHS, sys.stdout).
  • tests/test_autoland_interrupt.py: 9 → 7. Services.production is no longer patched, in the tests or in the subprocess script. The git/cli stubs, enrich_stack, and Worktree remain.
  • tests/test_autoland_optimize.py: 4 → 0. StackMergeUnit tests now run the real land_as_native_stack against FakeServices. The preview tests print to a FakeUI.
  • tests/test_autoland_step_execution.py: 0 → 0. Its fakes moved to autoland_fakes.py.
  • tests/autoland_scenarios.py (golden harness): 12 → 12, unchanged. It still fakes at run_shell_command / get_command_output, so the real client runs.

How the migrated tests changed, each checking the same behavior:

  • Wait-loop tests run the real loops through FakeServices.polling(): workflow ancestry (local, after a fetch, then the compare API; verdict caching), approval by review decision, and failed/incomplete runs. They no longer patch gh.github.*, runtime.run, or runtime.resilient_sleep.
  • The failed-read tests still use the real gh.GitHub() with gh faked at the subprocess boundary. They no longer patch the sleep. The two merge-queue ones script the mergeable read and the enqueue instead of patching waits.wait_for_mergeable and gh.github.enqueue.
  • The native-stack engine tests run engine.execute_plan with FakeGitHub and FakeWaits (their one-at-a-time landing recorded as queue waits, and the rebases counted on FakeRepo), instead of 11 patches.
  • GitHub.poll_merge tests use a small subclass of the real client with a canned PR state, instead of patching the singleton's methods.
  • StackMergeUnit tests: "merged PRs" is now the stack-creation requests FakeGitHub received. A fall-through is a stack that can't be created. The stack-merge abort is a real rebase failure ("Rebase failed after merging Add 'land.style' option. modular/stack-pr#102: boom", so the test stack has a third PR above the run). "Not stack-merged" is checked as no GitHub calls and no output, instead of assert_not_called on a patched function.

No user-visible change. Console output, checkpoints, and status output are byte-for-byte identical.

Test plan

  • The golden autoland scenarios pass with no golden files regenerated.
  • Every migrated test checks the same behavior through injected fakes, and each autoland test file passes on its own.
  • New: dataclasses.replace of a Services's github gives waits that poll the new client and never the old one.
  • The full suite passes with an empty GH_CONFIG_DIR and GH_TOKEN unset.
  • Manual: run a real stack-pr autoland (with and without --merge-as-stack) on a small stack and check that the output and the --status report are unchanged.

🤖 Generated with Claude Code

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

…stead of patches

## Summary

Phase 6 of the autoland step-abstraction stack. `Services` becomes the real seam: everything a plan's execution does goes through the `Services` it is given, and the autoland tests inject fakes there instead of patching module attributes.

**What now takes `Services`**
- `waits.py`: `wait_for_approval`, `wait_for_checks`, `wait_for_mergeable`, `enqueue_and_wait`, `wait_for_workflow`, and their helpers (`_poll_read`, `_refresh_review`, `_rewait_after_retry`) take a keyword `services` and use its `github`, `ui`, and `sleep` instead of `gh.github`, `runtime.console`, and `runtime.resilient_sleep`. `_Ancestry(common, services)` asks `services.repo` for local ancestry and the fetch, and `services.github` for the compare API.
- `native_stack.py`: `land_as_native_stack(..., services=)`, `_native_stack_for`, and `_await_native_stack_merge` use `services.github`, `services.ui`, and `services.sleep`. The waits before a stack merge go through `services.waits`, and the landed-SHA lookup and rebase through `services.repo`. `StackMergeUnit` passes `uc.services`.
- New `autoland/repo.py`: `GitRepo(github, ui)`, the production `Repo`. It holds what used to be `waits.rebase_and_resubmit`, `waits._refresh_last_landed_sha` (now `landed_sha`, which returns the SHA instead of writing it into a context), and `waits._local_is_ancestor` (now `is_ancestor`), plus `fetch_target`. The commands they run are unchanged. `Repo` gains `fetch_target` and `is_ancestor`.
- `engine.print_execution_preview` takes the `ui` to print to.
- `commands._run_fresh`, `_run_resume`, and `_replan` take an optional keyword `services` (default `Services.production()`), which they use for the preview and pass to `_land`. This is the composition root, and tests inject there instead of patching `Services.production`.

**The `Services` shape**: a frozen dataclass of `github: gh.GitHub`, `ui: UI`, `sleep: Callable[[int], float]`, `repo: Repo`, and `waits: Waits`. `waits` defaults to `PollingWaits(self)`, the real wait loops polling through the same services. It always rebinds to the `Services` it belongs to, so `dataclasses.replace(services, github=...)` gives waits that poll the new client. `Waits` stays a service (now with `wait_for_mergeable`, which the stack merge needs) because it is still the right seam for step-level and stack-merge tests: they check what a step or a unit does when a wait succeeds or fails, without scripting a GitHub to get there.

**`Services.production()` binds the real objects directly**: `gh.github`, `ConsoleUI(runtime.console)`, `runtime.resilient_sleep`, and `GitRepo(gh.github, ui)`, as they are when it's called. The call-time delegates (`_LiveGitHub`, `_ModuleWaits`, `_GitRepo`, `_resilient_sleep`) are gone.

**Module singletons that remain, and why**
- `runtime.console`: the command flows' own output and prompts (`commands`), `--status` (`status`, `display.print_status`), plan file/editor messages (`plan`), worktree messages, and the shell runner, which echoes each command (`$ gh ...`) from inside `runtime.run`. The real `gh.GitHub` client runs through `runtime.run`, so its echo stays there as well. `resilient_sleep` also prints its wake-up notice there.
- `gh.github`: plan parsing (`plan.parse_plan`'s merged-PR check, PR-URL resolution in `steps/land.py`) and stack discovery (`discovery.enrich_stack`). All of these run before a plan executes.
- `runtime.resilient_sleep`, `runtime.HAVE_RICH`, `display.GLYPHS`: production defaults and display settings. The golden harness patches these, and `Services.production()` picks up the patched sleep and console when the run starts.

**Test fakes** (new `tests/autoland_fakes.py`), consolidating the fakes that were spread over four files:
- `FakeUI` (scripted answers, recorded output), and `FakeWaits` (outcome functions per wait; records `calls` and `entries`, `waited(kind)`, the workflow steps and target SHAs).
- `FakeRepo`: landed SHAs from a table, counted rebases (optionally failing) and fetches, plus known ancestry and what a fetch adds to it.
- `FakeGitHub`: an in-memory GitHub (PR states, reviews, checks, merge commits, the merge queue, workflow runs, compare results, GitHub stacks), merging `_FakeNativeStackGitHub` from `test_autoland.py`. It logs every call (`calls`, `called(method)`), and `script(method, *replies)` answers the next calls with values, exceptions, or callables before it goes back to its state.
- `ForbiddenGitHub` / `forbidden_sleep`, for tests where a step must not use them directly.
- `FakeSleep(limit=None)`: records sleeps, and after `limit` of them raises `StopPolling` to end a wait that is still waiting.
- `FakeServices`: a dataclass of the fakes, with `.services`. `FakeServices()` uses `FakeWaits`, and `FakeServices.polling(...)` runs the real wait loops over the fakes. `github` can also be a real `gh.GitHub()` for tests that fake `gh` at the subprocess boundary.
- `OPTS` and `stack()` move there too.

**Patch counts** (lines with `mocker.patch` / `mock.patch` / `monkeypatch.setattr`, before → after):
- `tests/test_autoland.py`: 102 → 34. What remains: the subprocess boundary (`shell_commands.run_with_retry` via one `_shell` helper, `subprocess.run`, `RETRY_DELAY`, `stack_pr.git.run_shell_command`), stack-pr cli functions (`deduce_base`, `command_submit`, `get_stack`, `get_current_branch_name`), plan parsing and discovery outside the injected path (`gh.github.owner_repo` / `pr_state`, `discovery.*`), the command-flow seams (`commands.Worktree` / `AutolandLock` / `_unpushed_changes`, `engine.execute_plan`), and console/display singletons (`runtime.console`, `HAVE_RICH`, `display.GLYPHS`, `sys.stdout`).
- `tests/test_autoland_interrupt.py`: 9 → 7. `Services.production` is no longer patched, in the tests or in the subprocess script. The git/cli stubs, `enrich_stack`, and `Worktree` remain.
- `tests/test_autoland_optimize.py`: 4 → 0. `StackMergeUnit` tests now run the real `land_as_native_stack` against `FakeServices`. The preview tests print to a `FakeUI`.
- `tests/test_autoland_step_execution.py`: 0 → 0. Its fakes moved to `autoland_fakes.py`.
- `tests/autoland_scenarios.py` (golden harness): 12 → 12, unchanged. It still fakes at `run_shell_command` / `get_command_output`, so the real client runs.

**How the migrated tests changed**, each checking the same behavior:
- Wait-loop tests run the real loops through `FakeServices.polling()`: workflow ancestry (local, after a fetch, then the compare API; verdict caching), approval by review decision, and failed/incomplete runs. They no longer patch `gh.github.*`, `runtime.run`, or `runtime.resilient_sleep`.
- The failed-read tests still use the real `gh.GitHub()` with `gh` faked at the subprocess boundary. They no longer patch the sleep. The two merge-queue ones script the mergeable read and the enqueue instead of patching `waits.wait_for_mergeable` and `gh.github.enqueue`.
- The native-stack engine tests run `engine.execute_plan` with `FakeGitHub` and `FakeWaits` (their one-at-a-time landing recorded as `queue` waits, and the rebases counted on `FakeRepo`), instead of 11 patches.
- `GitHub.poll_merge` tests use a small subclass of the real client with a canned PR state, instead of patching the singleton's methods.
- `StackMergeUnit` tests: "merged PRs" is now the stack-creation requests `FakeGitHub` received. A fall-through is a stack that can't be created. The stack-merge abort is a real rebase failure ("Rebase failed after merging modular#102: boom", so the test stack has a third PR above the run). "Not stack-merged" is checked as no GitHub calls and no output, instead of `assert_not_called` on a patched function.

No user-visible change. Console output, checkpoints, and status output are byte-for-byte identical.

## Test plan

- [x] The golden autoland scenarios pass with no golden files regenerated.
- [x] Every migrated test checks the same behavior through injected fakes, and each autoland test file passes on its own.
- [x] New: `dataclasses.replace` of a `Services`'s `github` gives waits that poll the new client and never the old one.
- [x] The full suite passes with an empty `GH_CONFIG_DIR` and `GH_TOKEN` unset.
- [ ] Manual: run a real `stack-pr autoland` (with and without `--merge-as-stack`) on a small stack and check that the output and the `--status` report are unchanged.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
stack-info: PR: #74, branch: micahjsmith/stack/8
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