Repository navigation
refactor(autoland): inject services everywhere and test with fakes instead of patches - #74
Merged
Merged
Conversation
…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
micahjsmith
force-pushed
the
micahjsmith/stack/8
branch
from
October 12, 2026 23:05
11ecffe to
d40acaa
Compare
This was referenced Oct 12, 2026
micahjsmith
added this pull request to stack #77
October 12, 2026 23:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked PRs:
Summary
Phase 6 of the autoland step-abstraction stack.
Servicesbecomes the real seam: everything a plan's execution does goes through theServicesit is given, and the autoland tests inject fakes there instead of patching module attributes.What now takes
Serviceswaits.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 keywordservicesand use itsgithub,ui, andsleepinstead ofgh.github,runtime.console, andruntime.resilient_sleep._Ancestry(common, services)asksservices.repofor local ancestry and the fetch, andservices.githubfor the compare API.native_stack.py:land_as_native_stack(..., services=),_native_stack_for, and_await_native_stack_mergeuseservices.github,services.ui, andservices.sleep. The waits before a stack merge go throughservices.waits, and the landed-SHA lookup and rebase throughservices.repo.StackMergeUnitpassesuc.services.autoland/repo.py:GitRepo(github, ui), the productionRepo. It holds what used to bewaits.rebase_and_resubmit,waits._refresh_last_landed_sha(nowlanded_sha, which returns the SHA instead of writing it into a context), andwaits._local_is_ancestor(nowis_ancestor), plusfetch_target. The commands they run are unchanged.Repogainsfetch_targetandis_ancestor.engine.print_execution_previewtakes theuito print to.commands._run_fresh,_run_resume, and_replantake an optional keywordservices(defaultServices.production()), which they use for the preview and pass to_land. This is the composition root, and tests inject there instead of patchingServices.production.The
Servicesshape: a frozen dataclass ofgithub: gh.GitHub,ui: UI,sleep: Callable[[int], float],repo: Repo, andwaits: Waits.waitsdefaults toPollingWaits(self), the real wait loops polling through the same services. It always rebinds to theServicesit belongs to, sodataclasses.replace(services, github=...)gives waits that poll the new client.Waitsstays a service (now withwait_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, andGitRepo(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 insideruntime.run. The realgh.GitHubclient runs throughruntime.run, so its echo stays there as well.resilient_sleepalso prints its wake-up notice there.gh.github: plan parsing (plan.parse_plan's merged-PR check, PR-URL resolution insteps/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, andServices.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), andFakeWaits(outcome functions per wait; recordscallsandentries,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_FakeNativeStackGitHubfromtest_autoland.py. It logs every call (calls,called(method)), andscript(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 afterlimitof them raisesStopPollingto end a wait that is still waiting.FakeServices: a dataclass of the fakes, with.services.FakeServices()usesFakeWaits, andFakeServices.polling(...)runs the real wait loops over the fakes.githubcan also be a realgh.GitHub()for tests that fakeghat the subprocess boundary.OPTSandstack()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_retryvia one_shellhelper,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.productionis no longer patched, in the tests or in the subprocess script. The git/cli stubs,enrich_stack, andWorktreeremain.tests/test_autoland_optimize.py: 4 → 0.StackMergeUnittests now run the realland_as_native_stackagainstFakeServices. The preview tests print to aFakeUI.tests/test_autoland_step_execution.py: 0 → 0. Its fakes moved toautoland_fakes.py.tests/autoland_scenarios.py(golden harness): 12 → 12, unchanged. It still fakes atrun_shell_command/get_command_output, so the real client runs.How the migrated tests changed, each checking the same behavior:
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 patchgh.github.*,runtime.run, orruntime.resilient_sleep.gh.GitHub()withghfaked at the subprocess boundary. They no longer patch the sleep. The two merge-queue ones script the mergeable read and the enqueue instead of patchingwaits.wait_for_mergeableandgh.github.enqueue.engine.execute_planwithFakeGitHubandFakeWaits(their one-at-a-time landing recorded asqueuewaits, and the rebases counted onFakeRepo), instead of 11 patches.GitHub.poll_mergetests use a small subclass of the real client with a canned PR state, instead of patching the singleton's methods.StackMergeUnittests: "merged PRs" is now the stack-creation requestsFakeGitHubreceived. 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 ofassert_not_calledon a patched function.No user-visible change. Console output, checkpoints, and status output are byte-for-byte identical.
Test plan
dataclasses.replaceof aServices'sgithubgives waits that poll the new client and never the old one.GH_CONFIG_DIRandGH_TOKENunset.stack-pr autoland(with and without--merge-as-stack) on a small stack and check that the output and the--statusreport are unchanged.🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 5.5 (1M context) noreply@anthropic.com