feat(2w): W2 WC7 trigger surface — bridge parses !testme --quick

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 03:10:56 +01:00
parent 191ebde466
commit 9afc7f64b9
3 changed files with 76 additions and 13 deletions

View File

@ -64,6 +64,8 @@ steps:
# means no concurrent build shares /root/.abra. # means no concurrent build shares /root/.abra.
HOME: /root HOME: /root
commands: commands:
# RECIPE/REF/PR/SRC are injected as env vars from the build's custom params. # RECIPE/REF/PR/SRC (+ CCCI_QUICK for `!testme --quick`) are injected as env vars from the
- 'echo "recipe-ci: RECIPE=$RECIPE REF=$REF PR=$PR SRC=$SRC stages=$STAGES"' # 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 - cc-ci-run runner/run_recipe_ci.py

View File

@ -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") DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net")
CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci")
TRIGGER = "!testme" 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()} 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")} return {"sha": head.get("sha"), "repo": (head.get("repo") or {}).get("full_name")}
def trigger_build(recipe, ref, pr, src): def trigger_build(recipe, ref, pr, src, quick=False):
# Drone "create build" with custom params -> exposed to the pipeline as env vars. # Drone "create build" with custom params -> exposed to the pipeline as env vars. `--quick`
q = urllib.parse.urlencode( # (WC7) sets CCCI_QUICK=1 so run_recipe_ci takes the opt-in fast lane; absent => full cold.
{"branch": "main", "RECIPE": recipe, "REF": ref, "PR": str(pr), "SRC": src} 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}" url = f"{DRONE_URL}/api/repos/{CI_REPO}/builds?{q}"
status, build = _api(url, DRONE_TOKEN, method="POST", scheme="Bearer") status, build = _api(url, DRONE_TOKEN, method="POST", scheme="Bearer")
if status in (200, 201) and build: if status in (200, 201) and build:
@ -190,7 +205,7 @@ def _claim(comment_id) -> bool:
return True 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, """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).""" triggers the build, comments the run link. Returns (run_url|None, reason)."""
if not _claim(comment_id): 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) head = pr_head(owner, name, number)
if not head or not head["sha"]: if not head or not head["sha"]:
return None, "cannot resolve PR head" 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: if not num:
post_comment(owner, name, number, "cc-ci: failed to start a CI run (see bridge logs).") post_comment(owner, name, number, "cc-ci: failed to start a CI run (see bridge logs).")
return None, "trigger failed" return None, "trigger failed"
run_url = f"{DRONE_URL}/{CI_REPO}/{num}" run_url = f"{DRONE_URL}/{CI_REPO}/{num}"
mode = " **(--quick: lower-confidence fast lane; does not gate merge)**" if quick else ""
cid = post_comment( 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( log(
f"[{source}] triggered build {num} for {name}@{head['sha'][:8]} " f"[{source}] triggered build {num} for {name}@{head['sha'][:8]} "
@ -255,7 +272,8 @@ class Handler(BaseHTTPRequestHandler):
c = payload.get("comment") or {} c = payload.get("comment") or {}
issue = payload.get("issue") or {} issue = payload.get("issue") or {}
repo = payload.get("repository") 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") return self._send(204, "ignored")
if not issue.get("pull_request"): if not issue.get("pull_request"):
return self._send(204, "not a PR") return self._send(204, "not a PR")
@ -268,6 +286,7 @@ class Handler(BaseHTTPRequestHandler):
c.get("user", {}).get("login", ""), c.get("user", {}).get("login", ""),
c.get("id"), c.get("id"),
"webhook", "webhook",
quick=quick,
) )
if not run_url: if not run_url:
if reason == "duplicate": if reason == "duplicate":
@ -292,14 +311,15 @@ def poll_loop():
for pr in list_open_prs(full_name): for pr in list_open_prs(full_name):
number = pr.get("number") number = pr.get("number")
for c in list_comments(full_name, 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 continue
cid = c.get("id") cid = c.get("id")
if first: if first:
_claim(cid) # mark pre-existing comments seen; don't fire on startup _claim(cid) # mark pre-existing comments seen; don't fire on startup
continue continue
user = (c.get("user") or {}).get("login", "") 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 first = False
time.sleep(interval) time.sleep(interval)

View File

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