feat: hard merge gate by default (CI + directory + contract + coordinator + reviews)
- cloud/merge_gate_hard.py (new): pure hard gate; mode resolution is env-only (unset/invalid -> hard, fail-closed); five checks plus branch protection must be protected; unknown/unprotected block even with explicit confirm; no-evidence blocks (fail-closed); directory check reuses #209 check_directory_isolation (v0.3 rule); blocks codes + merge_gate_blocked audit detail are the track-3 pushback interface (no pushback state machine in this track). - cloud/server.py: POST /tasks/{id}/merge uses hard mode by default; record/enforce/off only when explicitly set via env (preserving #209 semantics); body merge_gate_mode is ignored server-side so callers cannot downgrade the gate; every gate block writes a merge_gate_blocked audit before returning 409, status unchanged; hard-mode body type errors return 400. - docs/merge-gate-branch-protection.md (new): protection payload (PR + strict status checks + no force-push + no deletion + enforce_admins), empty required_checks = unconfigured, Pro/public prerequisites, 403 causes; states protection is NOT claimed enabled. - SKILL.md (gate-mechanism wording only): four checks -> five checks; body + coordinator + client appendix gate paragraphs updated to default hard, merge_gate_blocked audit, payload doc reference. - tests: 31 new hard-gate tests (pure + endpoint + skill text); #209 endpoint tests now set record env explicitly (their intent); test_server_merge 200-path calls now carry full hard evidence.
This commit is contained in:
1 parent
f6d721158e
commit
93f2b350ac
7 files changed
+1048
-39
No files matched your search
@@ -4,6 +4,10 @@
|
||||
(本地或 Docker 容器内)执行,成功后再调本接口把云端
|
||||
任务从 completed 改为 merged。规则:404/401/403/409
|
||||
语义、maintainer 以上才能合并、审计里有 merge_task。
|
||||
门禁注意:默认模式已是 hard(见 test_merge_gate_hard.py),
|
||||
所以本文件里期望 200 的合并调用必须带完整硬门禁证据;
|
||||
无证据调用在默认下 409(本文件末尾两项新用例钉住它),
|
||||
record 下的旧行为由硬门禁测试文件覆盖。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
@@ -26,9 +30,28 @@ except ImportError:
|
||||
|
||||
|
||||
class MergeTestCase(unittest.TestCase):
|
||||
def _hard_body(self):
|
||||
return {
|
||||
"files_changed": ["apps/web/a.py"],
|
||||
"owned_dir": "apps/web",
|
||||
"branch_protection": "protected",
|
||||
"ci_status": "success",
|
||||
"ci_checks": [{"name": "ci", "status": "success"}],
|
||||
"required_checks": ["ci"],
|
||||
"contract_version": "v2",
|
||||
"contract_frozen": True,
|
||||
"coordinator_approved": True,
|
||||
"unresolved_review_comments": 0,
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self._saved_env = {k: os.environ.get(k) for k in
|
||||
("CLOUD_MERGE_GATE_MODE",
|
||||
"CLOUD_BRANCH_PROTECTION")}
|
||||
os.environ.pop("CLOUD_MERGE_GATE_MODE", None)
|
||||
os.environ.pop("CLOUD_BRANCH_PROTECTION", None)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.maint_id, self.maint_key = self.auth.create_user("maint")
|
||||
@@ -50,6 +73,11 @@ class MergeTestCase(unittest.TestCase):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
for key, value in self._saved_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
@@ -97,7 +125,8 @@ class MergeTestCase(unittest.TestCase):
|
||||
def test_merge_completed_by_owner(self):
|
||||
task = self._completed_task()
|
||||
status, merged = self._req(
|
||||
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
|
||||
"POST", "/tasks/%s/merge" % task["id"],
|
||||
self._hard_body(), self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(merged["status"], "merged")
|
||||
status, got = self._req(
|
||||
@@ -112,7 +141,8 @@ class MergeTestCase(unittest.TestCase):
|
||||
"POST", "/tasks/%s/merge" % task["id"], {}, self.contrib_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, merged = self._req(
|
||||
"POST", "/tasks/%s/merge" % task["id"], {}, self.maint_key)
|
||||
"POST", "/tasks/%s/merge" % task["id"],
|
||||
self._hard_body(), self.maint_key)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(merged["status"], "merged")
|
||||
|
||||
@@ -128,13 +158,32 @@ class MergeTestCase(unittest.TestCase):
|
||||
self.assertEqual(body["status"], "queued")
|
||||
done = self._completed_task()
|
||||
status, _ = self._req(
|
||||
"POST", "/tasks/%s/merge" % done["id"], {}, self.owner_key)
|
||||
"POST", "/tasks/%s/merge" % done["id"],
|
||||
self._hard_body(), self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/merge" % done["id"], {}, self.owner_key)
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(body["status"], "merged")
|
||||
|
||||
def test_merge_default_hard_blocks_no_evidence_call(self):
|
||||
task = self._completed_task()
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(body["merge_gate"]["mode"], "hard")
|
||||
status, got = self._req(
|
||||
"GET", "/tasks/%s" % task["id"], key=self.owner_key)
|
||||
self.assertEqual(got["status"], "completed")
|
||||
|
||||
def test_merge_record_env_preserves_old_no_evidence_call(self):
|
||||
os.environ["CLOUD_MERGE_GATE_MODE"] = "record"
|
||||
task = self._completed_task()
|
||||
status, merged = self._req(
|
||||
"POST", "/tasks/%s/merge" % task["id"], {}, self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(merged["status"], "merged")
|
||||
|
||||
def test_merge_missing_and_no_key(self):
|
||||
status, _ = self._req(
|
||||
"POST", "/tasks/nope/merge", {}, self.owner_key)
|
||||
|
||||
Reference in new issue
Block a user