From b7f6a8bb9ce1515dae4189a658fefc6352b8fc69 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 02:29:25 +0000 Subject: [PATCH] agent-exec transport e4ff44791a4210144c6165ceadebb342/692536c1c7d0 --- AGENTS.md | 2 + Makefile | 10 +- scripts/check-worktree.py | 2 +- scripts/ci_wait.py | 159 +++++++++++++++++++++++++ scripts/task.sh | 4 +- scripts/task_triage.py | 5 +- tests/test_check_worktree.py | 36 ++++++ tests/test_ci_wait.py | 219 +++++++++++++++++++++++++++++++++++ 8 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 scripts/ci_wait.py create mode 100644 tests/test_check_worktree.py create mode 100644 tests/test_ci_wait.py diff --git a/AGENTS.md b/AGENTS.md index 01e23625..d3cd45ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,8 @@ policy/delivery.md。 - 主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:` 四段;任务卡与 PR 描述为同一份文档。 - 跨仓 gitlink 变更须经组合验证(`make test`)后在 Dev 仓推进;受保护分支经 PR 合入。 +- **等待 CI** 只用一条阻塞命令 `make ci-wait SHA=<完整 SHA>`(15 s 轮询、失败即停、默认 20 分钟超时); + 禁止手写 `sleep` + `curl` 轮询循环,禁止对同一 SHA 重复/并行轮询;等待期间去做其他独立工作。 - **高后果操作双钥匙**:改写历史、解除/变更分支保护、删除归档引用、发布版本——Agent 只准备 方案与命令,由用户确认并亲手执行;Agent 不得自行实施(baseline 硬边界 1 的授权范围要求)。 - 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。 diff --git a/Makefile b/Makefile index d1fd4f17..f63b68b2 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PLUGINS := plugins COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO) -.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit +.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check ci-wait facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit version: @echo "agent-system $(VERSION)" @@ -32,6 +32,7 @@ export TASK_INITIATIVE setup: git config core.hooksPath scripts/hooks + git config submodule.fetchJobs 8 chmod +x scripts/hooks/pre-commit scripts/hooks/post-merge scripts/task.sh scripts/evidence.sh scripts/facts-verify.sh scripts/agent-exec @echo "OK: pre-commit/post-merge 钩子已启用(core.hooksPath=scripts/hooks)" @@ -66,6 +67,10 @@ task-finish: task-pr-check: python3 scripts/task_pr_check.py local $(if $(PR),--pr $(PR)) $(if $(FINAL),--final) +# CI 等待(单条阻塞命令):轮询 Gitea 合并状态,失败即停;两次连续全绿才算成功。默认 SHA=HEAD、20 分钟超时。 +ci-wait: + python3 scripts/ci_wait.py $(if $(SHA),--sha $(SHA)) $(if $(TIMEOUT),--timeout $(TIMEOUT)) $(if $(INTERVAL),--interval $(INTERVAL)) $(if $(CONTEXTS),--contexts $(CONTEXTS)) + # 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。 # 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。 # 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。 @@ -100,6 +105,7 @@ help: @echo "make task-triage-decide TRIAGE_RECEIPT= VERDICT=JOIN|NEW [INITIATIVE=] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)" @echo "make task-start NAME=feature/ TRIAGE_RECEIPT= [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)" @echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)" + @echo "make ci-wait [SHA=<完整 SHA>] [TIMEOUT=1200] [INTERVAL=15] [CONTEXTS=a,b] 阻塞等待该 SHA 的 CI 结论(失败即停)" agent-exec-validate: scripts/agent-exec validate --repo . @@ -112,7 +118,7 @@ agent-exec-admit: scripts/agent-exec admit --repo . sync: - git submodule update --init --recursive + git submodule update --init --recursive --jobs 8 status: @git submodule status diff --git a/scripts/check-worktree.py b/scripts/check-worktree.py index a9503cc4..cba774c0 100644 --- a/scripts/check-worktree.py +++ b/scripts/check-worktree.py @@ -97,7 +97,7 @@ def materialize(repo): for child, expected in submodules(repo): check_children(repo, "before-update") result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init", - "--checkout", "--", str(child.relative_to(repo))]) + "--jobs", "8", "--checkout", "--", str(child.relative_to(repo))]) if result.returncode: raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying") materialize(child) diff --git a/scripts/ci_wait.py b/scripts/ci_wait.py new file mode 100644 index 00000000..6d4b712d --- /dev/null +++ b/scripts/ci_wait.py @@ -0,0 +1,159 @@ +#!/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、每个 --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") +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): + """满足全绿与 --contexts 时返回可比较的集合键,否则 None。""" + if not rows or any(state != "success" 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()) diff --git a/scripts/task.sh b/scripts/task.sh index ac0d6d28..c5ddbbac 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -230,7 +230,7 @@ cmd_start() { git worktree add -q "$wt" -b "$name" "$start_ref" # 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针 # 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。 - (cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \ + (cd "$wt" && git submodule update --init --recursive --jobs 8 >/dev/null 2>&1) \ || echo "[warn] submodule update 非零退出,见下方状态核对" local sub_drift sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true) @@ -956,7 +956,7 @@ cmd_finish() { return 1 fi # Keep card closure separate: its push/PR side effects are not implied by local disposal. - echo "[next] inspect task-card closure with make task-close in the main checkout when authorized" + echo "[next] cards are normally fold-closed before merge with make task-close SELF=1; only legacy cards reported by precheck [cards] need make task-close in the main checkout when authorized" } case "${1:-}" in diff --git a/scripts/task_triage.py b/scripts/task_triage.py index acf83b24..32c03fd1 100644 --- a/scripts/task_triage.py +++ b/scripts/task_triage.py @@ -51,10 +51,11 @@ EXIT_VERIFY = 8 class Stop(Exception): """受控失败:退出码 + 不含 token 的说明。""" - def __init__(self, code, message): + def __init__(self, code, message, status=None): super().__init__(message) self.code = code self.message = message + self.status = status def utc_now(): @@ -152,7 +153,7 @@ def http_get_json(url, token): with urllib.request.urlopen(request, timeout=20) as response: body = response.read() except urllib.error.HTTPError as exc: - raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}") + raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code) except (urllib.error.URLError, http.client.HTTPException, OSError) as exc: raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})") try: diff --git a/tests/test_check_worktree.py b/tests/test_check_worktree.py new file mode 100644 index 00000000..bd300932 --- /dev/null +++ b/tests/test_check_worktree.py @@ -0,0 +1,36 @@ +"""scripts/check-worktree.py 的组件物化参数:逐个 submodule 物化,并以 --jobs 8 并行拉取。""" +import importlib.util +import subprocess +import unittest +from pathlib import Path +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check-worktree.py" +spec = importlib.util.spec_from_file_location("check_worktree", SCRIPT) +check_worktree = importlib.util.module_from_spec(spec) +spec.loader.exec_module(check_worktree) + + +class MaterializeTests(unittest.TestCase): + def test_submodule_update_uses_parallel_jobs_per_component(self): + repo = Path("/fixture/repo") + child = repo / "modules" / "core" + calls = [] + + def fake_submodules(path): + return [(child, "a" * 40)] if path == repo else [] + + def fake_run(argv, *args, **kwargs): + calls.append(argv) + return subprocess.CompletedProcess(argv, 0) + + with mock.patch.object(check_worktree, "submodules", fake_submodules), \ + mock.patch.object(check_worktree, "check_children", lambda *a: None), \ + mock.patch.object(check_worktree.subprocess, "run", fake_run): + check_worktree.materialize(repo) + self.assertEqual(calls, [["git", "-C", str(repo), "submodule", "update", "--init", + "--jobs", "8", "--checkout", "--", "modules/core"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ci_wait.py b/tests/test_ci_wait.py new file mode 100644 index 00000000..d6fe4b4b --- /dev/null +++ b/tests/test_ci_wait.py @@ -0,0 +1,219 @@ +"""CI 等待(scripts/ci_wait.py):临时 Git 仓库 + 进程内 Gitea 合并状态桩 + 注入的时钟与 sleep。 + +全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。 +""" +import contextlib +import http.server +import io +import json +import os +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + +SOURCE = Path(__file__).resolve().parents[1] +SCRIPT = SOURCE / "scripts" / "ci_wait.py" +sys.path.insert(0, str(SOURCE / "scripts")) + +import ci_wait # noqa: E402 + +TOKEN = "synthetic-gitea-token-not-real" + + +def status(context, state, url=None): + return {"context": context, "status": state, "target_url": url or f"https://ci.invalid/{context}"} + + +class FakeGitea: + """Gitea 合并状态桩(127.0.0.1):校验 token;每次 GET 依次返回脚本中的下一份 statuses(末项重复)。""" + + def __init__(self, token=TOKEN): + self.token = token + self.script = [] + self.requests = [] + stub = self + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def _send(self, code, obj): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.headers.get("Authorization") != f"token {stub.token}": + return self._send(401, {"message": "unauthorized"}) + parts = self.path.split("?", 1)[0].split("/") + if parts[1:4] != ["api", "v1", "repos"] or len(parts) != 9 or parts[6] != "commits" \ + or parts[8] != "status": + return self._send(404, {"message": "not found"}) + stub.requests.append("/".join(parts[4:6]) + "@" + parts[7]) + statuses = stub.script.pop(0) if len(stub.script) > 1 else (stub.script or [[]])[0] + return self._send(200, {"sha": parts[7], "statuses": statuses}) + + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1" + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def close(self): + self.server.shutdown() + self.server.server_close() + + +def closed_port_url(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + return f"http://127.0.0.1:{port}/api/v1" + + +class FakeClock: + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def clock(self): + return self.now + + def sleep(self, seconds): + self.sleeps.append(seconds) + self.now += seconds + + +class CiWaitTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="ci-wait-") + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.repo = root / "repo" + env = {k: v for k, v in os.environ.items() + if not k.startswith("GIT_") and k not in {"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN"}} + env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_TERMINAL_PROMPT": "0", + "GIT_AUTHOR_NAME": "CI Wait Test", "GIT_AUTHOR_EMAIL": "ci-wait@example.invalid", + "GIT_COMMITTER_NAME": "CI Wait Test", "GIT_COMMITTER_EMAIL": "ci-wait@example.invalid", + "HOME": str(root / "no-home"), + "NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost", + "GITEA_TOKEN": TOKEN}) + self.gitea = FakeGitea() + self.addCleanup(self.gitea.close) + env["GITEA_API_URL"] = self.gitea.url + self.env = env + env_patch = mock.patch.dict(os.environ, env, clear=True) + env_patch.start() + self.addCleanup(env_patch.stop) + self.git(root, "init", "--initial-branch=main", str(self.repo)) + (self.repo / "README").write_text("fixture\n") + self.git(self.repo, "add", "README") + self.git(self.repo, "commit", "-m", "Create fixture") + self.git(self.repo, "remote", "add", "origin", "ssh://git@git-ssh.example.invalid:31411/fixture/repo.git") + self.sha = self.git(self.repo, "rev-parse", "HEAD") + cwd = os.getcwd() + os.chdir(self.repo) + self.addCleanup(os.chdir, cwd) + + def git(self, cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip() + + def run_wait(self, *args, env=None): + clock = FakeClock() + out = io.StringIO() + with mock.patch.dict(os.environ, env or {}), contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(out): + code = ci_wait.main(list(args), sleep=clock.sleep, clock=clock.clock) + text = out.getvalue() + self.assertNotIn(TOKEN, text) + return code, text, clock + + def test_success_requires_two_consecutive_all_green_polls(self): + self.gitea.script = [[status("ci/test", "pending")], [status("ci/test", "pending")], + [status("ci/test", "success")], [status("ci/test", "success")]] + code, out, clock = self.run_wait("--timeout", "300") + self.assertEqual(code, 0, out) + self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out) + self.assertEqual(len(self.gitea.requests), 4, out) + self.assertEqual(self.gitea.requests[0], f"fixture/repo@{self.sha}") + self.assertEqual(clock.sleeps, [15.0, 15.0, 15.0], out) + # 状态行只在变化时打印:pending 与 success 各一行。 + lines = [line for line in out.splitlines() if line.startswith("[ci-wait] sha=")] + self.assertEqual(len(lines), 2, out) + + def test_late_registering_job_does_not_succeed_early(self): + self.gitea.script = [[status("ci/test", "success")], + [status("ci/test", "success"), status("ci/build", "pending")], + [status("ci/test", "success"), status("ci/build", "success")], + [status("ci/test", "success"), status("ci/build", "success")]] + code, out, _ = self.run_wait("--timeout", "300") + self.assertEqual(code, 0, out) + self.assertEqual(len(self.gitea.requests), 4, out) + self.assertIn("ci/build=pending", out) + + def test_failure_exits_fast_with_target_url(self): + url = "https://ci.invalid/runs/42" + self.gitea.script = [[status("ci/test", "pending"), status("ci/build", "success")], + [status("ci/test", "failure", url), status("ci/build", "success")]] + code, out, _ = self.run_wait("--sha", self.sha, "--timeout", "300") + self.assertEqual(code, 1, out) + self.assertEqual(len(self.gitea.requests), 2, out) + self.assertIn(f"[ci-wait] FAIL ci/test {url}", out) + self.assertNotIn("FAIL ci/build", out) + self.assertIn(f"[verdict] CI_FAILED sha={self.sha}", out) + + def test_missing_required_context_waits_until_timeout(self): + self.gitea.script = [[status("ci/test", "success")]] + code, out, clock = self.run_wait("--contexts", "ci/test,e2e", "--interval", "5", "--timeout", "30") + self.assertEqual(code, 2, out) + self.assertEqual(clock.now, 30.0) + self.assertIn("last observed: ci/test=success", out) + self.assertIn("missing contexts: e2e", out) + self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out) + + def test_context_names_match_as_substrings(self): + self.gitea.script = [[status("CI / test (pull_request)", "success")]] + code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60") + self.assertEqual(code, 0, out) + + def test_unreachable_api_exits_6_after_retrying_until_deadline(self): + code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_API_URL": closed_port_url()}) + self.assertEqual(code, 6, out) + self.assertEqual(clock.now, 60.0) + self.assertIn("[verdict] REMOTE_UNVERIFIED", out) + + def test_unauthorized_or_missing_token_exits_6_immediately(self): + code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_TOKEN": "wrong-token"}) + self.assertEqual(code, 6, out) + self.assertEqual(clock.sleeps, [], out) + self.assertIn("HTTP 401", out) + with mock.patch.dict(os.environ): + del os.environ["GITEA_TOKEN"] + code, out, _ = self.run_wait("--timeout", "60") + self.assertEqual(code, 6, out) + self.assertIn("[verdict] REMOTE_UNVERIFIED", out) + + def test_sha_must_resolve_to_full_sha(self): + code, out, _ = self.run_wait("--sha", "not-a-ref") + self.assertEqual(code, 2, out) + self.assertIn("full 40-hex SHA", out) + self.assertEqual(self.gitea.requests, []) + + def test_cli_entry_point_reports_remote_unverified(self): + env = self.env | {"GITEA_API_URL": closed_port_url()} + result = subprocess.run([sys.executable, str(SCRIPT), "--interval", "5", "--timeout", "0.2"], + cwd=self.repo, env=env, capture_output=True, text=True, timeout=60) + out = result.stdout + result.stderr + self.assertEqual(result.returncode, 6, out) + self.assertIn("[verdict] REMOTE_UNVERIFIED", out) + self.assertNotIn(TOKEN, out) + + +if __name__ == "__main__": + unittest.main()