diff --git a/README.md b/README.md index c7a1a5e..126dc6b 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,12 @@ version skew, script and test timings, workspace validation) and rolls what it sees into one authoritative verdict: **GREEN / STALE / YELLOW / RED**. GREEN means it is safe to release. -See the live **[health board](https://pyautolabs.github.io/PyAutoHeart/)** for -the whole picture on one page โ€” every check a traffic-light row, and every red -or yellow finding carrying links to the failing run and a one-tap ๐Ÿ“‹ button -that copies a ready-made Claude prompt (`/bug โ€ฆ`), so going from "something is -red" to "an agent is fixing it" is copy โ†’ paste, on a laptop or a phone. +See the **[PyAutoHeart Dashboard](https://pyautolabs.github.io/PyAutoHeart/)** +(mobile phone dashboard) for the whole picture on one page โ€” every check a +traffic-light row, and every red or yellow finding carrying links to the +failing run and a one-tap ๐Ÿ“‹ button that copies a ready-made Claude prompt +(`/bug โ€ฆ`), so going from "something is red" to "an agent is fixing it" is +copy โ†’ paste, on a laptop or a phone. ## How PyAutoHeart works diff --git a/heart/dashboard.py b/heart/dashboard.py index 7da96af..a658139 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -365,11 +365,17 @@ def _repo_section( continue state, label = _lib_row(name, body, unobserved=unobserved) rows.append((state, name, label)) - # The way OUT of a red row: the failing run itself. + # The way OUT of a red row: the failing run itself, plus a ready-made + # /bug prompt (rendered as the link's paired ๐Ÿ“‹ on the html surface). ci = body.get("ci_status") or {} if (state == FAIL and ci.get("url") and str(ci.get("conclusion") or "") not in ("", "success")): - links.append({"label": f"{name} run", "url": str(ci["url"])}) + wf = ci.get("workflow") or "CI" + links.append({ + "label": f"{name} run", "url": str(ci["url"]), + "prompt": (f"/bug Heart board: {name} {wf} failing on main โ€” " + f"failing run: {ci['url']}"), + }) if not rows: return None overall = _worst(s for s, _, _ in rows) @@ -943,22 +949,21 @@ def _render_md(board: Board) -> str: def _render_md_brief(board: Board) -> str: - """The README strip: verdict + linked blockers + the board link. The full - table lives on the Pages board; the README stays a glance, not a wall.""" + """The README strip: one glance, not a wall. A single verdict line with + the board link inline; blockers/warnings appear only when there ARE any + (stale evidence gaps and timestamps live on the board, not the README โ€” + the strip earns its README lines only when something needs a human).""" word = _VERDICT_WORD.get(board.verdict, "GREEN") emoji = _STATE_MD[_VERDICT_STATE.get(board.verdict, OK)] - age = format_age(board.age_seconds, stale=board.stale) lines = [ - f"## {emoji} PyAuto health โ€” **{word}** (score {board.score})", - "", - f"_snapshot `{board.ts}` ยท {age}_", - "", + f"## {emoji} PyAuto health โ€” **{word}** (score {board.score}) ยท " + f"[full board โ†’]({PAGES_URL})" ] - label, items = _shown_reasons(board) - if items: - lines.append(f"**{label}:** " + "; ".join(_md_reason(i) for i in items[:4])) - lines.append("") - lines.append(f"**[Full board โ†’]({PAGES_URL})** โ€” live page with one-tap ๐Ÿ“‹ fix prompts") + if board.verdict in ("red", "yellow"): + label, items = _shown_reasons(board) + if items: + lines += ["", f"**{label}:** " + + "; ".join(_md_reason(i) for i in items[:4])] return "\n".join(lines) @@ -1003,6 +1008,9 @@ def _render_html(board: Board) -> str: for link in sec.links: summary += (f" " f"{_html.escape(str(link.get('label', 'link')))} โ†—") + if link.get("prompt"): + summary += " " + _copy_btn(str(link["prompt"]), + "copy the fix prompt for a Claude Code chat") if sec.action and sec.action.get("payload"): summary += " " + _copy_btn(str(sec.action["payload"]), str(sec.action.get("label", "copy"))) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 014d24d..c3131eb 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -490,6 +490,33 @@ def test_md_brief_is_a_strip_not_a_table(): assert "[autolens_workspace](" in out assert dashboard.PAGES_URL in out assert "| Check |" not in out # no table โ€” the Pages board carries it + assert "snapshot" not in out # no timestamp clutter โ€” the board has it + + +def test_md_brief_is_one_line_unless_something_is_wrong(): + # GREEN and STALE both collapse to the single verdict+link line: evidence + # gaps are the board's business, not the README's. + green = dashboard.render(make_snapshot(), make_verdict(), fmt="md-brief", + now=FRESH_NOW) + assert "\n" not in green and dashboard.PAGES_URL in green + stale_v = {"verdict": "stale", "score": 65, + "stale_reasons": ["install verification not run"], "ts": TS} + stale = dashboard.render(make_snapshot(), stale_v, fmt="md-brief", now=FRESH_NOW) + assert "\n" not in stale + assert "install verification" not in stale + + +def test_failing_repo_row_link_carries_its_own_prompt(): + v = make_verdict("red", 45, + red_reasons=["autolens_workspace: Smoke Tests failure on main"]) + board = dashboard.build_board(_failing_snapshot(), v, now=FRESH_NOW) + ws = {s.key: s for s in board.sections}["workspaces"] + (link,) = ws.links + assert link["prompt"].startswith("/bug Heart board: autolens_workspace Smoke Tests") + assert RUN_URL in link["prompt"] + html = dashboard.render(_failing_snapshot(), v, fmt="html", now=FRESH_NOW) + # the row-level ๐Ÿ“‹ renders beside the run link, not only in the blockers + assert html.count("/bug Heart board: autolens_workspace") >= 2 def test_unobserved_rows_carry_watch_line_and_observe_action():