#!/usr/bin/env python3 """CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。 用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]] --sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。 轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status: - 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL `,立即退出 1; - 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串, 且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿); - 超时:打印最后观察到的状态,退出 2; - 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试, 截止前从未成功轮询才以 6 放弃)。 状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=`。 退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。 仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。 """ import argparse import re import subprocess import sys import time import urllib.parse from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug EXIT_FAILED = 1 EXIT_TIMEOUT = 2 EXIT_USAGE = 2 SHA_RE = re.compile(r"^[0-9a-f]{40}$") FAILING = ("failure", "error") # skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。 DONE_OK = ("success", "skipped", "warning") FATAL_HTTP = (400, 401, 403, 404, 410) def git(*args): proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace") return proc.stdout.strip() if proc.returncode == 0 else None def resolve_sha(sha): value = (sha or "HEAD").strip() if not SHA_RE.match(value.lower()): value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or "" value = value.lower() if not SHA_RE.match(value): raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA") return value def interval_arg(text): value = float(text) if value < 5: raise argparse.ArgumentTypeError("interval must be at least 5 seconds") return value def timeout_arg(text): value = float(text) if value <= 0: raise argparse.ArgumentTypeError("timeout must be positive") return value def observe(data): """[(context, state, target_url)],按 context 排序;忽略非 dict 项。""" items = data.get("statuses") if isinstance(data, dict) else None rows = [] for item in items if isinstance(items, list) else []: if not isinstance(item, dict): continue state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown" rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or ""))) return sorted(rows) def describe(rows): return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)" def green_key(rows, contexts): """满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。""" if not rows or any(state not in DONE_OK for _, state, _ in rows): return None if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts): return None return tuple((ctx, state) for ctx, state, _ in rows) def wait(sha, interval, timeout, contexts, sleep, clock): token = read_token() if not token: raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)") origin = git("remote", "get-url", "origin") if not origin: raise Stop(EXIT_REMOTE, "cannot read origin remote URL") owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/")) url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status" deadline = clock() + timeout rows, shown, previous_green, polled, last_error = [], None, None, False, None while True: try: data = http_get_json(url, token) except Stop as stop: if stop.status in FATAL_HTTP: raise last_error = stop.message else: polled = True rows = observe(data) line = describe(rows) if line != shown: print(f"[ci-wait] sha={sha[:12]} {line}", flush=True) shown = line failing = [row for row in rows if row[1] in FAILING] if failing: for ctx, _, target in failing: print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}") print(f"[verdict] CI_FAILED sha={sha}") return EXIT_FAILED key = green_key(rows, contexts) if key is not None and key == previous_green: print(f"[verdict] CI_SUCCESS sha={sha}") return 0 previous_green = key remaining = deadline - clock() if remaining <= 0: break sleep(min(interval, remaining)) if not polled: raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline") print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}") missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)] if missing: print(f"[ci-wait] missing contexts: {', '.join(missing)}") print(f"[verdict] CI_TIMEOUT sha={sha}") return EXIT_TIMEOUT def main(argv=None, sleep=time.sleep, clock=time.monotonic): parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论") parser.add_argument("--sha") parser.add_argument("--interval", type=interval_arg, default=15.0) parser.add_argument("--timeout", type=timeout_arg, default=1200.0) parser.add_argument("--contexts", default="") args = parser.parse_args(argv) contexts = [name.strip() for name in args.contexts.split(",") if name.strip()] try: sha = resolve_sha(args.sha) return wait(sha, args.interval, args.timeout, contexts, sleep, clock) except Stop as stop: print(f"[ci-wait] ERROR {stop.message}") if stop.code == EXIT_REMOTE: print("[verdict] REMOTE_UNVERIFIED") return stop.code if __name__ == "__main__": sys.exit(main())