Files
Agent-System-Dev/tests/test_task_triage.py
T

458 lines
22 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.
"""需求分流收据契约(scripts/task_triage.py):远端发现、显式决策、核验拒绝。
全部对真实临时 Git 仓库(bare origin + clone)与进程内 Gitea 桩运行;不联网,不读取真实 ~/.secrets。
"""
import hashlib
import json
import os
import socket
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_task import FakeGitea # noqa: E402
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "task_triage.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import task_triage # noqa: E402
def card_text(branch, kind="initiative", accepting="true", root="main", status="进行中"):
slug = branch.replace("/", "-")
return (
f"# {branch} 任务卡\n"
f"任务主记录:fixture;状态:{status};owner:Triage Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= 0\n"
f"- 工作区:fixture(worktree);分支:{branch}\n"
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{root};accepting_requirements:{accepting}\n\n"
"## 目标与非目标\n\n- 交付登录能力\n- 不做第三方登录\n\n## 其它\n\n- 无\n"
)
class TriageTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="triage-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.remote = self.root / "remote.git"
self.repo = self.root / "repo"
self.receipt = self.root / "receipt.json"
self.env = os.environ.copy()
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Triage Test", "GIT_AUTHOR_EMAIL": "triage@example.invalid",
"GIT_COMMITTER_NAME": "Triage Test", "GIT_COMMITTER_EMAIL": "triage@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_REPO": "fixture/repo", "GITEA_TOKEN": "dummy"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_API_URL", "TASK_TARGET_BRANCH",
"TRIAGE_RECEIPT"):
self.env.pop(key, None)
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(self.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", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
self.gitea = FakeGitea(token="dummy")
self.addCleanup(self.gitea.close)
self.env["GITEA_API_URL"] = self.gitea.url
# ---- 夹具 ----
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, check=True)
def make_initiative(self, branch, **card):
self.git(self.repo, "switch", "-c", branch, "main")
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.parent.mkdir(exist_ok=True)
path.write_text(card_text(branch, **card), encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch}")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
return self.head(branch)
def advance_initiative(self, branch):
self.git(self.repo, "switch", branch)
(self.repo / "more-work.txt").write_text("later initiative work\n")
self.git(self.repo, "add", "more-work.txt")
self.git(self.repo, "commit", "-m", "More initiative work")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
def head(self, ref):
return subprocess.run(["git", "rev-parse", ref], cwd=self.repo, env=self.env,
capture_output=True, text=True, check=True).stdout.strip()
def open_pr(self, number, branch, base="main", state="open"):
self.gitea.prs.append(FakeGitea.pr(number, branch, self.head(branch), base=base, state=state))
def run_script(self, *args, env=None):
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo,
env=self.env | (env or {}), capture_output=True, text=True, timeout=60)
result.out = result.stdout + result.stderr
return result
def discover(self, env=None, requirement="add login", receipt=None):
return self.run_script("discover", "--receipt", str(receipt or self.receipt),
"--requirement", requirement, env=env)
def decide(self, verdict, reason="", initiative=None):
args = ["decide", "--receipt", str(self.receipt), "--verdict", verdict, "--reason", reason]
if initiative:
args += ["--initiative", initiative]
return self.run_script(*args)
def verify(self, kind, initiative=None, max_age=None, env=None):
args = ["verify", "--receipt", str(self.receipt), "--kind", kind]
if initiative:
args += ["--initiative", initiative]
if max_age is not None:
args += ["--max-age-seconds", str(max_age)]
return self.run_script(*args, env=env)
def load(self):
return json.loads(self.receipt.read_text(encoding="utf-8"))
def save(self, data):
self.receipt.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def discover_single(self, branch="feature/alpha"):
self.make_initiative(branch)
self.open_pr(len(self.gitea.prs) + 1, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
return result
# ---- 发现 ----
def test_remote_initiative_absent_from_main_tree_is_discovered(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.assertNotIn(".task/feature-alpha.md",
subprocess.run(["git", "ls-tree", "-r", "--name-only", "main"], cwd=self.repo,
env=self.env, capture_output=True, text=True).stdout)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[verdict] DECISION_REQUIRED", result.out)
self.assertIn("make task-triage-decide", result.out)
data = self.load()
cand = data["candidates"][0]
self.assertEqual(cand["head_sha"], head)
self.assertEqual(cand["card_path"], ".task/feature-alpha.md")
self.assertEqual(cand["pr_base"], "main")
self.assertEqual(cand["pr_number"], 1)
self.assertIn("交付登录能力", cand["goal_excerpt"])
blob = subprocess.run(["git", "cat-file", "blob", f"{head}:.task/feature-alpha.md"],
cwd=self.repo, env=self.env, capture_output=True, check=True).stdout
self.assertEqual(cand["card_sha256"], hashlib.sha256(blob).hexdigest())
self.assertEqual((data["final_verdict"], data["discovery_verdict"]),
("DECISION_REQUIRED", "DECISION_REQUIRED"))
self.assertIsNone(data["decision"])
def test_zero_candidates_yields_new_without_selecting_an_initiative(self):
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] NEW", result.out)
data = self.load()
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
self.assertEqual(data["candidates"], [])
self.assertEqual(data["decision"]["verdict"], "NEW")
self.assertIsNone(data["decision"]["selected_initiative"])
self.assertEqual(data["decision"]["rationale"], "no accepting initiative")
self.assertEqual(data["requirement"]["sha256"], hashlib.sha256(b"add login").hexdigest())
self.assertEqual(data["repo"]["repo_slug"], "fixture/repo")
self.assertEqual(data["root_target_sha"], self.head("main"))
def test_single_candidate_requires_decision_never_auto_join(self):
self.discover_single()
data = self.load()
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
self.assertIsNone(data["decision"])
def test_multiple_candidates_stop_at_decision_required(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[candidate] branch=feature/beta", result.out)
data = self.load()
self.assertEqual([c["branch"] for c in data["candidates"]], ["feature/alpha", "feature/beta"])
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
def test_pr_base_not_main_and_closed_pr_are_ignored(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha", base="release")
self.open_pr(2, "feature/beta", state="closed")
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
def test_card_filters_ignore_closed_intake_done_plain_and_other_root(self):
self.make_initiative("feature/active")
self.make_initiative("feature/closed-intake", accepting="false")
self.make_initiative("feature/done", status="已完成(回填)")
self.make_initiative("feature/plain", kind="standalone")
self.make_initiative("feature/other-root", root="release")
for number, branch in enumerate(("feature/active", "feature/closed-intake", "feature/done",
"feature/plain", "feature/other-root"), start=1):
self.open_pr(number, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/active"])
def test_remote_discovery_paginates_until_empty(self):
for number in range(1, 51):
self.gitea.prs.append(FakeGitea.pr(number, f"feature/noise-{number}", "0" * 40, base="release"))
self.make_initiative("feature/late")
self.open_pr(51, "feature/late")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/late"])
def test_api_unreachable_yields_remote_unverified_without_receipt(self):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
result = self.discover(env={"GITEA_API_URL": f"http://127.0.0.1:{port}/api/v1"})
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_ls_remote_failure_yields_remote_unverified_without_receipt(self):
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.discover()
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_token_from_secrets_file_is_used_and_never_printed(self):
home = self.root / "home"
(home / ".secrets").mkdir(parents=True)
(home / ".secrets" / "gitea").write_text(
"URL_PREFIX=https://example.invalid\n- ACCESS_TOKEN=file-token-7f3a\nACCESS_TOKEN=second-token\n")
self.gitea.token = "file-token-7f3a"
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
result = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""})
self.assertEqual(result.returncode, 5, result.out)
self.assertNotIn("file-token-7f3a", result.out)
self.assertNotIn("file-token-7f3a", self.receipt.read_text(encoding="utf-8"))
self.gitea.token = "other-token"
rejected = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""}, receipt=self.root / "other.json")
self.assertEqual(rejected.returncode, 6, rejected.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", rejected.out)
self.assertNotIn("file-token-7f3a", rejected.out)
def test_repo_slug_is_derived_from_origin_when_gitea_repo_unset(self):
owner_dir = self.root / "fixture-owner"
owner_dir.mkdir()
named = owner_dir / "fixture-repo.git"
self.git(self.root, "init", "--bare", "--initial-branch=main", str(named))
self.git(self.repo, "remote", "set-url", "origin", str(named))
self.git(self.repo, "push", "-u", "origin", "main")
result = self.discover(env={"GITEA_REPO": ""})
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["repo"]["repo_slug"], "fixture-owner/fixture-repo")
def test_discover_requires_requirement_text(self):
result = self.discover(requirement=" ")
self.assertEqual(result.returncode, 2, result.out)
# ---- 决策 ----
def test_join_decision_succeeds_and_verifies_child(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
decided = self.decide("JOIN", "加入登录主线", initiative="feature/alpha")
self.assertEqual(decided.returncode, 0, decided.out)
data = self.load()
self.assertEqual(data["final_verdict"], "JOIN")
self.assertEqual(data["decision"]["selected_initiative"], "feature/alpha")
verified = self.verify("child", initiative="feature/alpha")
self.assertEqual(verified.returncode, 0, verified.out)
self.assertIn("[triage-verified] verdict=JOIN selected_initiative=feature/alpha "
"requirement_sha256=", verified.out)
self.assertIn(f"[triage-head] selected_head_sha={head}", verified.out)
def test_new_decision_for_single_non_fitting_candidate_requires_reason(self):
self.discover_single()
before = self.receipt.read_bytes()
no_reason = self.decide("NEW", "")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
decided = self.decide("NEW", "不适配:独立的后台巡检任务")
self.assertEqual(decided.returncode, 0, decided.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
self.assertEqual(self.verify("standalone").returncode, 0)
def test_decide_rejects_unknown_initiative_or_missing_reason_without_changing_receipt(self):
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
before = self.receipt.read_bytes()
unknown = self.decide("JOIN", "理由", initiative="feature/beta")
self.assertEqual(unknown.returncode, 7, unknown.out)
no_reason = self.decide("JOIN", "", initiative="feature/alpha")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
def test_decide_rejects_candidate_head_change_and_keeps_receipt(self):
self.discover_single()
before = self.receipt.read_bytes()
self.advance_initiative("feature/alpha")
result = self.decide("JOIN", "加入", initiative="feature/alpha")
self.assertEqual(result.returncode, 7, result.out)
self.assertIn("candidate set changed", result.out)
self.assertEqual(self.receipt.read_bytes(), before)
# ---- 核验 ----
def test_verify_rejects_stale_receipt(self):
self.assertEqual(self.discover().returncode, 0)
self.assertEqual(self.verify("standalone").returncode, 0)
data = self.load()
data["discovered_at"] = "2001-01-01T00:00:00Z"
self.save(data)
result = self.verify("standalone")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("stale", result.out)
def test_verify_rejects_changed_candidate_head(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
self.advance_initiative("feature/alpha")
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("candidates changed", result.out)
def test_verify_rejects_changed_card_digest(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
data = self.load()
data["candidates"][0]["card_sha256"] = "0" * 64
self.save(data)
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_verdict_and_kind_mismatch(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
standalone = self.verify("standalone")
self.assertEqual(standalone.returncode, 8, standalone.out)
self.decide("NEW", "不适配")
child = self.verify("child", initiative="feature/alpha")
self.assertEqual(child.returncode, 8, child.out)
def test_verify_rejects_child_with_wrong_initiative(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
self.discover()
self.decide("JOIN", "加入", initiative="feature/alpha")
result = self.verify("child", initiative="feature/beta")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_root_target_mismatch(self):
self.discover()
result = self.verify("standalone", env={"TASK_TARGET_BRANCH": "release"})
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("root target", result.out)
def test_verify_rejects_wrong_schema_and_missing_receipt(self):
self.discover()
data = self.load()
data["schema"] = "agent-system/other/v9"
self.save(data)
self.assertEqual(self.verify("standalone").returncode, 8)
self.receipt.unlink()
missing = self.verify("standalone")
self.assertEqual(missing.returncode, 8, missing.out)
class RedactionTests(unittest.TestCase):
def test_userinfo_is_stripped_from_urls_and_scp_forms(self):
self.assertEqual(task_triage.redact_url("https://user:secret@git.example:30319/o/r.git"),
"https://git.example:30319/o/r.git")
self.assertEqual(task_triage.redact_url("git@git-ssh.example:weichen/Repo.git"),
"git-ssh.example:weichen/Repo.git")
self.assertEqual(task_triage.redact_url("/tmp/plain/remote.git"), "/tmp/plain/remote.git")
class TokenSafetyTests(unittest.TestCase):
FAKE_A = "a" * 40
FAKE_B = "b" * 40
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix="triage-token-")
self.addCleanup(tmp.cleanup)
self.home = Path(tmp.name)
def env(self, token=""):
return mock.patch.dict(os.environ, {"HOME": str(self.home), "GITEA_TOKEN": token})
def write_secrets(self, text):
(self.home / ".secrets").mkdir(exist_ok=True)
(self.home / ".secrets" / "gitea").write_text(text, encoding="utf-8")
def test_multiline_env_token_is_rejected_without_echoing_values(self):
with self.env(f"{self.FAKE_A}\n{self.FAKE_B}"):
with self.assertRaises(task_triage.Stop) as caught:
task_triage.read_token()
self.assertEqual(caught.exception.code, task_triage.EXIT_REMOTE)
self.assertIn("malformed", caught.exception.message)
for value in (self.FAKE_A, self.FAKE_B):
self.assertNotIn(value, caught.exception.message)
self.assertNotIn(value, str(caught.exception))
def test_secrets_file_with_sections_yields_home_value(self):
self.write_secrets("IMC:\n- URL_PREFIX=https://imc.invalid\n- ACCESS_TOKEN=" + self.FAKE_B + "\n"
"HOME:\n- URL_PREFIX=https://home.invalid\n- ACCESS_TOKEN=" + self.FAKE_A + "\n")
with self.env():
self.assertEqual(task_triage.read_token(), self.FAKE_A)
def test_secrets_file_without_home_access_token_returns_none(self):
self.write_secrets("HOME:\n- URL_PREFIX=https://home.invalid\nIMC:\n- ACCESS_TOKEN=" + self.FAKE_B + "\n")
with self.env():
self.assertIsNone(task_triage.read_token())
def test_missing_configuration_returns_none(self):
with self.env():
self.assertIsNone(task_triage.read_token())
def test_request_value_error_does_not_leak_token(self):
token = self.FAKE_A
leak = ValueError(f"Invalid header value b'token {token}\\n{self.FAKE_B}'")
with mock.patch.object(task_triage.urllib.request, "urlopen", side_effect=leak):
with self.assertRaises(task_triage.Stop) as caught:
task_triage.http_get_json("https://gitea.invalid/api/v1/x", token)
self.assertEqual(caught.exception.code, task_triage.EXIT_REMOTE)
self.assertEqual(caught.exception.message, "Gitea API request failed (ValueError)")
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
for value in (token, self.FAKE_B):
self.assertNotIn(value, caught.exception.message)
if __name__ == "__main__":
unittest.main()