diff --git a/docs/board-data-contract.md b/docs/board-data-contract.md index dff0469e..00c4e0dd 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -58,7 +58,9 @@ Top-level fields: detection code. - `local_processes`: best-effort local lane process hints. - `next_action`: concise operator action such as `fix BLOCKED audit`, - `waiting for checks`, `ready for merge or auto-merge`, or `no active lanes`. + `waiting for checks`, `ready for merge or auto-merge`, + `remote unavailable; fix GitHub access`, or `no active lanes`. `no active + lanes` is emitted only when GitHub PR/workflow state was available. `remote.pull_requests[]` includes metadata useful to an operator: diff --git a/docs/package-customization.md b/docs/package-customization.md index 325bc65a..57ebf3d8 100644 --- a/docs/package-customization.md +++ b/docs/package-customization.md @@ -267,8 +267,9 @@ check, because stale terminal labels are a merge-safety issue rather than a cosmetic setup detail. For installed repo configs, doctor also checks that the configured workflow file exists on disk, so a config cannot claim stale-label protection when the generated workflow was never committed. The packaged -example config is exempt from this file-presence check because it is used before -first install. The generated stale-clear workflow currently supports +example config warns instead of passing because it is used before first install +and cannot prove the workflow file exists in the target checkout yet. The +generated stale-clear workflow currently supports `review_hygiene.token_env: GITHUB_TOKEN`; use a custom token only after also customizing the workflow that exports credentials to the `clear-stale` command. diff --git a/docs/try-in-10-minutes.md b/docs/try-in-10-minutes.md index d7016a55..5cd3ffdd 100644 --- a/docs/try-in-10-minutes.md +++ b/docs/try-in-10-minutes.md @@ -103,9 +103,10 @@ postures skip local-wrapper probes and keep missing local wrapper env vars out of the warning list. For merge-authority lanes such as Codex or Claude audit, look for -`provider.review_hygiene`. It should pass for lanes that can satisfy the merge -bar, because it proves Code Mower can clear stale terminal labels after a PR -receives new commits. +`provider.review_hygiene`. Before generated workflows are applied, it may warn +that workflow file presence was not verified. After the setup PR lands, it +should pass for lanes that can satisfy the merge bar, because it proves Code +Mower can clear stale terminal labels after a PR receives new commits. ## 5. Open The Setup PR diff --git a/src/code_mower/doctor_checks/provider_review_hygiene.py b/src/code_mower/doctor_checks/provider_review_hygiene.py index 36e06aaa..c5556167 100644 --- a/src/code_mower/doctor_checks/provider_review_hygiene.py +++ b/src/code_mower/doctor_checks/provider_review_hygiene.py @@ -5,7 +5,14 @@ from pathlib import Path from typing import Any, Mapping -from .common import DoctorCheck, STATUS_FAIL, STATUS_PASS, STATUS_SKIP, as_sequence +from .common import ( + DoctorCheck, + STATUS_FAIL, + STATUS_PASS, + STATUS_SKIP, + STATUS_WARN, + as_sequence, +) def check_review_hygiene( @@ -80,35 +87,51 @@ def check_review_hygiene( ), ) - if repo_root is not None: - workflow_path = Path(workflow) - if not workflow_path.is_absolute(): - workflow_path = repo_root / workflow_path - if not workflow_path.is_file(): - return DoctorCheck( - name="provider.review_hygiene", - status=STATUS_FAIL, - lane=lane_id, - message=( - "merge-authority lane stale terminal-label workflow is " - f"configured but missing from the repo: {workflow}" - ), - detail={ - **detail, - "workflow_exists": False, - "workflow_path": str(workflow_path), - }, - remediation=( - "Run `code-mower init --easy --apply` from the repository " - "root, commit the generated clear-stale workflow, then rerun " - "doctor before relying on this lane as merge authority." - ), - ) - detail = { - **detail, - "workflow_exists": True, - "workflow_path": str(workflow_path), - } + if repo_root is None: + return DoctorCheck( + name="provider.review_hygiene", + status=STATUS_WARN, + lane=lane_id, + message=( + "stale terminal-label guard is configured, but workflow file " + f"presence was not verified: {workflow}" + ), + detail={**detail, "workflow_exists": None}, + remediation=( + "Run doctor from the repository root after applying generated " + "workflows, or pass the repository code-mower.yml so doctor can " + "verify the workflow file before promotion." + ), + ) + + workflow_path = Path(workflow) + if not workflow_path.is_absolute(): + workflow_path = repo_root / workflow_path + if not workflow_path.is_file(): + return DoctorCheck( + name="provider.review_hygiene", + status=STATUS_FAIL, + lane=lane_id, + message=( + "merge-authority lane stale terminal-label workflow is " + f"configured but missing from the repo: {workflow}" + ), + detail={ + **detail, + "workflow_exists": False, + "workflow_path": str(workflow_path), + }, + remediation=( + "Run `code-mower init --easy --apply` from the repository " + "root, commit the generated clear-stale workflow, then rerun " + "doctor before relying on this lane as merge authority." + ), + ) + detail = { + **detail, + "workflow_exists": True, + "workflow_path": str(workflow_path), + } return DoctorCheck( name="provider.review_hygiene", diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index 0b46b246..7170036b 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -387,8 +387,12 @@ def _redact_local_paths(report: dict[str, Any]) -> None: def _global_next(report: Mapping[str, Any]) -> str: prs = report["remote"].get("pull_requests", []) local_active = bool(report["agenttrail"].get("boards")) or bool(report["local_processes"].get("processes")) - if not report["remote"].get("available") and local_active: - return "remote unavailable; inspect local lanes" + if not report["remote"].get("available"): + return ( + "remote unavailable; inspect local lanes" + if local_active + else "remote unavailable; fix GitHub access" + ) for action in ("fix BLOCKED audit", "fix failing check", "rebase/behind", "waiting for audits or owner input", "waiting for checks", "ready for merge or auto-merge"): if any(pr.get("next_action") == action for pr in prs): return action @@ -447,17 +451,29 @@ def render_text(report: Mapping[str, Any]) -> str: lines.append(f" next: {pr['next_action']}") if pr.get("gate_rerun_command") and pr.get("next_action") in GATE_RERUN_ACTIONS: lines.append(f" rerun gate: {pr['gate_rerun_command']}") - else: + elif remote.get("available"): lines.append("Open PRs: none") + else: + lines.append("Open PRs: unavailable") lines.append("") runs = remote.get("workflow_runs") or [] - lines.append("Recent Code Mower workflows:" if runs else "Recent Code Mower workflows: none") + if runs: + lines.append("Recent Code Mower workflows:") + elif remote.get("available"): + lines.append("Recent Code Mower workflows: none") + else: + lines.append("Recent Code Mower workflows: unavailable") for run in runs[:5]: state = run.get("conclusion") or run.get("status") or "unknown" lines.append(f"- {run.get('workflow') or 'workflow'} [{state}] {run.get('branch') or ''} updated {run.get('updated_at') or ''}".rstrip()) lines.append("") alerts = (remote.get("gate_health") or {}).get("alerts") or [] - lines.append("Gate alerts:" if alerts else "Gate alerts: none") + if alerts: + lines.append("Gate alerts:") + elif remote.get("available"): + lines.append("Gate alerts: none") + else: + lines.append("Gate alerts: unavailable") lines.extend(f"- {alert['message']}" for alert in alerts[:5]) lines.append("") boards = report["agenttrail"].get("boards") or [] diff --git a/tests/test_doctor_provider_review_hygiene.py b/tests/test_doctor_provider_review_hygiene.py index 1e5c3e1b..dd6b6c91 100644 --- a/tests/test_doctor_provider_review_hygiene.py +++ b/tests/test_doctor_provider_review_hygiene.py @@ -41,7 +41,7 @@ def test_template_defaults_do_not_satisfy_source_hygiene_requirement(self) -> No self.assertEqual(check.detail["missing"], ["workflow", "token_env"]) self.assertIn("missing stale terminal-label guard config", check.message) - def test_merge_authority_lane_passes_with_stale_guard(self) -> None: + def test_merge_authority_lane_warns_when_stale_guard_presence_unverified(self) -> None: check = check_review_hygiene( "codex", { @@ -55,15 +55,13 @@ def test_merge_authority_lane_passes_with_stale_guard(self) -> None: }, ) - self.assertEqual(check.status, "pass") - self.assertEqual( - check.message, - "stale terminal-label guard configured via .github/workflows/codex-clear-stale.yml", - ) + self.assertEqual(check.status, "warn") + self.assertIn("presence was not verified", check.message) self.assertEqual(check.detail["workflow"], ".github/workflows/codex-clear-stale.yml") self.assertEqual(check.detail["token_env"], "GITHUB_TOKEN") self.assertEqual(check.detail["dispatch_workflow"], "codex-audit-labeler.yml") self.assertEqual(check.detail["trusted_authors"], ["codex[bot]"]) + self.assertIsNone(check.detail["workflow_exists"]) def test_merge_authority_lane_fails_when_configured_workflow_is_missing(self) -> None: with TemporaryDirectory() as tmp: diff --git a/tests/test_lane_status.py b/tests/test_lane_status.py index 8598ddee..7bfedb57 100644 --- a/tests/test_lane_status.py +++ b/tests/test_lane_status.py @@ -154,6 +154,28 @@ def command_runner(args: list[str]) -> subprocess.CompletedProcess[str]: self.assertNotIn("AgentTrail boards:", rendered) self.assertNotIn("/tmp/lane-checkout", rendered) + def test_collect_status_never_reports_no_active_lanes_when_github_unavailable( + self, + ) -> None: + def gh_json(_args: list[str]) -> object: + raise lane_status.LaneStatusUnavailable("gh pr failed") + + report = lane_status.collect_status( + repo="owner/repo", + gh_json_runner=gh_json, + command_runner=lambda _args: _completed(""), + now=NOW, + ) + + self.assertFalse(report["remote"]["available"]) + self.assertEqual(report["next_action"], "remote unavailable; fix GitHub access") + rendered = lane_status.render_text(report) + self.assertIn("Open PRs: unavailable", rendered) + self.assertIn("Recent Code Mower workflows: unavailable", rendered) + self.assertIn("Gate alerts: unavailable", rendered) + self.assertNotIn("Open PRs: none", rendered) + self.assertNotIn("Next: no active lanes", rendered) + def test_collect_status_can_include_local_paths_for_debugging(self) -> None: def gh_json(_args: list[str]) -> object: raise lane_status.LaneStatusUnavailable("gh pr failed")