From 9afc7f64b9593d8654dd82cb3110f0110befeb28 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Fri, 29 May 2026 03:10:56 +0100 Subject: [PATCH] =?UTF-8?q?feat(2w):=20W2=20WC7=20trigger=20surface=20?= =?UTF-8?q?=E2=80=94=20bridge=20parses=20!testme=20--quick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bridge/bridge.py: parse_trigger(body) → (is_trigger, quick); accepts exactly '!testme' (cold, default) and '!testme --quick' (opt-in fast lane), rejects '!testmexyz'/'!testme foo'/etc. Threaded through both poll + webhook paths and process_testme → trigger_build adds the CCCI_QUICK=1 Drone param (auto-exposed to run_recipe_ci). PR comment labels a quick run lower-confidence. .drone.yml echoes quick=. +3 unit tests (incl. the !testmexyz negative). 64 unit pass. WC7: default !testme stays full cold; --quick opt-in, never gates merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .drone.yml | 6 +++-- bridge/bridge.py | 42 +++++++++++++++++++++++-------- tests/unit/test_bridge_trigger.py | 41 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_bridge_trigger.py diff --git a/.drone.yml b/.drone.yml index 08478c5..a432206 100644 --- a/.drone.yml +++ b/.drone.yml @@ -64,6 +64,8 @@ steps: # means no concurrent build shares /root/.abra. HOME: /root commands: - # RECIPE/REF/PR/SRC are injected as env vars from the build's custom params. - - 'echo "recipe-ci: RECIPE=$RECIPE REF=$REF PR=$PR SRC=$SRC stages=$STAGES"' + # RECIPE/REF/PR/SRC (+ CCCI_QUICK for `!testme --quick`) are injected as env vars from the + # build's custom params. CCCI_QUICK=1 makes run_recipe_ci take the opt-in fast lane (WC7); + # absent => full cold (default). run_quick ignores STAGES (always upgrade+custom). + - 'echo "recipe-ci: RECIPE=$RECIPE REF=$REF PR=$PR SRC=$SRC stages=$STAGES quick=${CCCI_QUICK:-0}"' - cc-ci-run runner/run_recipe_ci.py diff --git a/bridge/bridge.py b/bridge/bridge.py index 9d6eabc..9824aa5 100644 --- a/bridge/bridge.py +++ b/bridge/bridge.py @@ -43,6 +43,19 @@ 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") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") TRIGGER = "!testme" + + +def parse_trigger(body): + """Parse a PR comment body into (is_trigger, quick). Exactly two accepted forms (trimmed): + `!testme` → (True, False) = full COLD run (default, authoritative); + `!testme --quick` → (True, True) = opt-in LOWER-CONFIDENCE fast lane (WC4/WC7). + Anything else (`!testmexyz`, `!testme foo`, prose) → (False, False) — must NOT trigger.""" + s = (body or "").strip() + if s == TRIGGER: + return True, False + if s == f"{TRIGGER} --quick": + return True, True + return False, False ALLOWLIST = {u.strip() for u in os.environ.get("AUTH_ALLOWLIST", "").split(",") if u.strip()} @@ -105,11 +118,13 @@ def pr_head(owner, repo, number): return {"sha": head.get("sha"), "repo": (head.get("repo") or {}).get("full_name")} -def trigger_build(recipe, ref, pr, src): - # Drone "create build" with custom params -> exposed to the pipeline as env vars. - q = urllib.parse.urlencode( - {"branch": "main", "RECIPE": recipe, "REF": ref, "PR": str(pr), "SRC": src} - ) +def trigger_build(recipe, ref, pr, src, quick=False): + # Drone "create build" with custom params -> exposed to the pipeline as env vars. `--quick` + # (WC7) sets CCCI_QUICK=1 so run_recipe_ci takes the opt-in fast lane; absent => full cold. + params = {"branch": "main", "RECIPE": recipe, "REF": ref, "PR": str(pr), "SRC": src} + if quick: + params["CCCI_QUICK"] = "1" + q = urllib.parse.urlencode(params) url = f"{DRONE_URL}/api/repos/{CI_REPO}/builds?{q}" status, build = _api(url, DRONE_TOKEN, method="POST", scheme="Bearer") if status in (200, 201) and build: @@ -190,7 +205,7 @@ def _claim(comment_id) -> bool: return True -def process_testme(full_name, owner, name, number, user, comment_id, source): +def process_testme(full_name, owner, name, number, user, comment_id, source, quick=False): """Shared by both paths. Dedupes by comment id, checks authorization, resolves the PR head, triggers the build, comments the run link. Returns (run_url|None, reason).""" if not _claim(comment_id): @@ -201,13 +216,15 @@ def process_testme(full_name, owner, name, number, user, comment_id, source): head = pr_head(owner, name, number) if not head or not head["sha"]: return None, "cannot resolve PR head" - num = trigger_build(name, head["sha"], number, head["repo"] or full_name) + num = trigger_build(name, head["sha"], number, head["repo"] or full_name, quick=quick) if not num: post_comment(owner, name, number, "cc-ci: failed to start a CI run (see bridge logs).") 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]}` → {run_url}" + owner, name, number, + f"cc-ci: started CI run for `{name}` @ `{head['sha'][:8]}`{mode} → {run_url}", ) log( f"[{source}] triggered build {num} for {name}@{head['sha'][:8]} " @@ -255,7 +272,8 @@ class Handler(BaseHTTPRequestHandler): c = payload.get("comment") or {} issue = payload.get("issue") or {} repo = payload.get("repository") or {} - if action != "created" or (c.get("body") or "").strip() != TRIGGER: + is_trigger, quick = parse_trigger(c.get("body")) + if action != "created" or not is_trigger: return self._send(204, "ignored") if not issue.get("pull_request"): return self._send(204, "not a PR") @@ -268,6 +286,7 @@ class Handler(BaseHTTPRequestHandler): c.get("user", {}).get("login", ""), c.get("id"), "webhook", + quick=quick, ) if not run_url: if reason == "duplicate": @@ -292,14 +311,15 @@ def poll_loop(): for pr in list_open_prs(full_name): number = pr.get("number") for c in list_comments(full_name, number): - if (c.get("body") or "").strip() != TRIGGER: + is_trigger, quick = parse_trigger(c.get("body")) + if not is_trigger: continue cid = c.get("id") if first: _claim(cid) # mark pre-existing comments seen; don't fire on startup continue user = (c.get("user") or {}).get("login", "") - process_testme(full_name, owner, name, number, user, cid, "poll") + process_testme(full_name, owner, name, number, user, cid, "poll", quick=quick) first = False time.sleep(interval) diff --git a/tests/unit/test_bridge_trigger.py b/tests/unit/test_bridge_trigger.py new file mode 100644 index 0000000..f981f0e --- /dev/null +++ b/tests/unit/test_bridge_trigger.py @@ -0,0 +1,41 @@ +"""Unit tests for the bridge's `!testme` / `!testme --quick` trigger parser (WC7 + D1). + +Pure: imports bridge/bridge.py (stdlib-only, no side effects at import — main() is __main__-guarded). +Locks the two accepted forms and the must-NOT-trigger cases the Adversary probes (`!testmexyz`, etc.). +""" + +from __future__ import annotations + +import os +import sys + +# bridge.py reads HMAC/DRONE/GITEA secret FILES at import; point them at /dev/null (readable, empty) +# so the import works in a unit context — parse_trigger doesn't use any of them. +for _v in ("HMAC_FILE", "DRONE_TOKEN_FILE", "GITEA_TOKEN_FILE"): + os.environ.setdefault(_v, "/dev/null") +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "bridge")) +import bridge # noqa: E402 + + +def test_plain_testme_is_cold(): + assert bridge.parse_trigger("!testme") == (True, False) + assert bridge.parse_trigger(" !testme ") == (True, False) # trimmed + + +def test_quick_form(): + assert bridge.parse_trigger("!testme --quick") == (True, True) + assert bridge.parse_trigger(" !testme --quick \n") == (True, True) + + +def test_non_trigger_forms_rejected(): + for body in ( + "!testmexyz", # the Adversary's classic negative + "!testme xyz", + "!testme--quick", # no space → not the quick form + "!testme --quick", # double space → not an exact match (conservative) + "please !testme", + "testme", + "", + None, + ): + assert bridge.parse_trigger(body) == (False, False), body