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:
@ -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[]({run_url})"
|
||||
if artifact_available(badge_url):
|
||||
body += f"\n\n[]({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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 "" 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
|
||||
|
||||
Reference in New Issue
Block a user