feat: full-auto mode v0.4 (host executor + skill + CI fix loop, stacked on v0.3)
Host auto executor (cloud/full_auto.py, stdlib only): default off (FULL_AUTO_ENABLED + verified installation), forbidden actions fail-closed (delete repo, force-push, visibility change, secret read/expose, branch-protection delete/bypass, GitHub account auto-registration), token only in request header, audit log redacted, branch protection requires PR + strict checks + no force-push + no deletions + enforce_admins, merge gate (CI green + directory check + contract version + coordinator approval), CI fix loop max 3 then escalate, rollback only via revert PR or previous tag. Skill: v0.4 full-auto mode, coordinator reviews, host executor executes, task agent never executes merge/deploy; repo-creation honesty branch (org via App token, user repo needs_user_once); no web auto-registration of GitHub accounts. Tests: 19 new, cloud full 1451 OK (skipped=4).
This commit is contained in:
1 parent
34ed438d0e
commit
a605ff2f92
3 files changed
+719
-9
No files matched your search
@@ -0,0 +1,220 @@
|
||||
"""全自动执行器(cloud/full_auto.py)的轻量测试。
|
||||
|
||||
覆盖护栏(默认关闭、禁止/未知动作)、合并门、自动配置
|
||||
计划顺序与分支保护 payload、计划执行(假客户端,不发
|
||||
真实网络)、CI 修复循环限次、发版/回滚不 force-push、
|
||||
审计记录不含 token。另断言技能正文与附录都含有全自动
|
||||
模式的关键概念(与 test_software_engineering_github.py
|
||||
同款正文+附录双向断言,附录没同步就会失败)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cloud import full_auto
|
||||
from cloud.full_auto import (
|
||||
FullAutoDenied,
|
||||
FullAutoError,
|
||||
append_audit_log,
|
||||
audit_record,
|
||||
auto_config_plan,
|
||||
branch_protection_payload,
|
||||
ci_fix_decision,
|
||||
is_full_auto_enabled,
|
||||
merge_gate,
|
||||
release_plan,
|
||||
rollback_plan,
|
||||
run_plan,
|
||||
validate_action,
|
||||
)
|
||||
|
||||
SKILL = Path(__file__).resolve().parents[1] / "skills" / "software-engineering" / "SKILL.md"
|
||||
|
||||
FULL_AUTO_KEYWORDS = ("全自动模式", "FULL_AUTO_ENABLED",
|
||||
"一次授权", "CI 自动修复", "回滚")
|
||||
|
||||
|
||||
class FullAutoGateTestCase(unittest.TestCase):
|
||||
def test_default_off_without_flag(self):
|
||||
self.assertFalse(is_full_auto_enabled({}, True))
|
||||
self.assertFalse(is_full_auto_enabled(
|
||||
{"FULL_AUTO_ENABLED": "1"}, False))
|
||||
self.assertFalse(is_full_auto_enabled(
|
||||
{"FULL_AUTO_ENABLED": "0"}, True))
|
||||
|
||||
def test_enabled_only_with_flag_and_verified_installation(self):
|
||||
self.assertTrue(is_full_auto_enabled(
|
||||
{"FULL_AUTO_ENABLED": "1"}, True))
|
||||
self.assertTrue(is_full_auto_enabled(
|
||||
{"FULL_AUTO_ENABLED": "true"}, True))
|
||||
|
||||
def test_forbidden_and_unknown_actions_denied(self):
|
||||
for action in ("delete_repo", "force_push", "force_push_main",
|
||||
"change_visibility", "read_secret",
|
||||
"expose_secret", "delete_branch_protection",
|
||||
"bypass_branch_protection",
|
||||
"register_github_account", "nonsense_action"):
|
||||
with self.assertRaises(FullAutoDenied, msg=action):
|
||||
validate_action(action)
|
||||
|
||||
def test_allowed_actions_pass(self):
|
||||
for action in ("create_pr", "merge_pr", "create_release",
|
||||
"rollback_to_tag", "set_branch_protection"):
|
||||
self.assertTrue(validate_action(action))
|
||||
|
||||
|
||||
class MergeGateTestCase(unittest.TestCase):
|
||||
def test_all_green_passes(self):
|
||||
passed, reasons = merge_gate(
|
||||
{"verify": "success"}, ("verify",), True, True, True)
|
||||
self.assertTrue(passed)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_any_missing_gate_fails(self):
|
||||
cases = [
|
||||
({"verify": "failure"}, ("verify",), True, True, True),
|
||||
({"verify": "success"}, ("verify",), False, True, True),
|
||||
({"verify": "success"}, ("verify",), True, False, True),
|
||||
({"verify": "success"}, ("verify",), True, True, False),
|
||||
({}, ("verify",), True, True, True),
|
||||
]
|
||||
for checks, required, d, c, a in cases:
|
||||
passed, reasons = merge_gate(checks, required, d, c, a)
|
||||
self.assertFalse(passed)
|
||||
self.assertTrue(reasons)
|
||||
|
||||
|
||||
class AutoConfigPlanTestCase(unittest.TestCase):
|
||||
def test_plan_order_and_branch_protection(self):
|
||||
plan = auto_config_plan("owner/repo", "main", org="myorg",
|
||||
repo_exists=False,
|
||||
ci_workflow_present=False)
|
||||
steps = [s["step"] for s in plan]
|
||||
self.assertEqual(steps, ["ensure_repo", "set_default_branch",
|
||||
"set_branch_protection",
|
||||
"add_ci_workflow", "verify_webhook"])
|
||||
protection = plan[2]["payload_summary"]
|
||||
self.assertTrue(protection["enforce_admins"])
|
||||
self.assertTrue(
|
||||
protection["required_status_checks"]["strict"])
|
||||
self.assertEqual(
|
||||
protection["required_status_checks"]["contexts"],
|
||||
["verify"])
|
||||
self.assertFalse(protection["allow_force_pushes"])
|
||||
self.assertFalse(protection["allow_deletions"])
|
||||
self.assertIsNotNone(
|
||||
protection["required_pull_request_reviews"])
|
||||
|
||||
def test_user_repo_without_org_is_user_once_not_fake_api(self):
|
||||
plan = auto_config_plan("owner/repo", "main", org=None,
|
||||
repo_exists=False)
|
||||
first = plan[0]
|
||||
self.assertEqual(first["manual"], "user_once")
|
||||
self.assertIsNone(first["api"])
|
||||
|
||||
def test_existing_repo_skips_ensure_step(self):
|
||||
plan = auto_config_plan("owner/repo", "main", org=None,
|
||||
repo_exists=True)
|
||||
self.assertNotIn("ensure_repo",
|
||||
[s["step"] for s in plan])
|
||||
|
||||
def test_empty_required_checks_is_incomplete(self):
|
||||
with self.assertRaises(FullAutoError):
|
||||
branch_protection_payload([])
|
||||
|
||||
|
||||
class RunPlanTestCase(unittest.TestCase):
|
||||
def _fake_client(self, calls, fail_on=None):
|
||||
def client(method, url, headers, body):
|
||||
calls.append((method, url, headers))
|
||||
if fail_on and fail_on in url:
|
||||
return 422, {"message": "validation failed"}
|
||||
return 201, {"ok": True}
|
||||
return client
|
||||
|
||||
def test_executes_in_order_and_token_only_in_header(self):
|
||||
calls = []
|
||||
plan = auto_config_plan("owner/repo", "main", org="myorg",
|
||||
repo_exists=False)
|
||||
results = run_plan(self._fake_client(calls), "tok-123", plan)
|
||||
self.assertTrue(all(r["ok"] for r in results))
|
||||
self.assertEqual(calls[0][0], "POST")
|
||||
self.assertIn("/orgs/myorg/repos", calls[0][1])
|
||||
self.assertEqual(calls[0][2]["Authorization"],
|
||||
"Bearer tok-123")
|
||||
dumped = json.dumps(results)
|
||||
self.assertNotIn("tok-123", dumped)
|
||||
|
||||
def test_stops_on_first_failure(self):
|
||||
calls = []
|
||||
plan = auto_config_plan("owner/repo", "main", org="myorg",
|
||||
repo_exists=False)
|
||||
with self.assertRaises(FullAutoError):
|
||||
run_plan(self._fake_client(calls, fail_on="protection"),
|
||||
"tok-123", plan)
|
||||
self.assertEqual(len(calls), 3) # ensure + branch + protection
|
||||
|
||||
def test_missing_token_denied(self):
|
||||
plan = auto_config_plan("owner/repo")
|
||||
with self.assertRaises(FullAutoDenied):
|
||||
run_plan(self._fake_client([]), "", plan)
|
||||
|
||||
|
||||
class CiFixAndReleaseTestCase(unittest.TestCase):
|
||||
def test_fix_loop_max_three_then_escalate(self):
|
||||
for attempt in (1, 2, 3):
|
||||
decision = ci_fix_decision(attempt, ["verify"])
|
||||
self.assertEqual(decision["action"], "fix_and_rerun")
|
||||
decision = ci_fix_decision(4, ["verify"])
|
||||
self.assertEqual(decision["action"], "escalate")
|
||||
self.assertEqual(decision["reason"],
|
||||
"max_fix_attempts_exceeded")
|
||||
|
||||
def test_release_notes_from_merged_prs_only(self):
|
||||
plan = release_plan("v0.1.0", [{"number": 12, "title": "feat: x"}])
|
||||
self.assertIn("#12 feat: x", plan["release_notes"])
|
||||
self.assertIn("never force-push", plan["rollback"])
|
||||
|
||||
def test_rollback_requires_previous_tag(self):
|
||||
self.assertEqual(rollback_plan("v0.0.9")["previous_tag"],
|
||||
"v0.0.9")
|
||||
with self.assertRaises(FullAutoError):
|
||||
rollback_plan("")
|
||||
|
||||
|
||||
class AuditTestCase(unittest.TestCase):
|
||||
def test_audit_record_redacts_token(self):
|
||||
record = audit_record("merge_pr", "owner/repo",
|
||||
"full-auto-executor:123", "ok",
|
||||
{"token": "tok-123",
|
||||
"note": "Bearer tok-123 here"})
|
||||
dumped = json.dumps(record)
|
||||
self.assertNotIn("tok-123", dumped)
|
||||
self.assertIn("Bearer [redacted]", dumped)
|
||||
|
||||
def test_append_audit_log_jsonl(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = str(Path(tmp) / "audit.jsonl")
|
||||
record = audit_record("create_pr", "owner/repo",
|
||||
"full-auto-executor:1", "ok")
|
||||
append_audit_log(path, record)
|
||||
lines = Path(path).read_text(encoding="utf-8").strip().split("\n")
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertEqual(json.loads(lines[0])["action"],
|
||||
"create_pr")
|
||||
|
||||
|
||||
class SkillFullAutoTestCase(unittest.TestCase):
|
||||
def test_full_auto_in_body_and_appendix(self):
|
||||
text = SKILL.read_text(encoding="utf-8")
|
||||
main, sep, appendix = text.partition("## 附录:角色模板")
|
||||
self.assertTrue(sep, "appendix marker missing")
|
||||
for kw in FULL_AUTO_KEYWORDS:
|
||||
self.assertIn(kw, main, kw)
|
||||
self.assertIn(kw, appendix, "appendix: " + kw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user