diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a893680..3abb1901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ later entries are regular releases. previews and staged applies. Applying packaged starter defaults beside an existing config requires `--packaged-starter`, remediation prints exact preview/apply commands, and generated lane-config paths are unique (#1054). +- `doctor --adoption --orchestrator-only --repo OWNER/REPO` now works without a + checkout config through a labeled packaged-starter observer plan. It keeps + unselected local wrappers, providers, campaigns, Cloud upload, and pytest + checks quiet; omits checkout workflow checks when no checkout exists; keeps + repository Actions secret checks distinct; and removes local paths from this + share-oriented report (#1053). - Hosted-agent installation guidance now includes a Python-based uv bootstrap that does not pipe a remote script into a shell. Remote-only orchestrators get an explicit packaged-starter doctor command, doctor JSON is identified as diff --git a/docs/orchestrator-prompt-pack.md b/docs/orchestrator-prompt-pack.md index de991ca3..d923069a 100644 --- a/docs/orchestrator-prompt-pack.md +++ b/docs/orchestrator-prompt-pack.md @@ -74,15 +74,25 @@ Run the posture-appropriate doctor: - local reviewer/builder machine: code-mower doctor --adoption --repo OWNER/REPO --json - hosted builder or observer: code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json - orchestrator-only host: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json -- remote-only host with no checkout config: code-mower doctor --easy --orchestrator-only --repo OWNER/REPO --json +- remote-only host with no checkout config: code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json - supervised pilot readiness: code-mower doctor --supervised-pilot --repo OWNER/REPO --json Read the posture-scoped summary first by rerunning the same posture with --concise instead of --json: every check still runs, and the summary leads with active failures and owner actions. Keep the --json output as the complete machine-readable local evidence, and use --advanced for the full text list. -Doctor JSON can contain local config, executable, and workflow paths; review or -redact it before attaching or uploading it. +When the orchestrator-only command has no repository config, doctor labels the +result as a `packaged_starter_remote_observer` plan. It omits local wrapper, +provider-login, campaign, optional Cloud, and product-test checks unless they +were selected explicitly. With no checkout, it also omits workflow-presence +checks. Repository Actions secret and variable presence still comes from the +target repository through `gh`; that is separate from `GITHUB_TOKEN` or local +checkout-path requirements used by direct local-audit wrappers. + +That packaged-starter remote-observer JSON uses stable package labels and does +not include local config, executable, checkout, or workflow paths. Other doctor +JSON can contain local paths; review or redact it before attaching or uploading +it. Provider selection and a working CLI do not qualify an orchestrator. Devin is currently limited to bounded builder work and informational review; use a diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 039bf37d..b2640360 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -177,6 +177,21 @@ doctor verifies them from the current checkout. With a real repository `code-mower.yml`, missing configured clear-stale workflows are failures because the gate would otherwise be able to trust stale review evidence. +An orchestrator-only observer without a checkout can use: + +```bash +code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json +``` + +Doctor uses the packaged-starter remote-observer plan in that case. Checkout +workflow paths, direct local-audit wrapper credentials and repo-path mappings, +unselected campaign providers, optional Cloud upload, and product-side pytest +are out of scope and stay out of the report. Repository Actions secret and +variable presence remains in scope because doctor reads metadata for the +explicit `OWNER/REPO`. If repository metadata is inaccessible, fix the one +`github.repo.metadata` remediation first: verify that `gh auth` can read the +target, including private-repository access when needed. + ## Python Is Too Old Use the checked-in developer wrapper instead of bare `python3`: diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md index a29cd5eb..fd19a6d4 100644 --- a/docs/try-in-10-minutes.md +++ b/docs/try-in-10-minutes.md @@ -156,6 +156,15 @@ Warnings are setup guidance. They are only fatal when you pass `--strict`. In JSON mode, check the top-level `run_plan` field first. It tells you whether the preflight included GitHub and optional cloud checks before you inspect individual provider warnings. + +For a remote observer that has no checkout or `code-mower.yml`, use +`code-mower doctor --adoption --orchestrator-only --repo "$REPO" --json`. +That posture uses a labeled packaged-starter observer plan and reports the +target repository's metadata, Actions settings, secrets, and variables without +requiring local reviewer tools or publishing local filesystem paths. Add +`--cloud`, `--campaign`, or another optional selector only when that capability +is deliberately part of the observation. + Use `--hosted-builders` or `--orchestrator-only` when this machine observes or coordinates lanes without running Codex/Claude local audit wrappers; those postures skip local-wrapper probes and keep missing local wrapper env vars out diff --git a/src/code_mower/doctor.py b/src/code_mower/doctor.py index 028482a6..a5917321 100644 --- a/src/code_mower/doctor.py +++ b/src/code_mower/doctor.py @@ -156,6 +156,8 @@ def _doctor_config_error_message(exc: Exception, *, config_arg: str) -> str: def main(argv: Sequence[str] | None = None) -> int: # Slack has a deliberately separate output boundary: generic doctor output # includes repository paths and provider details unsuitable for this surface. + # Preserve the original argv once: later defaults must distinguish an + # omitted flag from an explicit false-by-default argparse value. raw_args = list(sys.argv[1:] if argv is None else argv) if "--slack" in raw_args: from code_mower import slack_setup @@ -373,10 +375,27 @@ def main(argv: Sequence[str] | None = None) -> int: args.adoption = True if args.adoption: args.preflight = True + cloud_explicit = "--cloud" in raw_args + runtime_probe_explicit = "--probe-runtime" in raw_args + explicit_config = args.config is not None _apply_first_run_defaults(args) + configless_starter_requested = args.packaged_starter or ( + not explicit_config and not Path("code-mower.yml").is_file() + ) + if ( + args.adoption_posture == "orchestrator-only" + and configless_starter_requested + ): + # The adoption preset predates observer postures and expands to local + # runtime plus optional Cloud checks. Those are useful on a reviewer + # host, but an orchestrator only needs the repository-facing checks it + # selected. Preserve deliberate additions to the command line. + if not cloud_explicit: + args.cloud = False + if not runtime_probe_explicit: + args.probe_runtime = False if args.easy and args.profile is None: args.profile = "recommended" - explicit_config = args.config is not None if args.config is None: args.config = "code-mower.yml" if args.packaged_starter and explicit_config: diff --git a/src/code_mower/doctor_checks/adoption.py b/src/code_mower/doctor_checks/adoption.py index b018fbde..9bfd13de 100644 --- a/src/code_mower/doctor_checks/adoption.py +++ b/src/code_mower/doctor_checks/adoption.py @@ -229,6 +229,8 @@ def check_adoption_setup( repo_root: Path | None = None, trusted_author_variables: Mapping[str, str] | None = None, trusted_author_variable_errors: Mapping[str, Any] | None = None, + observer_plan: bool = False, + checkout_present: bool = True, ) -> tuple[DoctorCheck, ...]: """Return first-run adoption posture checks.""" @@ -300,18 +302,40 @@ def check_adoption_setup( else [] ) detail = { - "config_path": str(config_path), + "config_path": "packaged-starter" if observer_plan else str(config_path), "config_source": source, - "config_source_state": source_state, - "configured_repositories": repositories, + "config_source_state": "observer_plan" if observer_plan else source_state, + "configured_repositories": [] if observer_plan else repositories, "effective_repository": repo_slug, "repository_config_present": not using_packaged_example, "generated_setup_detected": bool(generated_marker_types), "generated_setup_marker_types": generated_marker_types, } - if next_steps: + if observer_plan: + detail.update( + { + "plan": "packaged_starter_remote_observer", + "adoption_posture": "orchestrator-only", + "checkout_present": checkout_present, + "checkout_checks": "enabled" if checkout_present else "not_applicable", + "local_wrapper_checks": "not_selected", + } + ) + if next_steps and not observer_plan: detail["next_steps"] = next_steps - if using_packaged_example: + if observer_plan: + checks.append( + DoctorCheck( + name="doctor.adoption.config_source", + status=STATUS_PASS, + message=( + "using packaged-starter remote-observer plan; a local " + "repository config is not required" + ), + detail=detail, + ) + ) + elif using_packaged_example: checks.append( DoctorCheck( name="doctor.adoption.config_source", diff --git a/src/code_mower/doctor_checks/github.py b/src/code_mower/doctor_checks/github.py index 05615c86..4eb018f9 100644 --- a/src/code_mower/doctor_checks/github.py +++ b/src/code_mower/doctor_checks/github.py @@ -39,6 +39,7 @@ def check_github_setup( adoption: bool = False, adoption_posture: str = "reviewer-gate", pilot_mode: str = "manual", + shareable: bool = False, ) -> list[DoctorCheck]: checks: list[DoctorCheck] = [] gh_path = shutil.which("gh") @@ -61,7 +62,11 @@ def check_github_setup( name="github.cli", status=STATUS_PASS, message="gh found for GitHub setup checks", - detail={"gh_path": gh_path}, + detail=( + {"command": "gh", "available": True} + if shareable + else {"gh_path": gh_path} + ), ) ) diff --git a/src/code_mower/doctor_checks/provider_review_hygiene.py b/src/code_mower/doctor_checks/provider_review_hygiene.py index 00b6229f..b63c3c8d 100644 --- a/src/code_mower/doctor_checks/provider_review_hygiene.py +++ b/src/code_mower/doctor_checks/provider_review_hygiene.py @@ -22,6 +22,7 @@ def check_review_hygiene( effective_lane: Mapping[str, Any] | None = None, repo_root: Path | None = None, missing_workflow_is_warning: bool = False, + shareable: bool = False, ) -> DoctorCheck: """Verify that merge-authority lanes have stale terminal-label protection.""" @@ -126,24 +127,27 @@ def check_review_hygiene( "root, commit the generated clear-stale workflow, then rerun " "doctor before relying on this lane as merge authority." ) + missing_detail = { + **detail, + "workflow_exists": False, + "missing_workflow_is_warning": missing_workflow_is_warning, + } + if not shareable: + missing_detail["workflow_path"] = str(workflow_path) return DoctorCheck( name="provider.review_hygiene", status=status, lane=lane_id, message=message, - detail={ - **detail, - "workflow_exists": False, - "workflow_path": str(workflow_path), - "missing_workflow_is_warning": missing_workflow_is_warning, - }, + detail=missing_detail, remediation=remediation, ) detail = { **detail, "workflow_exists": True, - "workflow_path": str(workflow_path), } + if not shareable: + detail["workflow_path"] = str(workflow_path) return DoctorCheck( name="provider.review_hygiene", diff --git a/src/code_mower/doctor_checks/providers.py b/src/code_mower/doctor_checks/providers.py index b4ff0561..08f806ee 100644 --- a/src/code_mower/doctor_checks/providers.py +++ b/src/code_mower/doctor_checks/providers.py @@ -210,17 +210,23 @@ def check_lane_runtime( http_timeout: int, adoption_posture: str = "reviewer-gate", missing_workflow_is_warning: bool = False, + check_checkout_files: bool = True, + quiet_local_skips: bool = False, + shareable: bool = False, ) -> list[DoctorCheck]: hygiene_source = source_lane if source_lane is not None else lane - checks = [ - check_review_hygiene( - lane_id, - hygiene_source, - effective_lane=lane, - repo_root=repo_root, - missing_workflow_is_warning=missing_workflow_is_warning, + checks = [] + if check_checkout_files: + checks.append( + check_review_hygiene( + lane_id, + hygiene_source, + effective_lane=lane, + repo_root=repo_root, + missing_workflow_is_warning=missing_workflow_is_warning, + shareable=shareable, + ) ) - ] try: transport = lane_transport(lane_id, hygiene_source) except code_mower_config.ConfigError as exc: @@ -247,13 +253,14 @@ def check_lane_runtime( checks.extend(check_required_env(lane_id, lane)) if driver == "local_cli": if skip_local_cli_runtime: - checks.extend( - _skip_local_cli_checks( - lane_id, - lane, - adoption_posture=adoption_posture, + if not quiet_local_skips: + checks.extend( + _skip_local_cli_checks( + lane_id, + lane, + adoption_posture=adoption_posture, + ) ) - ) return checks checks.extend( check_local_audit_wrapper_setup( diff --git a/src/code_mower/doctor_checks/runner.py b/src/code_mower/doctor_checks/runner.py index 8a9d705a..f8edd287 100644 --- a/src/code_mower/doctor_checks/runner.py +++ b/src/code_mower/doctor_checks/runner.py @@ -4,6 +4,7 @@ from pathlib import Path import shutil +import subprocess from typing import Any, Mapping from code_mower import config as code_mower_config @@ -53,7 +54,13 @@ def _global_runtime_checks( *, probe_runtime: bool, http_timeout: int, + observer_plan: bool = False, ) -> tuple[DoctorCheck, ...]: + if observer_plan: + # An observer is already running Code Mower successfully. Product test + # tools, reviewer conveniences, and self-hosted runner state are not + # prerequisites for repository observation. + return () return ( check_python_runtime(), check_pytest(), @@ -82,11 +89,15 @@ def _run_plan_check( probe_runtime: bool, actions_cost_sample: int, adoption_posture: str, + observer_plan: bool = False, ) -> DoctorCheck: + plan_name = ( + "packaged-starter remote-observer plan" if observer_plan else "doctor run plan" + ) return DoctorCheck( name="doctor.plan", status=STATUS_PASS, - message="doctor run plan: " + ", ".join(stage.id for stage in plan), + message=plan_name + ": " + ", ".join(stage.id for stage in plan), detail={ "stages": [ { @@ -99,6 +110,9 @@ def _run_plan_check( "probe_runtime": probe_runtime, "actions_cost_sample": actions_cost_sample, "adoption_posture": adoption_posture, + "plan": ( + "packaged_starter_remote_observer" if observer_plan else "standard" + ), }, ) @@ -130,6 +144,7 @@ def run_doctor( context_online: bool = False, context_state_dir: Path | None = None, board_startup_grace: Any = None, + checkout_present: bool | None = None, ) -> DoctorReport: config, templates, checks = load_inputs(config_path, provider_templates_path) # Devin is an explicit addition to the Claude + Codex default, so its stage @@ -143,15 +158,6 @@ def run_doctor( devin_ambiguous = devin_transport is None and bool( devin_selection_ambiguity(config, lanes=devin_lanes, profile=profile) ) - plan = build_doctor_run_plan( - github=github, - cloud=cloud, - runner=runner, - adoption=adoption or bool(repo_slug), - devin=devin or bool(devin_transport) or devin_ambiguous, - supervised_pilot=supervised_pilot, - ) - enabled_stages = {stage.id for stage in plan} if isinstance(config, Mapping) and config.get('context') is not None: from ..context_readiness import inspect_connection readiness = inspect_connection(config['context'], state_dir=context_state_dir, @@ -161,6 +167,42 @@ def run_doctor( checks.append(DoctorCheck('context.readiness', context_status, readiness['message'], detail=readiness, remediation=readiness['next_action'])) using_packaged_example = config_path.name == "code-mower.example.yml" + packaged_observer_plan = ( + adoption + and adoption_posture == "orchestrator-only" + and using_packaged_example + and config_source in {"", "packaged_starter", "source_tree_starter"} + ) + if checkout_present is None: + try: + checkout_probe = subprocess.run( + ["git", "-C", str(Path.cwd()), "rev-parse", "--is-inside-work-tree"], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + checkout_present = False + else: + checkout_present = ( + checkout_probe.returncode == 0 + and checkout_probe.stdout.strip().lower() == "true" + ) + plan = build_doctor_run_plan( + github=github, + cloud=cloud, + runner=runner, + adoption=adoption or bool(repo_slug), + devin=devin or bool(devin_transport) or devin_ambiguous, + supervised_pilot=supervised_pilot, + ) + if packaged_observer_plan: + quiet_stages = {"runtime"} + if not checkout_present: + quiet_stages.add("providers") + plan = tuple(stage for stage in plan if stage.id not in quiet_stages) + enabled_stages = {stage.id for stage in plan} # `doctor --easy` can inspect the packaged example before a repo has written # code-mower.yml. In that mode the example should teach the user about stale # guards without failing because product workflow files are not installed @@ -207,6 +249,8 @@ def run_doctor( repo_root=Path.cwd() if using_packaged_example else config_path.parent, trusted_author_variables=trusted_author_variables, trusted_author_variable_errors=trusted_author_variable_errors, + observer_plan=packaged_observer_plan, + checkout_present=checkout_present, ) ) if config is not None and repo_slug: @@ -217,6 +261,7 @@ def run_doctor( probe_runtime=probe_runtime, actions_cost_sample=actions_cost_sample, adoption_posture=adoption_posture, + observer_plan=packaged_observer_plan, ) ) if config is None or templates is None: @@ -267,6 +312,7 @@ def run_doctor( _global_runtime_checks( probe_runtime=probe_runtime, http_timeout=http_timeout, + observer_plan=packaged_observer_plan, ) ) @@ -306,6 +352,11 @@ def run_doctor( adoption_posture=adoption_posture, probe_runtime=probe_runtime, http_timeout=http_timeout, + check_checkout_files=( + not packaged_observer_plan or bool(checkout_present) + ), + quiet_local_skips=packaged_observer_plan, + shareable=packaged_observer_plan, ) ) if "devin-readiness" in enabled_stages: @@ -334,7 +385,7 @@ def run_doctor( ) ) - if adoption: + if adoption and (not packaged_observer_plan or campaign): from code_mower import release_campaigns checks.extend( @@ -356,7 +407,7 @@ def run_doctor( ) ) - if adoption: + if adoption and not packaged_observer_plan: checks.extend( check_jira_tracker_readiness( config=config, @@ -377,6 +428,7 @@ def run_doctor( adoption=adoption, adoption_posture=adoption_posture, pilot_mode=pilot_mode, + shareable=packaged_observer_plan, ) ) @@ -409,8 +461,12 @@ def run_doctor( ) return DoctorReport( - config_path=str(config_path), - provider_templates_path=str(provider_templates_path), + config_path=("packaged-starter" if packaged_observer_plan else str(config_path)), + provider_templates_path=( + "packaged-provider-catalog" + if packaged_observer_plan + else str(provider_templates_path) + ), profile=profile, checks=tuple(checks), ) diff --git a/tests/test_remote_observer_doctor_1053.py b/tests/test_remote_observer_doctor_1053.py new file mode 100644 index 00000000..ff90dd3b --- /dev/null +++ b/tests/test_remote_observer_doctor_1053.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import json +import os +import tempfile +from contextlib import contextmanager, redirect_stdout +from io import StringIO +from pathlib import Path +from unittest import TestCase, mock + +from code_mower import doctor as doctor_cli +from code_mower.doctor_checks import DoctorCheck, DoctorReport, render_doctor_text +from code_mower.doctor_checks.runner import run_doctor + + +ROOT = Path(__file__).resolve().parents[1] +STARTER = ROOT / "src/code_mower/templates/code-mower.example.yml" +PROVIDERS = ROOT / "src/code_mower/templates/providers.yml" + + +@contextmanager +def _cwd(path: Path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +def _observer_report(*, checkout_present: bool, github: bool = False) -> DoctorReport: + return run_doctor( + config_path=STARTER, + provider_templates_path=PROVIDERS, + profile="recommended", + repo_slug="owner/repo", + repo_source="explicit", + config_source="packaged_starter", + adoption=True, + adoption_posture="orchestrator-only", + github=github, + checkout_present=checkout_present, + ) + + +class RemoteObserverDoctorTests(TestCase): + def test_checkout_free_plan_omits_local_and_unselected_checks(self) -> None: + with tempfile.TemporaryDirectory() as tmp, _cwd(Path(tmp)): + report = _observer_report(checkout_present=False) + + source = next( + check + for check in report.checks + if check.name == "doctor.adoption.config_source" + ) + self.assertEqual(source.status, "pass") + self.assertEqual(source.detail["plan"], "packaged_starter_remote_observer") + self.assertFalse(source.detail["checkout_present"]) + self.assertEqual(source.detail["checkout_checks"], "not_applicable") + self.assertNotIn("next_steps", source.detail) + self.assertEqual(report.config_path, "packaged-starter") + self.assertEqual(report.provider_templates_path, "packaged-provider-catalog") + self.assertEqual( + [stage["id"] for stage in report.run_plan], + ["load-inputs", "select-profile", "adoption"], + ) + + names = {check.name for check in report.checks} + self.assertNotIn("provider.review_hygiene", names) + self.assertFalse(any(name.startswith("runtime.local_") for name in names)) + self.assertFalse(any(name.startswith("doctor.campaign.") for name in names)) + self.assertNotIn("doctor.campaign.cloud_upload", names) + self.assertNotIn("runtime.pytest", names) + self.assertFalse(any(name.startswith("tracker.jira.") for name in names)) + + def test_checkout_plan_checks_workflows_without_publishing_local_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + checkout = Path(tmp) + workflows = checkout / ".github/workflows" + workflows.mkdir(parents=True) + for name in ("codex-clear-stale.yml", "claude-clear-stale.yml"): + (workflows / name).write_text("name: clear stale\n", encoding="utf-8") + with _cwd(checkout): + report = _observer_report(checkout_present=True) + serialized = json.dumps(report.as_dict(), sort_keys=True) + + hygiene = [ + check for check in report.checks if check.name == "provider.review_hygiene" + ] + self.assertEqual([check.status for check in hygiene], ["pass", "pass"]) + self.assertTrue(all("workflow_path" not in (check.detail or {}) for check in hygiene)) + self.assertNotIn(str(checkout), serialized) + self.assertNotIn(str(STARTER.parent), serialized) + + def test_inaccessible_remote_has_one_exact_repository_remediation(self) -> None: + inaccessible = DoctorCheck( + name="github.repo.metadata", + status="warn", + message="could not read GitHub repository metadata for owner/repo", + detail={"repo": "owner/repo", "returncode": 1, "output_redacted": True}, + remediation=( + "Verify gh auth can read this repo. Private repos need a token or " + "GitHub App installation with repository access." + ), + ) + variables = { + "statuses": { + "CODE_MOWER_OWNER_LOGIN": "not_confirmed", + "CODE_MOWER_DECISION_AUTHORITIES": "not_confirmed", + "CODE_MOWER_TRUSTED_AUTHORS_JSON": "not_confirmed", + "CLAUDE_AUDIT_BOT_AUTHORS": "not_confirmed", + "CODEX_BOT_AUTHORS": "not_confirmed", + }, + "read_errors": {}, + } + with ( + tempfile.TemporaryDirectory() as tmp, + _cwd(Path(tmp)), + mock.patch( + "code_mower.doctor_checks.github.shutil.which", + return_value="/private/observer/bin/gh", + ), + mock.patch( + "code_mower.doctor_checks.runner.trusted_author_variable_probe", + return_value=variables, + ), + mock.patch( + "code_mower.doctor_checks.github.check_repo_metadata", + return_value=(inaccessible, None), + ), + ): + report = _observer_report(checkout_present=False, github=True) + + metadata = [ + check for check in report.checks if check.name == "github.repo.metadata" + ] + self.assertEqual(len(metadata), 1) + self.assertEqual(metadata[0].remediation, inaccessible.remediation) + serialized = json.dumps(report.as_dict(), sort_keys=True) + self.assertNotIn("/private/observer", serialized) + self.assertEqual( + report.warnings, + sum(1 for check in report.checks if check.status == "warn"), + ) + rendered = render_doctor_text(report) + self.assertEqual(rendered.count("- WARN "), report.warnings) + + def test_cli_keeps_cloud_and_runtime_probe_quiet_unless_selected(self) -> None: + captured: dict[str, object] = {} + + def fake_run_doctor(**kwargs: object) -> DoctorReport: + captured.update(kwargs) + return DoctorReport( + config_path="packaged-starter", + provider_templates_path="packaged-provider-catalog", + profile="recommended", + checks=(), + ) + + with ( + tempfile.TemporaryDirectory() as tmp, + _cwd(Path(tmp)), + mock.patch.object(doctor_cli, "run_doctor", side_effect=fake_run_doctor), + mock.patch.object(doctor_cli, "board_startup_grace", return_value=None), + redirect_stdout(StringIO()), + ): + code = doctor_cli.main( + [ + "--adoption", + "--orchestrator-only", + "--repo", + "owner/repo", + "--json", + ] + ) + + self.assertEqual(code, 0) + self.assertTrue(captured["github"]) + self.assertFalse(captured["cloud"]) + self.assertFalse(captured["probe_runtime"]) + + captured.clear() + with ( + tempfile.TemporaryDirectory() as tmp, + _cwd(Path(tmp)), + mock.patch.object(doctor_cli, "run_doctor", side_effect=fake_run_doctor), + mock.patch.object(doctor_cli, "board_startup_grace", return_value=None), + redirect_stdout(StringIO()), + ): + code = doctor_cli.main( + [ + "--adoption", + "--orchestrator-only", + "--repo", + "owner/repo", + "--cloud", + "--probe-runtime", + "--json", + ] + ) + + self.assertEqual(code, 0) + self.assertTrue(captured["cloud"]) + self.assertTrue(captured["probe_runtime"]) + + captured.clear() + with tempfile.TemporaryDirectory() as tmp: + checkout = Path(tmp) + (checkout / "code-mower.yml").write_text("version: 1\n", encoding="utf-8") + with ( + _cwd(checkout), + mock.patch.object( + doctor_cli, "run_doctor", side_effect=fake_run_doctor + ), + mock.patch.object( + doctor_cli, "board_startup_grace", return_value=None + ), + redirect_stdout(StringIO()), + ): + code = doctor_cli.main( + [ + "--adoption", + "--orchestrator-only", + "--repo", + "owner/repo", + "--json", + ] + ) + + self.assertEqual(code, 0) + self.assertTrue(captured["cloud"]) + self.assertTrue(captured["probe_runtime"])