diff --git a/bridge/bridge.py b/bridge/bridge.py index 9824aa5..1fc259f 100644 --- a/bridge/bridge.py +++ b/bridge/bridge.py @@ -41,8 +41,16 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer GITEA_API = os.environ.get("GITEA_API", "https://git.autonomic.zone/api/v1") DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net") +# Dashboard base URL — where per-run artifacts (summary card PNG, level badge SVG) are served +# (Phase 3 U2.3: /runs//...). The PR comment (U3) embeds the card + badge from here. The +# run_id is the Drone build number (== `num`), so the URLs are /runs//{summary.png,badge.svg}. +DASH_URL = os.environ.get("DASH_URL", "https://ci.commoninternet.net") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") TRIGGER = "!testme" +# Hidden HTML-comment marker embedded in the bot's PR comment so a re-`!testme` UPDATES the same +# comment in place (R2/U3 "one comment per PR, updated in place") instead of stacking new ones. +# Invisible in rendered Gitea markdown. +COMMENT_MARKER = "" def parse_trigger(body): @@ -160,9 +168,49 @@ def build_status(num): _TERMINAL = {"success", "failure", "error", "killed"} +def artifact_available(url): + """True iff the dashboard serves `url` (HTTP 200). Used to decide image-vs-text fallback for the + PR comment (R7: a render failure → text, never a broken image). Best-effort; any error → False.""" + try: + req = urllib.request.Request(url, method="HEAD") + with urllib.request.urlopen(req, timeout=10) as r: + return getattr(r, "status", r.getcode()) == 200 + except Exception: # noqa: BLE001 — unreachable/404/timeout all mean "fall back to text" + return False + + +def start_comment_body(recipe, sha, run_url, mode=""): + """U3.1 — the YunoHost-shaped placeholder posted when a run starts: 🌻 marker + ⏳ + live-logs + link. Edited in place to the image-forward result by watch_and_reflect on completion.""" + return ( + f"{COMMENT_MARKER}\n" + f"🌻 **cc-ci** — testing `{recipe}` @ `{sha[:8]}`{mode}\n\n" + f"⏳ Run in progress — level pending. [Live logs]({run_url})." + ) + + +def result_comment_body(recipe, sha, num, run_url, status): + """U3.2 — the YunoHost-shaped result comment: 🌻 marker + a level/status **badge** + the + **summary card** image, both linking to the run; falls back to a compact text verdict if the + rendered card/badge isn't available (render failed, or the build didn't complete) — R7.""" + badge_url = f"{DASH_URL}/runs/{num}/badge.svg" + card_url = f"{DASH_URL}/runs/{num}/summary.png" + icon = "✅" if status == "success" else "❌" + verdict = "passed" if status == "success" else (status or "did not complete") + header = f"{COMMENT_MARKER}\n🌻 **cc-ci** — `{recipe}` @ `{sha[:8]}` {icon} **{verdict}**" + links = f"[full logs]({run_url}) · [dashboard]({DASH_URL}/)" + # Image-forward (YunoHost style) only when the card actually rendered + is served; else text. + if artifact_available(card_url): + body = f"{header}\n\n[![cc-ci result card]({card_url})]({run_url})" + if artifact_available(badge_url): + body += f"\n\n[![level]({badge_url})]({run_url})" + return f"{body}\n\n{links}" + return f"{header} → {run_url}\n\n_(summary card unavailable — see the run for details.)_ {links}" + + def watch_and_reflect(owner, name, number, num, recipe, sha, comment_id, run_url): - """Poll the Drone build to completion, then edit the PR comment to reflect the outcome (D7). - Bounded by the build timeout (60m) + margin.""" + """Poll the Drone build to completion, then edit the PR comment to the YunoHost-style image-forward + result (🌻 + badge + summary card, linked; text fallback) — D7/R2/U3. Bounded by build timeout.""" import time as _t deadline = _t.time() + 75 * 60 @@ -172,15 +220,8 @@ def watch_and_reflect(owner, name, number, num, recipe, sha, comment_id, run_url if last in _TERMINAL: break _t.sleep(15) - icon = {"success": "✅"}.get(last, "❌") - verdict = "passed" if last == "success" else (last or "did not complete") if comment_id: - edit_comment( - owner, - name, - comment_id, - f"cc-ci: run for `{recipe}` @ `{sha[:8]}` {icon} **{verdict}** → {run_url}", - ) + edit_comment(owner, name, comment_id, result_comment_body(recipe, sha, num, run_url, last)) log(f"reflected outcome build {num} ({recipe} PR #{number}): {last}") @@ -194,6 +235,15 @@ def list_comments(full_name, number): return cs if status == 200 and cs else [] +def find_existing_comment(full_name, number): + """Return the id of the bot's existing cc-ci PR comment (carrying COMMENT_MARKER), or None — so a + re-`!testme` UPDATES that comment in place (R2/U3) rather than stacking a new one each run.""" + for c in list_comments(full_name, number): + if COMMENT_MARKER in (c.get("body") or ""): + return c.get("id") + return None + + def _claim(comment_id) -> bool: """Atomically claim a comment id for processing. Returns False if already claimed (dedup).""" if comment_id is None: @@ -222,10 +272,15 @@ def process_testme(full_name, owner, name, number, user, comment_id, source, qui return None, "trigger failed" run_url = f"{DRONE_URL}/{CI_REPO}/{num}" mode = " **(--quick: lower-confidence fast lane; does not gate merge)**" if quick else "" - cid = post_comment( - owner, name, number, - f"cc-ci: started CI run for `{name}` @ `{head['sha'][:8]}`{mode} → {run_url}", - ) + # R2/U3: one comment per PR, updated in place. Reuse the existing marked comment if present + # (re-`!testme` refreshes it back to the ⏳ placeholder), else post a new one. + start_body = start_comment_body(name, head["sha"], run_url, mode) + existing = find_existing_comment(full_name, number) + if existing: + edit_comment(owner, name, existing, start_body) + cid = existing + else: + cid = post_comment(owner, name, number, start_body) log( f"[{source}] triggered build {num} for {name}@{head['sha'][:8]} " f"(PR #{number}, comment {comment_id}) by {user}" diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index 9db3d56..9960115 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -192,23 +192,16 @@ def serve_run_file(run_id, fname): class Handler(BaseHTTPRequestHandler): - def _send(self, code, body, ctype="text/html; charset=utf-8"): - data = body.encode() if isinstance(body, str) else body - self.send_response(code) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def do_GET(self): - path = self.path.split("?")[0].rstrip("/") or "/" + def _route(self, path): + """Resolve a request path to (code, body, content_type). Shared by GET and HEAD so they + never diverge. `body` is bytes/str for GET; HEAD sends only the status + headers.""" if path in ("/healthz", "/dashboard/healthz"): - return self._send(200, "ok", "text/plain") + return 200, "ok", "text/plain" if path.startswith("/badge/") and path.endswith(".svg"): recipe = path[len("/badge/") : -len(".svg")] row = next((r for r in recipes_cached() if r["recipe"] == recipe), None) status = row["status"] if row else "unknown" - return self._send(200, render_badge(recipe, status), "image/svg+xml") + return 200, render_badge(recipe, status), "image/svg+xml" if path.startswith("/runs/"): # /runs// — stable URL for a run's results.json / summary.png / screenshot / # badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file. @@ -216,11 +209,32 @@ class Handler(BaseHTTPRequestHandler): if len(parts) == 2: got = serve_run_file(parts[0], parts[1]) if got is not None: - return self._send(200, got[1], got[0]) - return self._send(404, "not found", "text/plain") + return 200, got[1], got[0] + return 404, "not found", "text/plain" if path == "/": - return self._send(200, render_overview(recipes_cached())) - return self._send(404, "not found", "text/plain") + return 200, render_overview(recipes_cached()), "text/html; charset=utf-8" + return 404, "not found", "text/plain" + + def _send(self, code, body, ctype="text/html; charset=utf-8", head_only=False): + data = body.encode() if isinstance(body, str) else body + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + if not head_only: + self.wfile.write(data) + + def do_GET(self): + path = self.path.split("?")[0].rstrip("/") or "/" + code, body, ctype = self._route(path) + self._send(code, body, ctype) + + def do_HEAD(self): + # Same routing as GET, headers only (no body) — enables cheap existence checks, e.g. the + # comment-bridge deciding image-vs-text fallback for the PR comment (U3). + path = self.path.split("?")[0].rstrip("/") or "/" + code, body, ctype = self._route(path) + self._send(code, body, ctype, head_only=True) def log_message(self, *a): pass diff --git a/tests/unit/test_bridge_trigger.py b/tests/unit/test_bridge_trigger.py index f981f0e..9e1cc14 100644 --- a/tests/unit/test_bridge_trigger.py +++ b/tests/unit/test_bridge_trigger.py @@ -39,3 +39,48 @@ def test_non_trigger_forms_rejected(): None, ): assert bridge.parse_trigger(body) == (False, False), body + + +# --- Phase 3 U3: YunoHost-style PR comment builders (R2) ----------------------------------------- + +def test_start_comment_is_yunohost_shaped(): + b = bridge.start_comment_body("uptime-kuma", "dfed87a39f8a", "https://drone.x/cc-ci/42") + assert bridge.COMMENT_MARKER in b # re-!testme updates the same comment + assert "🌻" in b and "⏳" in b # marker + in-progress + assert "uptime-kuma" in b and "dfed87a3" in b + assert "https://drone.x/cc-ci/42" in b + + +def test_result_comment_image_forward_when_card_available(monkeypatch): + monkeypatch.setattr(bridge, "artifact_available", lambda url: True) + monkeypatch.setattr(bridge, "DASH_URL", "https://ci.example") + b = bridge.result_comment_body("uptime-kuma", "dfed87a39f8a", "42", "https://drone.x/cc-ci/42", "success") + assert bridge.COMMENT_MARKER in b + assert "✅" in b and "passed" in b + # the card + badge are embedded as linked images at the stable /runs// URLs + assert "![cc-ci result card](https://ci.example/runs/42/summary.png)" in b + assert "https://ci.example/runs/42/badge.svg" in b + assert "(https://drone.x/cc-ci/42)" in b # links to the run + + +def test_result_comment_text_fallback_when_card_missing(monkeypatch): + # Render failed / not served → MUST degrade to text, never a broken image (R7). + monkeypatch.setattr(bridge, "artifact_available", lambda url: False) + b = bridge.result_comment_body("hedgedoc", "abc1234def", "9", "https://drone.x/cc-ci/9", "failure") + assert "summary.png" not in b # no image embed + assert "![" not in b # no markdown image at all + assert "❌" in b and "failure" in b + assert "https://drone.x/cc-ci/9" in b + + +def test_find_existing_comment_matches_marker(monkeypatch): + monkeypatch.setattr(bridge, "list_comments", lambda fn, n: [ + {"id": 1, "body": "just a normal comment"}, + {"id": 2, "body": bridge.COMMENT_MARKER + "\n🌻 old run"}, + ]) + assert bridge.find_existing_comment("org/repo", 5) == 2 + + +def test_find_existing_comment_none_when_absent(monkeypatch): + monkeypatch.setattr(bridge, "list_comments", lambda fn, n: [{"id": 1, "body": "hello"}]) + assert bridge.find_existing_comment("org/repo", 5) is None