From 6debf629c9528e7bc6bf158193bb6765c2b32739 Mon Sep 17 00:00:00 2001 From: Jeff Huber Date: Sun, 20 Sep 2026 02:43:06 -0500 Subject: [PATCH] Clarify status lineage and Board presentation --- CHANGELOG.md | 5 ++ docs/board-data-contract.md | 14 ++++++ src/code_mower/board.py | 16 +++++-- src/code_mower/lane_status.py | 37 ++++++++++++++- tests/test_board.py | 43 +++++++++++++++++ tests/test_lane_status.py | 89 +++++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abb1901..5293614d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ later entries are regular releases. 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). +- Lane status and the Board now preserve readable PR and gate state when no + lineage policy is configured, label lineage as optional with the command + needed to evaluate it, and distinguish that posture from unreadable lineage + metadata. Empty recent workflows render as `none`, and the Board header + names the serving version explicitly (#1055). - 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/board-data-contract.md b/docs/board-data-contract.md index 60352f18..5d56fe1c 100644 --- a/docs/board-data-contract.md +++ b/docs/board-data-contract.md @@ -16,6 +16,15 @@ to the newest timestamped result in this current-state snapshot. Superseded results remain available in prior local history events, but do not drive the current next action or owner queue. +Each readable open PR includes a `lineage` posture. When no repository policy +was loaded, `status: optional` and `reason: lineage_policy_not_configured` +preserve the independently readable labels, checks, gate state, and PR next +action; `lineage.next_action` tells the operator to pass +`--config code-mower.yml` if they want lineage evaluated. When a configured +policy cannot read or validate the lineage metadata, `status: unavailable` +and `reason: lineage_unreadable` direct the operator to restore readable +metadata and rerun status. Neither posture is presented as verified lineage. + `code_mower.board.v1` is the local board wrapper added by `code-mower board serve --repo OWNER/REPO`. It adds board display metadata and embeds the lane-status snapshot unchanged. @@ -466,6 +475,11 @@ and at every width. The tabs are a real `tablist` of `tab` buttons controlling real `tabpanel` regions; the unselected panels carry `hidden`, so they leave the accessibility tree instead of being painted away. +The header labels the running package as `Serving version: VERSION`; this is +the primary version reading, while the Health view retains installed-version +and restart detail. An empty Recent Code Mower Workflows section renders +`none`, matching the terminal status surface. + - **Now** — the work rows, the selected work item's evidence, the participant summary, and the existing owner queue, lane work, supervised pilot and open PR sections. diff --git a/src/code_mower/board.py b/src/code_mower/board.py index 747e1606..b4dd3d7e 100644 --- a/src/code_mower/board.py +++ b/src/code_mower/board.py @@ -4560,8 +4560,8 @@ def timelines_payload( const servingVersion = version.serving_version || "unknown"; const installedVersion = version.installed_version || servingVersion; document.getElementById("version").textContent = version.restart_recommended - ? `serving ${servingVersion}; installed ${installedVersion} available after restart` - : `serving ${servingVersion}`; + ? `Serving version: ${servingVersion}; installed ${installedVersion} available after restart` + : `Serving version: ${servingVersion}`; document.getElementById("generated").innerHTML = data.generated_at ? `Generated ${localTime(data.generated_at)}` : "Loading..."; const prs = data.remote?.pull_requests || []; const runs = data.remote?.workflow_runs || []; @@ -4708,14 +4708,20 @@ def timelines_payload( return [header, ...cardRows]; }).join(""); put("campaigns", campaignRows || empty(campaignsData.message || "No release campaigns.")); - put("prs", prs.length ? prs.map(pr => `
+ put("prs", prs.length ? prs.map(pr => { + const lineage = pr.lineage || null; + const lineageNext = lineage?.next_action || lineage?.owner_action || ""; + return `
#${esc(pr.number)} ${esc(pr.title)}${pill(pr.merge_state)}${pr.is_draft ? pill("draft") : ""}${pr.stale ? pill("stale") : ""}
${esc(pr.branch)} by ${esc(pr.author)}${pr.updated_at ? ` updated ${localTime(pr.updated_at)}` : ""}
labels: ${labels(pr.labels)}
checks: ${checks(pr.checks)}
+ ${lineage ? `
lineage: ${esc(lineage.status || "unavailable")} (${esc(lineage.reason || "reason unavailable")})
` : ""} + ${lineageNext ? `
lineage next: ${esc(lineageNext)}
` : ""}
next: ${esc(pr.next_action)}
${pr.next_detail ? `
${esc(pr.next_detail)}
` : ""} -
`).join("") : empty("No open pull requests.")); +
`; + }).join("") : empty("No open pull requests.")); put("alerts", !remoteAvailable ? empty("GitHub unavailable; gate alerts not recorded.") : alerts.length @@ -4730,7 +4736,7 @@ def timelines_payload( const publisher = isGatePublisher(run.workflow); const state = run.conclusion || run.status; return `
${esc(run.workflow || "workflow")}${statePill(display(state), stateClass(state))}${publisher ? pill("gate publisher") : ""}
${publisher ? `
Publisher execution only; the ${esc(GATE_CONTEXT)} verdict is the commit status listed under each PR.
` : ""}
${esc(run.branch)}${run.updated_at ? ` updated ${localTime(run.updated_at)}` : ""}
`; - }).join("") : empty("No recent Code Mower workflow runs.")); + }).join("") : empty("none")); put("verdicts", verdicts.length ? verdicts.map(v => `
#${esc(v.pr_number)} ${esc(v.lane)}${pill(v.verdict)}${pill(v.head_sha_prefix)}
${localTime(v.created_at)}
`).join("") : empty(timelines.verdicts?.message || "No local reviewer verdict history yet.")); const spendRows = [ ...spendGroups.map(g => `
${esc(g.lane)}${pill(display(g.verdict))}${pill(`${display(g.runs)} runs`)}
${seconds(g.wall_seconds_total)} total / ${seconds(g.wall_seconds_avg)} avg / ${money(g.cost_usd_total)} / ${esc(display(g.total_tokens))} tokens
`), diff --git a/src/code_mower/lane_status.py b/src/code_mower/lane_status.py index e9110492..04c308ab 100644 --- a/src/code_mower/lane_status.py +++ b/src/code_mower/lane_status.py @@ -422,8 +422,18 @@ def _remote( from . import config as policy_config budget = 64 # Global history requests, including terminal probes; every listed PR stays visible. for pr, raw_pr in zip(prs, (item for item in raw_prs if isinstance(item, Mapping)), strict=True): + if lineage_config is None: + pr["lineage"] = { + "status": "optional", + "reason": "lineage_policy_not_configured", + "current_writer": None, + "contributors": [], + "admitted_reviewers": [], + "next_action": "pass --config code-mower.yml to evaluate lineage", + } + continue try: - if lineage_config is None or (lineage_config and policy_config.validate_config(lineage_config)): + if policy_config.validate_config(lineage_config): raise ContractError("Trusted validated status policy required") identity = lineage_identity(lineage_config) authority = lineage_authorities(lineage_config) @@ -452,10 +462,22 @@ def page(number, size, target=target): str(lane.get("author_lane") or lane.get("trailer_lane") or lane.get("provider") or key) for key, lane in lanes.items() if isinstance(lane, Mapping) and admit(decision, str(lane.get("author_lane") or lane.get("trailer_lane") or lane.get("provider") or key))}) + except LaneStatusUnavailable: + pr["lineage"] = { + "status": "unavailable", + "reason": "lineage_unreadable", + "current_writer": None, + "contributors": [], + "admitted_reviewers": [], + "next_action": "restore readable lineage metadata and rerun status", + } except (ValueError, KeyError, TypeError, RuntimeError): pr["lineage"] = {"status": "unknown", "reason": "lineage_unreadable", "current_writer": None, "contributors": [], "admitted_reviewers": []} - if pr["lineage"]["status"] != "ready": + if pr["lineage"]["status"] == "unavailable": + pr["next_action"] = str(pr["lineage"]["next_action"]) + pr["next_detail"] = "lineage unavailable: " + pr["lineage"]["reason"] + elif pr["lineage"]["status"] != "ready": pr["next_action"] = "owner action required" pr["next_detail"] = "lineage " + pr["lineage"]["status"] + ": " + pr["lineage"]["reason"] @@ -982,6 +1004,7 @@ def _global_next(report: Mapping[str, Any]) -> tuple[str, str]: else "remote unavailable; fix GitHub access" ), "" for action in ( + "restore readable lineage metadata and rerun status", "owner action required", "fix BLOCKED audit", "fix failing check", @@ -1080,6 +1103,15 @@ def render_text(report: Mapping[str, Any]) -> str: lines.append(f"- #{pr['number']} {pr['title']} [{pr['merge_state']}{stale}] {pr['branch']} by {pr['author']} updated {pr['updated_at']}") lines.append(f" labels: {_label_text(pr['labels'])}") lines.append(f" checks: {_check_text(pr['checks'])}") + lineage = pr.get("lineage") if isinstance(pr.get("lineage"), Mapping) else {} + if lineage: + lines.append( + f" lineage: {lineage.get('status') or 'unavailable'} " + f"({lineage.get('reason') or 'reason unavailable'})" + ) + lineage_next = lineage.get("next_action") or lineage.get("owner_action") + if lineage_next: + lines.append(f" lineage next: {lineage_next}") lines.append(f" next: {pr['next_action']}") if pr.get("next_detail"): lines.append(f" detail: {pr['next_detail']}") @@ -1201,6 +1233,7 @@ def main( stale_minutes=args.stale_minutes, show_local_paths=args.show_local_paths, tracker_config=tracker_config, + lineage_config=tracker_config, jira_reader=jira_reader, ) output = json.dumps(report, indent=2, sort_keys=True) + "\n" if args.json else render_text(report) diff --git a/tests/test_board.py b/tests/test_board.py index 26ae976d..44497561 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -2078,6 +2078,49 @@ def _status(**overrides: object) -> dict[str, object]: class BoardPresentationTruthTests(TestCase): """Issue #947: the Board may not claim more than the payload records.""" + def test_primary_ui_names_serving_version_and_empty_workflows(self) -> None: + nodes = _render_board_dom( + _status( + board={ + "version": { + "serving_version": "1.5.1", + "installed_version": "1.5.1", + "restart_recommended": False, + } + } + ) + ) + + self.assertEqual(nodes["version"], "Serving version: 1.5.1") + self.assertEqual(nodes["runs"], '
none
') + + def test_pr_row_explains_optional_lineage_and_next_step(self) -> None: + pr = _pr( + 15, + lineage={ + "status": "optional", + "reason": "lineage_policy_not_configured", + "next_action": "pass --config code-mower.yml to evaluate lineage", + }, + ) + nodes = _render_board_dom( + _status( + remote={ + "available": True, + "errors": [], + "pull_requests": [pr], + "workflow_runs": [], + "gate_health": {"status": "pass", "alerts": []}, + } + ) + ) + + self.assertIn("lineage: optional (lineage_policy_not_configured)", nodes["prs"]) + self.assertIn( + "lineage next: pass --config code-mower.yml to evaluate lineage", + nodes["prs"], + ) + def test_absent_measurements_render_as_not_recorded_not_zero(self) -> None: # Number(null), Number("") and Number(false) are all a finite 0, so a # naive Number.isFinite check turns "never measured" into "measured diff --git a/tests/test_lane_status.py b/tests/test_lane_status.py index 5e988770..672c980e 100644 --- a/tests/test_lane_status.py +++ b/tests/test_lane_status.py @@ -174,6 +174,95 @@ def gh_json(args: list[str]) -> object: self.assertEqual(report["remote"]["gate_health"]["status"], "warn") self.assertIn("fix BLOCKED audit", lane_status.render_text(report)) + def test_readable_pr_without_lineage_policy_reports_optional_lineage(self) -> None: + calls: list[list[str]] = [] + + def gh_json(args: list[str]) -> object: + calls.append(args) + if args[:2] == ["pr", "list"]: + return [ + { + "number": 13, + "title": "Document optional lineage", + "url": "https://github.com/owner/repo/pull/13", + "headRefName": "docs/optional-lineage", + "headRefOid": "abcdef0123456789abcdef0123456789abcdef01", + "author": {"login": "alice"}, + "isDraft": False, + "mergeStateStatus": "CLEAN", + "updatedAt": NOW.isoformat().replace("+00:00", "Z"), + "labels": [{"name": "claude-audit-done"}], + "statusCheckRollup": [ + {"context": "code-mower/gate", "state": "SUCCESS"}, + ], + } + ] + if args[:2] == ["run", "list"]: + return [] + raise lane_status.LaneStatusUnavailable("lineage comments must not be read without policy") + + report = lane_status.collect_status( + repo="owner/repo", + gh_json_runner=gh_json, + command_runner=lambda _args: _completed(""), + now=NOW, + ) + + pr = report["remote"]["pull_requests"][0] + self.assertEqual(pr["lineage"]["status"], "optional") + self.assertEqual(pr["lineage"]["reason"], "lineage_policy_not_configured") + self.assertEqual( + pr["lineage"]["next_action"], + "pass --config code-mower.yml to evaluate lineage", + ) + self.assertEqual(pr["next_action"], "ready for merge or auto-merge") + self.assertFalse(any(args[0] == "api" for args in calls)) + rendered = lane_status.render_text(report) + self.assertIn("lineage: optional (lineage_policy_not_configured)", rendered) + self.assertIn("lineage next: pass --config code-mower.yml to evaluate lineage", rendered) + self.assertIn("Recent Code Mower workflows: none", rendered) + + def test_configured_but_unreadable_lineage_has_precise_recovery(self) -> None: + def gh_json(args: list[str]) -> object: + if args[:2] == ["pr", "list"]: + return [ + { + "number": 14, + "title": "Recover lineage visibility", + "url": "https://github.com/owner/repo/pull/14", + "headRefName": "codex/lineage-recovery", + "headRefOid": "abcdef0123456789abcdef0123456789abcdef01", + "author": {"login": "alice"}, + "isDraft": False, + "mergeStateStatus": "CLEAN", + "updatedAt": NOW.isoformat().replace("+00:00", "Z"), + "labels": [{"name": "builder:codex"}], + "statusCheckRollup": [ + {"context": "code-mower/gate", "state": "PENDING"}, + ], + } + ] + if args[:2] == ["run", "list"]: + return [] + if args[0] == "api": + raise lane_status.LaneStatusUnavailable("comment access denied") + raise lane_status.LaneStatusUnavailable("unexpected gh call") + + report = lane_status.collect_status( + lineage_config=policy({}), + repo="owner/repo", + gh_json_runner=gh_json, + command_runner=lambda _args: _completed(""), + now=NOW, + ) + + pr = report["remote"]["pull_requests"][0] + self.assertEqual(pr["lineage"]["status"], "unavailable") + self.assertEqual(pr["lineage"]["reason"], "lineage_unreadable") + self.assertEqual(pr["next_action"], "restore readable lineage metadata and rerun status") + self.assertEqual(report["next_action"], pr["next_action"]) + self.assertIn("lineage unavailable: lineage_unreadable", pr["next_detail"]) + def test_render_text_includes_copy_pasteable_gate_rerun_command(self) -> None: def gh_json(args: list[str]) -> object: if args[:2] == ["pr", "list"]: