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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/board-data-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 11 additions & 5 deletions src/code_mower/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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 => `<div class="row">
put("prs", prs.length ? prs.map(pr => {
const lineage = pr.lineage || null;
const lineageNext = lineage?.next_action || lineage?.owner_action || "";
return `<div class="row">
<div class="line"><a href="${esc(href(pr.url))}">#${esc(pr.number)} ${esc(pr.title)}</a>${pill(pr.merge_state)}${pr.is_draft ? pill("draft") : ""}${pr.stale ? pill("stale") : ""}</div>
<div class="muted">${esc(pr.branch)} by ${esc(pr.author)}${pr.updated_at ? ` updated ${localTime(pr.updated_at)}` : ""}</div>
<div>labels: ${labels(pr.labels)}</div>
<div>checks: ${checks(pr.checks)}</div>
${lineage ? `<div>lineage: <b>${esc(lineage.status || "unavailable")}</b> (${esc(lineage.reason || "reason unavailable")})</div>` : ""}
${lineageNext ? `<div class="muted">lineage next: ${esc(lineageNext)}</div>` : ""}
<div>next: <b>${esc(pr.next_action)}</b></div>
${pr.next_detail ? `<div class="muted">${esc(pr.next_detail)}</div>` : ""}
</div>`).join("") : empty("No open pull requests."));
</div>`;
}).join("") : empty("No open pull requests."));
put("alerts", !remoteAvailable
? empty("GitHub unavailable; gate alerts not recorded.")
: alerts.length
Expand All @@ -4730,7 +4736,7 @@ def timelines_payload(
const publisher = isGatePublisher(run.workflow);
const state = run.conclusion || run.status;
return `<div class="row"><div class="line"><a href="${esc(href(run.url))}">${esc(run.workflow || "workflow")}</a>${statePill(display(state), stateClass(state))}${publisher ? pill("gate publisher") : ""}</div>${publisher ? `<div class="muted">Publisher execution only; the ${esc(GATE_CONTEXT)} verdict is the commit status listed under each PR.</div>` : ""}<div class="muted">${esc(run.branch)}${run.updated_at ? ` updated ${localTime(run.updated_at)}` : ""}</div></div>`;
}).join("") : empty("No recent Code Mower workflow runs."));
}).join("") : empty("none"));
put("verdicts", verdicts.length ? verdicts.map(v => `<div class="row"><div class="line"><a href="${esc(href(v.url))}">#${esc(v.pr_number)} ${esc(v.lane)}</a>${pill(v.verdict)}${pill(v.head_sha_prefix)}</div><div class="muted">${localTime(v.created_at)}</div></div>`).join("") : empty(timelines.verdicts?.message || "No local reviewer verdict history yet."));
const spendRows = [
...spendGroups.map(g => `<div class="row"><div class="line"><b>${esc(g.lane)}</b>${pill(display(g.verdict))}${pill(`${display(g.runs)} runs`)}</div><div class="muted">${seconds(g.wall_seconds_total)} total / ${seconds(g.wall_seconds_avg)} avg / ${money(g.cost_usd_total)} / ${esc(display(g.total_tokens))} tokens</div></div>`),
Expand Down
37 changes: 35 additions & 2 deletions src/code_mower/lane_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"]

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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']}")
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"], '<div class="muted">none</div>')

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: <b>optional</b> (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
Expand Down
89 changes: 89 additions & 0 deletions tests/test_lane_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
Loading