agent-exec transport e4ff44791a4210144c6165ceadebb342/692536c1c7d0
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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=<path> VERDICT=JOIN|NEW [INITIATIVE=<branch>] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)"
|
||||
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <context> <target_url>`,立即退出 1;
|
||||
- 成功须同时满足:至少一条状态、全部 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")
|
||||
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())
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user