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:
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
41
tests/unit/test_bridge_trigger.py
Normal file
41
tests/unit/test_bridge_trigger.py
Normal 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
|
||||||
Reference in New Issue
Block a user