"""需求分流收据契约(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()