Files
Agent-System-Dev/scripts/ci_wait.py
T

162 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
截止前从未成功轮询才以 6 放弃)。
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<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())