162 lines
6.6 KiB
Python
162 lines
6.6 KiB
Python
#!/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())
|