feat(3 U3): YunoHost-style PR comment (🌻 + level badge + summary card images, linked) updated in place per PR; text fallback; bridge tests + dashboard do_HEAD

This commit is contained in:
autonomic-bot
2026-05-31 07:46:00 +00:00
parent 656faa3d8e
commit 9a47aa28e3
3 changed files with 144 additions and 30 deletions

View File

@ -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/<run_id>/...). The PR comment (U3) embeds the card + badge from here. The
# run_id is the Drone build number (== `num`), so the URLs are /runs/<num>/{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 = "<!-- cc-ci:testme -->"
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}"

View File

@ -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/<run_id>/<file> — 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

View File

@ -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/<num>/ 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