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}"