Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/board-data-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions docs/package-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions docs/try-in-10-minutes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 53 additions & 30 deletions src/code_mower/doctor_checks/provider_review_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 21 additions & 5 deletions src/code_mower/lane_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand Down
10 changes: 4 additions & 6 deletions tests/test_doctor_provider_review_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand All @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_lane_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading