Merge branch 'master' into feat/issue-389-review-workflow-load-proof
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""Tests for already-landed PR reconciliation gates (#310)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import already_landed_reconcile
|
||||
|
||||
|
||||
class TestAssessAlreadyLandedReconciliation(unittest.TestCase):
|
||||
def test_open_pr_already_landed_allows_close(self):
|
||||
with patch(
|
||||
"already_landed_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 278,
|
||||
"state": "open",
|
||||
"title": "Fix thing (Closes #263)",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/x", "sha": "abc123"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
)
|
||||
self.assertTrue(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_ALREADY_LANDED,
|
||||
)
|
||||
self.assertEqual(assessment["linked_issue"], 263)
|
||||
self.assertTrue(assessment["ancestor_proof"])
|
||||
|
||||
def test_not_landed_pr_denies_close(self):
|
||||
with patch(
|
||||
"already_landed_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=False,
|
||||
):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 99,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff111"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_NOT_LANDED,
|
||||
)
|
||||
|
||||
def test_stale_target_branch_denies_close(self):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 99,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff111"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": False,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": None,
|
||||
"reasons": ["git fetch failed"],
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_STALE_TARGET,
|
||||
)
|
||||
|
||||
def test_closed_pr_denies_close(self):
|
||||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||||
pr={
|
||||
"number": 50,
|
||||
"state": "closed",
|
||||
"title": "Done",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/z", "sha": "aaa"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["close_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
already_landed_reconcile.ELIGIBILITY_PR_NOT_OPEN,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for branches-only author mutation worktree guard (#274)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
|
||||
|
||||
class TestPathUnderBranches(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def test_branches_subdirectory_passes(self):
|
||||
self.assertTrue(
|
||||
amw.is_path_under_branches(f"{self.ROOT}/branches/issue-274", self.ROOT)
|
||||
)
|
||||
|
||||
def test_control_checkout_fails(self):
|
||||
self.assertFalse(amw.is_path_under_branches(self.ROOT, self.ROOT))
|
||||
|
||||
def test_sibling_directory_fails(self):
|
||||
self.assertFalse(
|
||||
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
|
||||
)
|
||||
|
||||
|
||||
class TestAssessAuthorMutationWorktree(unittest.TestCase):
|
||||
ROOT = "/repo/Gitea-Tools"
|
||||
|
||||
def test_branches_worktree_passes(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=f"{self.ROOT}/branches/issue-274",
|
||||
project_root=self.ROOT,
|
||||
current_branch="feat/issue-274-branches-only-worktrees",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_control_checkout_blocks(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=self.ROOT,
|
||||
project_root=self.ROOT,
|
||||
current_branch="master",
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("control checkout", result["reasons"][0])
|
||||
|
||||
def test_drifted_control_branch_blocks(self):
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=self.ROOT,
|
||||
project_root=self.ROOT,
|
||||
current_branch="feat/some-task",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("drift" in r for r in result["reasons"]))
|
||||
|
||||
def test_branches_worktree_as_project_root_passes(self):
|
||||
"""MCP launched from a branches/ worktree uses that path as PROJECT_ROOT."""
|
||||
worktree_root = f"{self.ROOT}/branches/issue-274"
|
||||
result = amw.assess_author_mutation_worktree(
|
||||
workspace_path=worktree_root,
|
||||
project_root=worktree_root,
|
||||
current_branch="feat/issue-274-branches-only-worktrees",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestPreflightIntegration(unittest.TestCase):
|
||||
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
|
||||
import mcp_server
|
||||
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
import mcp_server
|
||||
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "author"
|
||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -55,7 +55,15 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self.locked_worktree_dir = tempfile.TemporaryDirectory()
|
||||
repo_root = os.path.realpath(
|
||||
os.path.join(os.path.dirname(__file__), os.pardir)
|
||||
)
|
||||
branches_root = os.path.join(repo_root, "branches")
|
||||
os.makedirs(branches_root, exist_ok=True)
|
||||
self.locked_worktree_dir = tempfile.TemporaryDirectory(
|
||||
dir=branches_root,
|
||||
prefix="test-commit-payloads-",
|
||||
)
|
||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||
|
||||
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
||||
@@ -94,6 +102,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TEST_PORCELAIN": "",
|
||||
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
|
||||
}
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||
|
||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||
structured permission report). With it, deletion proceeds through existing
|
||||
preflight and audit unchanged.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_delete_branch
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
|
||||
AUTHOR_NO_DELETE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
||||
"gitea.branch.push", "gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
AUTHOR_WITH_DELETE = {
|
||||
"profile_name": "prgs-author-deleter",
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author-deleter",
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"author-no-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "author-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-no-delete",
|
||||
},
|
||||
"author-with-delete": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "deleter-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
|
||||
"forbidden_operations": [],
|
||||
"execution_profile": "author-with-delete",
|
||||
},
|
||||
"reviewer-profile": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reviewer",
|
||||
"username": "reviewer-user",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
|
||||
],
|
||||
"execution_profile": "reviewer-profile",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
|
||||
class TestDeleteBranchToolGate(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._preflight_snapshot = (
|
||||
mcp_server._preflight_whoami_called,
|
||||
mcp_server._preflight_capability_called,
|
||||
)
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
patch("gitea_config.load_config", return_value={}).start()
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
self._preflight_snapshot
|
||||
)
|
||||
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def test_blocked_without_delete_capability(self):
|
||||
self._set_profile(AUTHOR_NO_DELETE)
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||
self.assertTrue(res["reasons"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.mock_api.assert_not_called()
|
||||
self.mock_auth.assert_not_called()
|
||||
|
||||
def test_allowed_delete_proceeds(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("deleted", res["message"])
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertTrue(delete_calls)
|
||||
|
||||
def test_allowed_delete_audited_with_capability_proof(self):
|
||||
self._set_profile(AUTHOR_WITH_DELETE)
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
mock_write = patch("gitea_audit.write_event").start()
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
mock_write.assert_called()
|
||||
event = mock_write.call_args[0][0]
|
||||
self.assertEqual(event["action"], "delete_branch")
|
||||
self.assertEqual(
|
||||
event["request_metadata"]["required_permission"],
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
patch(
|
||||
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||
).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
}
|
||||
|
||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||
resolve = mcp_server.gitea_resolve_task_capability(
|
||||
task="delete_branch", remote="prgs")
|
||||
self.assertFalse(resolve["allowed_in_current_session"])
|
||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(
|
||||
res["permission_report"]["missing_permission"],
|
||||
resolve["required_operation_permission"],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,11 +25,15 @@ def _review_handoff(**overrides):
|
||||
"- Files changed: review_proofs.py",
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Mutations: review only",
|
||||
"- Workspace mutations: none",
|
||||
"- File edits by reviewer: none",
|
||||
"- Worktree/index mutations: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- MCP/Gitea mutations: review submitted",
|
||||
"- Review mutations: submitted request_changes review",
|
||||
"- Merge mutations: none",
|
||||
"- Cleanup mutations: none",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||
"- Current status: PR open",
|
||||
"- Blockers: none",
|
||||
"- Next: await author",
|
||||
@@ -53,26 +57,47 @@ def _review_handoff(**overrides):
|
||||
return text
|
||||
|
||||
|
||||
def _reconcile_handoff(**overrides):
|
||||
def _reconcile_handoff(drop=(), **overrides):
|
||||
lines = [
|
||||
"## Controller Handoff",
|
||||
"",
|
||||
"- Task: reconcile already-landed PR #99",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: reconciler",
|
||||
"- Identity: sysadmin / prgs-author",
|
||||
"- Role/profile: reconciler / prgs-reconciler",
|
||||
"- Identity: sysadmin",
|
||||
"- Selected PR: #99",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- PR live state: open",
|
||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Target branch: master",
|
||||
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
|
||||
"- Ancestor proof: candidate head is ancestor of target branch SHA",
|
||||
"- Linked issue: #98",
|
||||
"- Linked issue live status: not verified in this session",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
||||
"- Issue comments posted: none",
|
||||
"- PRs closed: none",
|
||||
"- Issues closed: none",
|
||||
"- File edits by reconciler: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Worktree mutations: none",
|
||||
"- MCP/Gitea mutations: PR comment posted",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
||||
"- Reconciliation mutations: PR comment posted",
|
||||
"- Current status: reconciliation complete",
|
||||
"- Safe next action: none",
|
||||
"- Blocker: missing gitea.pr.close capability",
|
||||
"- Safe next action: hand off to a profile with gitea.pr.close",
|
||||
"- No review/merge confirmation: confirmed",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
kept = [
|
||||
line
|
||||
for line in lines
|
||||
if not any(line.startswith(f"- {name}:") for name in drop)
|
||||
]
|
||||
text = "\n".join(kept)
|
||||
for key, value in overrides.items():
|
||||
if f"- {key}:" in text:
|
||||
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
||||
@@ -190,11 +215,76 @@ class TestReviewPrRules(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_already_landed_oldest_eligible_pr_blocks(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
+ "\nAlready landed.\nOldest eligible PR: PR #278."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.already_landed_eligible_wording"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_already_landed_gate_blocks_review_terminal_states(self):
|
||||
cases = {
|
||||
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
|
||||
"MERGED": ("- Merge result: not attempted", "- Merge result: MERGED"),
|
||||
"READY_TO_MERGE": ("- Current status: PR open", "- Current status: READY_TO_MERGE"),
|
||||
}
|
||||
for state, (old, new) in cases.items():
|
||||
with self.subTest(state=state):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Reviewer eligibility: eligible", "- Reviewer eligibility: blocked")
|
||||
.replace(old, new)
|
||||
+ "\n- Already-landed gate: fired"
|
||||
+ "\n- Ancestor proof: passed"
|
||||
+ "\n- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.already_landed_review_state"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_target_branch_up_to_date_requires_fetch_and_sha(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
|
||||
+ "\nTarget branch up to date."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.target_branch_freshness_proof"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_target_branch_up_to_date_with_fetch_and_sha_passes(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
+ "\n- Target branch SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
+ "\nTarget branch up to date."
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
def test_git_fetch_must_not_be_readonly_only(self):
|
||||
report = (
|
||||
_review_handoff()
|
||||
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
|
||||
+ "\n- Read-only diagnostics: git fetch prgs master"
|
||||
.replace(
|
||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||
"- Read-only diagnostics: git fetch prgs master",
|
||||
)
|
||||
)
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
@@ -251,6 +341,159 @@ class TestReconciliationRules(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
||||
|
||||
def test_comment_only_reconciliation_passes(self):
|
||||
result = assess_final_report_validator(
|
||||
_reconcile_handoff(),
|
||||
"reconcile_already_landed",
|
||||
)
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertEqual(result["findings"], [])
|
||||
|
||||
def test_successful_close_reconciliation_passes(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
|
||||
).replace(
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- Missing capabilities: none",
|
||||
).replace(
|
||||
"- PRs closed: none",
|
||||
"- PRs closed: #99",
|
||||
).replace(
|
||||
"- Blocker: missing gitea.pr.close capability",
|
||||
"- Blocker: none",
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
|
||||
def test_blocked_reconciliation_passes(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Capabilities proven: gitea.read",
|
||||
).replace(
|
||||
"- Missing capabilities: gitea.pr.close",
|
||||
"- Missing capabilities: gitea.pr.close, gitea.pr.comment",
|
||||
).replace(
|
||||
"- PR comments posted: 1 reconciliation comment on PR #99",
|
||||
"- PR comments posted: none",
|
||||
).replace(
|
||||
"- MCP/Gitea mutations: PR comment posted",
|
||||
"- MCP/Gitea mutations: none",
|
||||
).replace(
|
||||
"- Reconciliation mutations: PR comment posted",
|
||||
"- Reconciliation mutations: none",
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "A")
|
||||
|
||||
def test_missing_capabilities_field_required(self):
|
||||
report = _reconcile_handoff(drop=("Missing capabilities",))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "downgraded")
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.controller_handoff"
|
||||
and "Missing capabilities" in f["reason"]
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_ancestor_proof_and_candidate_sha_downgrade(self):
|
||||
report = _reconcile_handoff(drop=("Ancestor proof", "Candidate head SHA"))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertEqual(result["grade"], "downgraded")
|
||||
reasons = " ".join(f["reason"] for f in result["findings"])
|
||||
self.assertIn("Ancestor proof", reasons)
|
||||
self.assertIn("Candidate head SHA", reasons)
|
||||
|
||||
def test_stale_issue_lock_proof_blocks(self):
|
||||
report = _reconcile_handoff() + "\n- Issue lock proof: locked before diff"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "issue lock proof"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_stale_claim_comment_status_blocks(self):
|
||||
report = _reconcile_handoff() + "\n- Claim/comment status: claimed"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "claim/comment status"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_stale_workspace_mutations_blocks_without_observed_mutations(self):
|
||||
report = _reconcile_handoff() + "\n- Workspace mutations: None"
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "workspace mutations"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_pr_number_opened_allowed_when_session_opened_pr(self):
|
||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
session_pr_opened=True,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "pr number opened"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_pr_number_opened_still_blocked_without_session_proof(self):
|
||||
report = _reconcile_handoff() + "\n- PR number opened: #12"
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
session_pr_opened=False,
|
||||
)
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
|
||||
and f["field"] == "pr number opened"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_action_log_fetch_requires_git_ref_mutation_entry(self):
|
||||
report = _reconcile_handoff().replace(
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Git ref mutations: none",
|
||||
)
|
||||
result = assess_final_report_validator(
|
||||
report,
|
||||
"reconcile_already_landed",
|
||||
action_log=[{"command": "git fetch prgs master", "performed": True}],
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "reviewer.git_fetch_readonly"
|
||||
for f in result["findings"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestEntryPoint(unittest.TestCase):
|
||||
def test_unknown_task_kind_blocks(self):
|
||||
result = assess_final_report_validator("report", "unknown_mode")
|
||||
@@ -270,4 +513,4 @@ class TestEntryPoint(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -16,6 +16,7 @@ def test_all_workflow_files_exist():
|
||||
"reconcile-landed-pr.md",
|
||||
"create-issue.md",
|
||||
"work-issue.md",
|
||||
"pr-queue-cleanup.md",
|
||||
):
|
||||
assert (SKILL_DIR / "workflows" / name).is_file(), name
|
||||
|
||||
@@ -26,6 +27,7 @@ def test_skill_references_all_workflow_files():
|
||||
"workflows/reconcile-landed-pr.md",
|
||||
"workflows/create-issue.md",
|
||||
"workflows/work-issue.md",
|
||||
"workflows/pr-queue-cleanup.md",
|
||||
):
|
||||
assert name in SKILL
|
||||
|
||||
@@ -36,6 +38,7 @@ def test_skill_contains_mode_isolation_language():
|
||||
assert "reconcile-landed-pr" in SKILL
|
||||
assert "create-issue" in SKILL
|
||||
assert "work-issue" in SKILL
|
||||
assert "pr-queue-cleanup" in SKILL
|
||||
assert "Do not mix modes" in SKILL or "do not mix modes" in SKILL.lower()
|
||||
|
||||
|
||||
@@ -97,6 +100,19 @@ def test_work_issue_workflow_contract():
|
||||
assert "canonical: true" in text
|
||||
assert "Do not merge your own PR" in text
|
||||
assert "## 21. PR creation rules" in text
|
||||
assert "gitea_route_task_session" in text
|
||||
assert "work-issue" in text
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_workflow_contract():
|
||||
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "canonical: true" in text
|
||||
assert "exactly one" in text
|
||||
assert "check_cleanup_task_allowed" in text
|
||||
assert "resolve_cleanup_run_state" in text
|
||||
assert "Next suggested PR" in text
|
||||
|
||||
|
||||
def test_final_report_schemas_exist():
|
||||
@@ -105,6 +121,7 @@ def test_final_report_schemas_exist():
|
||||
"reconcile-landed-final-report.md",
|
||||
"create-issue-final-report.md",
|
||||
"work-issue-final-report.md",
|
||||
"pr-queue-cleanup-final-report.md",
|
||||
):
|
||||
assert (SKILL_DIR / "schemas" / name).is_file(), name
|
||||
|
||||
@@ -124,6 +141,30 @@ def test_start_issue_template_references_work_issue_workflow():
|
||||
assert "workflows/work-issue.md" in text
|
||||
|
||||
|
||||
def test_reconcile_skill_registered_in_mcp_server():
|
||||
from mcp_server import _PROJECT_SKILLS
|
||||
|
||||
assert "gitea-reconcile-landed-pr" in _PROJECT_SKILLS
|
||||
steps = _PROJECT_SKILLS["gitea-reconcile-landed-pr"]["steps"]
|
||||
joined = " ".join(steps).lower()
|
||||
assert "gitea_scan_already_landed_open_prs" in joined
|
||||
assert "gitea_assess_already_landed_reconciliation" in joined
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_template_references_workflow():
|
||||
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "workflows/pr-queue-cleanup.md" in text
|
||||
assert "pr_queue_cleanup" in text
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_verifier_exported():
|
||||
from review_proofs import assess_pr_queue_cleanup_report
|
||||
|
||||
assert callable(assess_pr_queue_cleanup_report)
|
||||
|
||||
|
||||
def test_reviewer_fallback_verifier_exported():
|
||||
from review_proofs import assess_reviewer_fallback_report
|
||||
|
||||
@@ -136,12 +177,24 @@ def test_final_report_validator_exported():
|
||||
assert callable(assess_final_report_validator)
|
||||
|
||||
|
||||
def test_review_final_report_schema_verifier_exported():
|
||||
from review_proofs import assess_review_final_report_schema
|
||||
|
||||
assert callable(assess_review_final_report_schema)
|
||||
|
||||
|
||||
def test_create_issue_final_report_verifier_exported():
|
||||
from review_proofs import assess_create_issue_final_report
|
||||
|
||||
assert callable(assess_create_issue_final_report)
|
||||
|
||||
|
||||
def test_work_issue_final_report_verifier_exported():
|
||||
from review_proofs import assess_work_issue_final_report
|
||||
|
||||
assert callable(assess_work_issue_final_report)
|
||||
|
||||
|
||||
def test_merge_simulation_verifier_exported():
|
||||
from review_proofs import assess_merge_simulation_report
|
||||
|
||||
@@ -154,6 +207,12 @@ def test_validation_integrity_verifier_exported():
|
||||
assert callable(assess_validation_integrity_report)
|
||||
|
||||
|
||||
def test_validation_failure_history_verifier_exported():
|
||||
from review_proofs import assess_validation_failure_history_report
|
||||
|
||||
assert callable(assess_validation_failure_history_report)
|
||||
|
||||
|
||||
def test_prior_blocker_skip_verifier_exported():
|
||||
from review_proofs import assess_prior_blocker_skip_proof
|
||||
|
||||
@@ -172,12 +231,36 @@ def test_validation_worktree_edit_verifier_exported():
|
||||
assert callable(assess_validation_worktree_edit_report)
|
||||
|
||||
|
||||
def test_reconcile_inventory_verifier_exported():
|
||||
from review_proofs import assess_reconcile_inventory_report
|
||||
|
||||
assert callable(assess_reconcile_inventory_report)
|
||||
|
||||
|
||||
def test_reconcile_linked_issue_verifier_exported():
|
||||
from review_proofs import assess_reconcile_linked_issue_report
|
||||
|
||||
assert callable(assess_reconcile_linked_issue_report)
|
||||
|
||||
|
||||
def test_already_landed_handoff_verifier_exported():
|
||||
from review_proofs import assess_already_landed_handoff_report
|
||||
|
||||
assert callable(assess_already_landed_handoff_report)
|
||||
|
||||
|
||||
def test_inventory_worktree_verifier_exported():
|
||||
from review_proofs import assess_inventory_worktree_report
|
||||
|
||||
assert callable(assess_inventory_worktree_report)
|
||||
|
||||
|
||||
def test_proof_backed_handoff_verifier_exported():
|
||||
from review_proofs import assess_proof_backed_handoff_report
|
||||
|
||||
assert callable(assess_proof_backed_handoff_report)
|
||||
|
||||
|
||||
def test_infra_stop_handoff_verifier_exported():
|
||||
from review_proofs import assess_infra_stop_handoff_report
|
||||
|
||||
@@ -193,4 +276,29 @@ def test_mutation_categories_verifier_exported():
|
||||
def test_worktree_ownership_verifier_exported():
|
||||
from review_proofs import assess_worktree_ownership_report
|
||||
|
||||
assert callable(assess_worktree_ownership_report)
|
||||
assert callable(assess_worktree_ownership_report)
|
||||
|
||||
|
||||
def test_already_landed_classification_verifier_exported():
|
||||
from review_proofs import assess_already_landed_classification_report
|
||||
|
||||
assert callable(assess_already_landed_classification_report)
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_verifier_exported():
|
||||
from review_proofs import assess_pr_queue_cleanup_report
|
||||
|
||||
assert callable(assess_pr_queue_cleanup_report)
|
||||
|
||||
|
||||
def test_pr_queue_cleanup_workflow_contract():
|
||||
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(encoding="utf-8")
|
||||
assert "canonical: true" in text
|
||||
assert "task_mode: pr-queue-cleanup" in text
|
||||
assert "## 1. Mode isolation" in text
|
||||
assert "## 4. Terminal mutation chain" in text
|
||||
assert "## 5. Fresh run per PR" in text
|
||||
assert "exactly one" in text.lower()
|
||||
assert "review-merge-pr.md" in text
|
||||
assert "Next suggested PR" in text
|
||||
assert "schemas/pr-queue-cleanup-final-report.md" in text
|
||||
|
||||
@@ -1013,9 +1013,19 @@ class TestReviewPR(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDeleteBranch(unittest.TestCase):
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "test-deleter",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-deleter",
|
||||
}
|
||||
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_delete_branch(self, _auth, mock_api):
|
||||
def test_delete_branch(self, _auth, mock_api, _profile):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
mock_api.return_value = {}
|
||||
result = gitea_delete_branch(branch="feat/branch")
|
||||
self.assertTrue(result["success"])
|
||||
@@ -3059,10 +3069,18 @@ class TestIssueLocking(unittest.TestCase):
|
||||
mock_api.return_value = [] # no open PRs
|
||||
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
|
||||
self.assertEqual(res["work_lease"]["issue_number"], 196)
|
||||
self.assertEqual(res["work_lease"]["branch"], "feat/issue-196-mutations")
|
||||
self.assertIn("created_at", res["work_lease"])
|
||||
self.assertIn("expires_at", res["work_lease"])
|
||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
||||
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
|
||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
||||
lock = json.load(f)
|
||||
self.assertIn("worktree_path", lock)
|
||||
self.assertIn("work_lease", lock)
|
||||
|
||||
def test_lock_issue_mismatch_branch_fails(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
@@ -3103,6 +3121,51 @@ class TestIssueLocking(unittest.TestCase):
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already tied to an open PR", str(ctx.exception))
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state):
|
||||
mock_api.side_effect = [
|
||||
[],
|
||||
[{"name": "feat/issue-196-existing-work"}],
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already has matching branch", str(ctx.exception))
|
||||
|
||||
def test_lock_issue_blocks_active_same_operation_lease(self):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"issue_number": 196,
|
||||
"branch_name": "feat/issue-196-other-work",
|
||||
"worktree_path": "/tmp/other-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
}, f)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
|
||||
|
||||
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"issue_number": 196,
|
||||
"branch_name": "feat/issue-196-other-work",
|
||||
"worktree_path": "/tmp/other-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2000-01-01T00:00:00Z",
|
||||
},
|
||||
}, f)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_from_clean_scratch_worktree(self, _auth, _api):
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for PR-only queue cleanup mode (#390)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from pr_queue_cleanup import (
|
||||
CLEANUP_FORBIDDEN_TASKS,
|
||||
CLEANUP_WORKFLOW_PATH,
|
||||
CONTINUE_TO_SAME_PR_MERGE,
|
||||
STOP_AFTER_DECISION,
|
||||
STOP_AFTER_MERGE,
|
||||
STOP_AFTER_MERGE_BLOCKER,
|
||||
STOP_AFTER_REQUEST_CHANGES,
|
||||
STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
STOP_APPROVED_NO_MERGE_AUTH,
|
||||
STOP_GATE_NOT_PROVEN,
|
||||
assess_pr_queue_cleanup_report,
|
||||
check_cleanup_task_allowed,
|
||||
resolve_cleanup_run_state,
|
||||
)
|
||||
from review_proofs import assess_pr_queue_cleanup_report as proofs_assess
|
||||
from role_session_router import REVIEWER_TASKS, route_task_session
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
|
||||
class TestCleanupCapabilityMapping(unittest.TestCase):
|
||||
def test_cleanup_task_is_reviewer_role(self):
|
||||
for key in ("pr_queue_cleanup", "pr-queue-cleanup"):
|
||||
self.assertEqual(required_permission(key), "gitea.pr.review")
|
||||
self.assertEqual(required_role(key), "reviewer")
|
||||
|
||||
def test_cleanup_task_registered_as_reviewer_route(self):
|
||||
self.assertIn("pr_queue_cleanup", REVIEWER_TASKS)
|
||||
|
||||
def test_author_session_gets_wrong_role_stop(self):
|
||||
result = route_task_session(
|
||||
"pr_queue_cleanup",
|
||||
active_profile="prgs-author",
|
||||
active_role_kind="author",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "wrong_role_stop")
|
||||
self.assertFalse(result["downstream_allowed"])
|
||||
|
||||
def test_reviewer_session_allowed(self):
|
||||
result = route_task_session(
|
||||
"pr_queue_cleanup",
|
||||
active_profile="prgs-reviewer",
|
||||
active_role_kind="reviewer",
|
||||
allowed_in_current_session=True,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "allowed_current_session")
|
||||
self.assertTrue(result["downstream_allowed"])
|
||||
|
||||
|
||||
class TestCleanupTaskGate(unittest.TestCase):
|
||||
def test_author_tasks_blocked(self):
|
||||
for task in (
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
):
|
||||
allowed, reasons = check_cleanup_task_allowed(task)
|
||||
self.assertFalse(allowed, task)
|
||||
self.assertTrue(reasons, task)
|
||||
|
||||
def test_review_task_allowed(self):
|
||||
allowed, reasons = check_cleanup_task_allowed("review_pr")
|
||||
self.assertTrue(allowed)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_forbidden_set_covers_issue_filing_and_impl(self):
|
||||
self.assertIn("create_issue", CLEANUP_FORBIDDEN_TASKS)
|
||||
self.assertIn("create_branch", CLEANUP_FORBIDDEN_TASKS)
|
||||
self.assertIn("commit_files", CLEANUP_FORBIDDEN_TASKS)
|
||||
|
||||
|
||||
class TestCleanupRunState(unittest.TestCase):
|
||||
def test_request_changes_stops_run(self):
|
||||
state = resolve_cleanup_run_state("request_changes")
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_REQUEST_CHANGES)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_comment_and_skip_stop_run(self):
|
||||
for decision in ("comment", "skip"):
|
||||
state = resolve_cleanup_run_state(decision)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_DECISION)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_approved_without_merge_auth_stops(self):
|
||||
state = resolve_cleanup_run_state("approved", merge_gates_passed=True)
|
||||
self.assertEqual(state["outcome"], STOP_APPROVED_NO_MERGE_AUTH)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_approved_with_merge_auth_continues_to_merge(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], CONTINUE_TO_SAME_PR_MERGE)
|
||||
self.assertTrue(state["further_mutation_allowed"])
|
||||
self.assertEqual(state["allowed_mutation"], "merge same PR only")
|
||||
|
||||
def test_approved_with_auth_but_failed_gates_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=False,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_APPROVED_MERGE_GATES_FAILED)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_merge_completed_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
merge_completed=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_MERGE)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_merge_blocker_stops(self):
|
||||
state = resolve_cleanup_run_state(
|
||||
"approved",
|
||||
merge_authorized_for_pr=True,
|
||||
merge_gates_passed=True,
|
||||
merge_blocker=True,
|
||||
)
|
||||
self.assertEqual(state["outcome"], STOP_AFTER_MERGE_BLOCKER)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
def test_unknown_decision_fails_closed(self):
|
||||
state = resolve_cleanup_run_state("ship_it")
|
||||
self.assertEqual(state["outcome"], STOP_GATE_NOT_PROVEN)
|
||||
self.assertFalse(state["further_mutation_allowed"])
|
||||
|
||||
|
||||
def _clean_report(**overrides):
|
||||
lines = {
|
||||
"task": "Task: pr-queue-cleanup",
|
||||
"workflow": f"Workflow source: {CLEANUP_WORKFLOW_PATH} (hash abc123def456)",
|
||||
"pagination": (
|
||||
"PR inventory pagination proof: inventory_complete=true, final "
|
||||
"page, total_count 15"
|
||||
),
|
||||
"selected": "Selected PR: #371",
|
||||
"decision": "Review decision: approved",
|
||||
"merge_auth": "Merge authorized for PR: false",
|
||||
"merge_result": "Merge result: not attempted",
|
||||
"next": "Next suggested PR: #372",
|
||||
"issue_mut": "Issue mutations: none",
|
||||
"branch_mut": "Branch mutations: none",
|
||||
}
|
||||
lines.update(overrides)
|
||||
return "\n".join(v for v in lines.values() if v)
|
||||
|
||||
|
||||
class TestCleanupReportVerifier(unittest.TestCase):
|
||||
def test_clean_single_pr_report_passes(self):
|
||||
result = assess_pr_queue_cleanup_report(_clean_report())
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_approved_and_merged_with_authorization_passes(self):
|
||||
report = _clean_report(
|
||||
merge_auth="Merge authorized for PR: true",
|
||||
merge_result="Merge result: merged 0123456789ab",
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_request_changes_then_stop_passes(self):
|
||||
report = _clean_report(decision="Review decision: request_changes")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_multi_pr_selection_blocked(self):
|
||||
report = _clean_report() + "\nSelected PR: #403\n"
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("multiple prs" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_two_terminal_mutations_blocked(self):
|
||||
report = _clean_report() + "\nReview decision: request_changes"
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("terminal review mutations" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_pagination_proof_blocked(self):
|
||||
report = _clean_report(
|
||||
pagination="PR inventory pagination proof: inventory listed"
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("pagination" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_next_suggested_pr_blocked(self):
|
||||
report = _clean_report(next="")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("Next suggested PR" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_merge_without_authorization_blocked(self):
|
||||
report = _clean_report(
|
||||
merge_result="Merge result: merged abc123abc123",
|
||||
)
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("authorization" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_issue_mutations_forbidden(self):
|
||||
report = _clean_report(issue_mut="Issue mutations: gitea_mark_issue")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("issue mutations" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_branch_mutations_forbidden(self):
|
||||
report = _clean_report(branch_mut="Branch mutations: created feat/x")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("branch mutations" in r.lower() for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_missing_workflow_citation_blocked(self):
|
||||
report = _clean_report(workflow="Workflow source: none")
|
||||
result = assess_pr_queue_cleanup_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("pr-queue-cleanup.md" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_review_proofs_wrapper_matches_module(self):
|
||||
direct = assess_pr_queue_cleanup_report(_clean_report())
|
||||
wrapped = proofs_assess(_clean_report())
|
||||
self.assertEqual(direct["proven"], wrapped["proven"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for gitea_reconcile_already_landed_pr MCP tool (#310)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import gitea_reconcile_already_landed_pr
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.close",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.close", "gitea.pr.approve", "gitea.pr.merge"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
OPEN_LANDED_PR = {
|
||||
"number": 278,
|
||||
"title": "Already landed (Closes #263)",
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"head": {"ref": "feat/issue-263", "sha": "2a544b78d1371925761d16f1c451bb3b2984470e"},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
|
||||
|
||||
class TestReconcileAlreadyLandedPrTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value="token test"
|
||||
).start()
|
||||
patch("mcp_server.verify_preflight_purity").start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def _set_profile(self, profile):
|
||||
patch("mcp_server.get_profile", return_value=profile).start()
|
||||
|
||||
def test_close_blocked_without_pr_close_capability(self):
|
||||
self._set_profile(AUTHOR_PROFILE)
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["required_permission"], "gitea.pr.close")
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
def test_not_landed_pr_close_denied_after_live_fetch(self):
|
||||
self._set_profile(RECONCILER_PROFILE)
|
||||
self.mock_api.return_value = OPEN_LANDED_PR
|
||||
with patch(
|
||||
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
|
||||
return_value={
|
||||
"pr_state": "open",
|
||||
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"ancestor_proof": False,
|
||||
"eligibility_class": "NOT_ALREADY_LANDED",
|
||||
"linked_issue": 263,
|
||||
"close_allowed": False,
|
||||
"git_ref_mutations": ["git fetch prgs master"],
|
||||
"reasons": ["not ancestor"],
|
||||
},
|
||||
):
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res.get("pr_closed"))
|
||||
patch_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
|
||||
]
|
||||
self.assertEqual(patch_calls, [])
|
||||
|
||||
def test_already_landed_close_allowed_with_capability(self):
|
||||
self._set_profile(RECONCILER_PROFILE)
|
||||
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "GET" and "/pulls/278" in url:
|
||||
return dict(OPEN_LANDED_PR)
|
||||
if method == "PATCH" and "/pulls/278" in url:
|
||||
closed = dict(OPEN_LANDED_PR)
|
||||
closed["state"] = "closed"
|
||||
return closed
|
||||
return {}
|
||||
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
with patch(
|
||||
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
|
||||
return_value={
|
||||
"pr_state": "open",
|
||||
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": "e017d80256217f17b0f421cd051cfa5cbcf5ae0f",
|
||||
"ancestor_proof": True,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"linked_issue": 263,
|
||||
"close_allowed": True,
|
||||
"git_ref_mutations": ["git fetch prgs master"],
|
||||
"reasons": [],
|
||||
},
|
||||
):
|
||||
res = gitea_reconcile_already_landed_pr(
|
||||
pr_number=278,
|
||||
close_pr=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["performed"])
|
||||
self.assertTrue(res["pr_closed"])
|
||||
self.assertEqual(res["eligibility_class"], "ALREADY_LANDED_RECONCILE_REQUIRED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for reconciler profile model (#304)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import migrate_profiles
|
||||
import reconciler_profile
|
||||
|
||||
|
||||
PRGS_RECONCILER_ALLOWED = [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.pr.close",
|
||||
]
|
||||
PRGS_RECONCILER_FORBIDDEN = [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
]
|
||||
|
||||
|
||||
class TestReconcilerProfileModel(unittest.TestCase):
|
||||
def test_prgs_reconciler_shape_valid(self):
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["is_reconciler_profile"])
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_author_profile_not_reconciler(self):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.issue.comment",
|
||||
]
|
||||
forbidden = ["gitea.pr.approve", "gitea.pr.merge"]
|
||||
self.assertFalse(
|
||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
||||
)
|
||||
|
||||
def test_reviewer_profile_not_reconciler(self):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
]
|
||||
forbidden = ["gitea.pr.create", "gitea.branch.push"]
|
||||
self.assertFalse(
|
||||
reconciler_profile.is_reconciler_profile(allowed, forbidden)
|
||||
)
|
||||
|
||||
def test_broad_close_on_author_invalid(self):
|
||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.pr.create"]
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
allowed,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertFalse(result["is_reconciler_profile"])
|
||||
|
||||
def test_role_kind_detects_reconciler(self):
|
||||
role = mcp_server._role_kind(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertEqual(role, "reconciler")
|
||||
|
||||
def test_migrate_infer_role_reconciler(self):
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for reconciliation MCP assessment tools (#301)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server
|
||||
from mcp_server import (
|
||||
gitea_assess_already_landed_reconciliation,
|
||||
gitea_scan_already_landed_open_prs,
|
||||
)
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.close"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
OPEN_PR = {
|
||||
"number": 278,
|
||||
"title": "Landed (Closes #263)",
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"head": {"ref": "feat/x", "sha": "a" * 40},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
|
||||
|
||||
class TestReconciliationMcpTools(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value="token test"
|
||||
).start()
|
||||
patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE).start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def test_assess_returns_plan_without_mutations(self):
|
||||
self.mock_api.return_value = OPEN_PR
|
||||
with patch(
|
||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
||||
return_value={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"candidate_head_sha": OPEN_PR["head"]["sha"],
|
||||
"target_branch_sha": "deadbeef",
|
||||
"linked_issue": 263,
|
||||
"review_merge_allowed": False,
|
||||
},
|
||||
):
|
||||
res = gitea_assess_already_landed_reconciliation(
|
||||
pr_number=278, remote="prgs"
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertEqual(res["plan"]["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
self.assertFalse(res["review_merge_allowed"])
|
||||
patch_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
|
||||
]
|
||||
self.assertEqual(patch_calls, [])
|
||||
|
||||
def test_scan_returns_candidates_with_pagination(self):
|
||||
with patch(
|
||||
"mcp_server.api_fetch_page",
|
||||
return_value=([OPEN_PR], {
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
"is_final_page": True,
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
}),
|
||||
), patch(
|
||||
"mcp_server.reconciliation_workflow.fetch_target_branch",
|
||||
return_value={
|
||||
"success": True,
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
), patch(
|
||||
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
|
||||
return_value={
|
||||
"pr_number": 278,
|
||||
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"reconciliation_allowed": True,
|
||||
},
|
||||
):
|
||||
res = gitea_scan_already_landed_open_prs(remote="prgs", limit=50)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["candidate_count"], 1)
|
||||
self.assertTrue(res["pagination"]["inventory_complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for already-landed reconciliation workflow helpers (#301)."""
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import reconciliation_workflow
|
||||
from review_proofs import assess_reconcile_workflow_source
|
||||
|
||||
|
||||
class TestReconciliationAssessment(unittest.TestCase):
|
||||
def test_already_landed_open_pr(self):
|
||||
with patch(
|
||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
):
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr={
|
||||
"number": 278,
|
||||
"state": "open",
|
||||
"title": "Fix (Closes #263)",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/x", "sha": "abc" * 13 + "a"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
"git_fetch_command": "git fetch prgs master",
|
||||
},
|
||||
)
|
||||
self.assertTrue(assessment["reconciliation_allowed"])
|
||||
self.assertEqual(
|
||||
assessment["eligibility_class"],
|
||||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
)
|
||||
|
||||
def test_not_landed_pr(self):
|
||||
with patch(
|
||||
"reconciliation_workflow.is_head_ancestor_of_ref",
|
||||
return_value=False,
|
||||
):
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr={
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"title": "WIP",
|
||||
"body": "",
|
||||
"head": {"ref": "feat/y", "sha": "fff" * 13 + "f"},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
project_root="/tmp/repo",
|
||||
remote="prgs",
|
||||
target_branch="master",
|
||||
target_fetch={
|
||||
"success": True,
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": "deadbeef",
|
||||
},
|
||||
)
|
||||
self.assertFalse(assessment["reconciliation_allowed"])
|
||||
|
||||
|
||||
class TestReconciliationPlan(unittest.TestCase):
|
||||
def test_full_close_when_close_pr_allowed(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={
|
||||
"close_pr": True,
|
||||
"comment_pr": True,
|
||||
"close_issue": True,
|
||||
"comment_issue": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_FULL_RECONCILE,
|
||||
)
|
||||
self.assertTrue(plan["close_pr_allowed"])
|
||||
self.assertFalse(plan["review_merge_allowed"])
|
||||
|
||||
def test_comment_then_stop_without_close(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={
|
||||
"close_pr": False,
|
||||
"comment_pr": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_PARTIAL_COMMENT,
|
||||
)
|
||||
|
||||
def test_recovery_handoff_without_comment_or_close(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": True,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
|
||||
},
|
||||
capabilities={"close_pr": False, "comment_pr": False},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_RECOVERY_HANDOFF,
|
||||
)
|
||||
|
||||
def test_not_landed_no_action(self):
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment={
|
||||
"reconciliation_allowed": False,
|
||||
"eligibility_class": reconciliation_workflow.ELIGIBILITY_NOT_LANDED,
|
||||
"reasons": ["not ancestor"],
|
||||
},
|
||||
capabilities={"close_pr": True},
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["outcome"],
|
||||
reconciliation_workflow.OUTCOME_NOT_LANDED,
|
||||
)
|
||||
|
||||
|
||||
class TestReconcileWorkflowSource(unittest.TestCase):
|
||||
def test_valid_report_passes(self):
|
||||
report = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
||||
)
|
||||
result = assess_reconcile_workflow_source(report)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
def test_missing_workflow_fails(self):
|
||||
result = assess_reconcile_workflow_source("Task mode: reconcile-landed-pr")
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -121,6 +121,32 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["different_mcp_namespace_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_work_issue_author_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
for task in ("work_issue", "work-issue"):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task=task, remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["requested_task"], task)
|
||||
self.assertEqual(
|
||||
res["required_operation_permission"], "gitea.pr.create"
|
||||
)
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="work_issue", remote="prgs"
|
||||
)
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertTrue(res["different_mcp_namespace_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_issue_comment_task(self, _auth, _api):
|
||||
@@ -275,6 +301,40 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertEqual(res["required_role_kind"], "author")
|
||||
self.assertIn("author", res["exact_safe_next_action"].lower())
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_reconcile_already_landed_pr_requires_close(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="reconcile_already_landed_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["requested_task"], "reconcile_already_landed_pr")
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_resolve_pr_queue_cleanup_author_profile_blocked(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["required_role_kind"], "reviewer")
|
||||
self.assertFalse(res["allowed_in_current_session"])
|
||||
self.assertTrue(res["stop_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_resolve_pr_queue_cleanup_reviewer_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.review")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertFalse(res["stop_required"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_close_pr_known_and_lookalike_tasks_still_fail_closed(self, _auth, _api):
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for reviewer final-report schema verification (#391)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_final_report_schema import assess_review_final_report_schema # noqa: E402
|
||||
|
||||
|
||||
def _minimal_review_report(**overrides):
|
||||
lines = [
|
||||
"## Controller Handoff",
|
||||
"",
|
||||
"- Task: review PR #203",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: reviewer",
|
||||
"- Identity: sysadmin / prgs-reviewer",
|
||||
"- Issue/PR: #182 / PR #203",
|
||||
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Files changed: review_proofs.py",
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Mutations: review only",
|
||||
"- File edits by reviewer: none",
|
||||
"- Worktree/index mutations: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- MCP/Gitea mutations: review submitted",
|
||||
"- Review mutations: request_changes submitted",
|
||||
"- Merge mutations: none",
|
||||
"- Cleanup mutations: none",
|
||||
"- External-state mutations: none",
|
||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
||||
"- Current status: PR open",
|
||||
"- Blockers: none",
|
||||
"- Next: await author",
|
||||
"- Safety: no self-review; no self-merge",
|
||||
"- Selected PR: #203",
|
||||
"- Reviewer eligibility: eligible",
|
||||
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Worktree path: branches/review-203",
|
||||
"- Worktree dirty: clean",
|
||||
"- Unrelated local mutations: none",
|
||||
"- Review decision: request_changes",
|
||||
"- Merge result: not attempted",
|
||||
"- Linked issue: #182",
|
||||
"- Linked issue live status: open (gitea_view_issue)",
|
||||
"- Cleanup status: none",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
prefix = f"- {key}:"
|
||||
replaced = False
|
||||
new_lines = []
|
||||
for line in text.splitlines():
|
||||
if line.strip().startswith(prefix):
|
||||
new_lines.append(f"{prefix} {value}")
|
||||
replaced = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
text = "\n".join(new_lines)
|
||||
if not replaced:
|
||||
text += f"\n{prefix} {value}"
|
||||
return text
|
||||
|
||||
|
||||
class TestReviewFinalReportSchema(unittest.TestCase):
|
||||
def test_clean_report_passes(self):
|
||||
result = assess_review_final_report_schema(
|
||||
_minimal_review_report(),
|
||||
linked_issue_lock={"issue_number": 182, "pr_number": 203},
|
||||
)
|
||||
self.assertFalse(result["blocked"])
|
||||
self.assertIn(result["grade"], ("A", "downgraded"))
|
||||
|
||||
def test_legacy_pinned_reviewed_head_blocks(self):
|
||||
report = _minimal_review_report(**{"Pinned reviewed head": "abc123"})
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.legacy_stale_field", rule_ids)
|
||||
|
||||
def test_reviewed_head_without_validation_blocks(self):
|
||||
report = _minimal_review_report().replace(
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Validation: not run",
|
||||
)
|
||||
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.reviewed_head_without_validation", rule_ids)
|
||||
|
||||
def test_merged_without_proof_blocks(self):
|
||||
report = _minimal_review_report() + "\n\nPR #203 merged successfully."
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.merged_without_proof", rule_ids)
|
||||
|
||||
def test_issue_closed_without_live_proof_blocks(self):
|
||||
report = _minimal_review_report(
|
||||
**{
|
||||
"Linked issue live status": "closed",
|
||||
"Read-only diagnostics": "gitea_view_pr only",
|
||||
}
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.issue_closed_without_live_proof", rule_ids)
|
||||
|
||||
def test_full_suite_pass_with_ignored_tests_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report()
|
||||
+ "\nFull test suite passed with 0 failed; some tests ignored."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.full_suite_pass_ignored_tests", rule_ids)
|
||||
|
||||
def test_blocked_handoff_replay_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report(**{"Blockers": "capability stop"})
|
||||
+ "\nSafe next action: run gitea_merge_pr to finish."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.blocked_handoff_replay", rule_ids)
|
||||
|
||||
def test_narrative_handoff_drift_blocks(self):
|
||||
report = (
|
||||
_minimal_review_report()
|
||||
+ "\n## Summary\nVerdict: APPROVE this PR."
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.narrative_handoff_drift", rule_ids)
|
||||
|
||||
def test_pending_vs_approved_blocks(self):
|
||||
report = _minimal_review_report(
|
||||
**{"Review decision": "PENDING official review submitted approve"}
|
||||
)
|
||||
result = assess_review_final_report_schema(report)
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.pending_vs_approved", rule_ids)
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_review_final_report_schema as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
class TestMcpValidateReviewFinalReport(unittest.TestCase):
|
||||
def test_mcp_tool_callable(self):
|
||||
import gitea_mcp_server
|
||||
|
||||
result = gitea_mcp_server.gitea_validate_review_final_report(
|
||||
_minimal_review_report()
|
||||
)
|
||||
self.assertIn("grade", result)
|
||||
self.assertIn("findings", result)
|
||||
self.assertFalse(result["blocked"])
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for enforced PR review/merge state machine (#290)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import review_merge_state_machine as rmsm # noqa: E402
|
||||
|
||||
FULL_REVIEW_COMPLETION = {state: True for state in rmsm.REVIEW_MERGE_STATES}
|
||||
|
||||
|
||||
def _completion_through(state_name: str) -> dict[str, bool]:
|
||||
idx = rmsm.state_index(state_name)
|
||||
return {state: i <= idx for i, state in enumerate(rmsm.REVIEW_MERGE_STATES)}
|
||||
|
||||
|
||||
class TestWorkflowBlockers(unittest.TestCase):
|
||||
def test_infra_stop_blocks_all_states(self):
|
||||
result = rmsm.assess_workflow_blockers(infra_stop=True)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["forbidden_states"], list(rmsm.REVIEW_MERGE_STATES))
|
||||
|
||||
def test_infra_stop_forbids_advancement(self):
|
||||
result = rmsm.assess_state_advancement(
|
||||
{},
|
||||
target_state="INVENTORY",
|
||||
infra_stop=True,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertFalse(result["allowed"])
|
||||
|
||||
|
||||
class TestStateAdvancement(unittest.TestCase):
|
||||
def test_cannot_skip_inventory(self):
|
||||
result = rmsm.assess_state_advancement(
|
||||
{"PRECHECK": True},
|
||||
target_state="SELECT_PR",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["next_allowed_state"], "INVENTORY")
|
||||
|
||||
def test_sequential_advance_allowed(self):
|
||||
completion = _completion_through("INVENTORY")
|
||||
result = rmsm.assess_state_advancement(
|
||||
completion,
|
||||
target_state="SELECT_PR",
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
|
||||
class TestApproveAndMergeGates(unittest.TestCase):
|
||||
def test_approve_blocked_without_validate(self):
|
||||
completion = _completion_through("PIN_HEAD_SHA")
|
||||
result = rmsm.can_approve(completion)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("VALIDATE", ",".join(result["missing_states"]))
|
||||
|
||||
def test_approve_allowed_when_review_path_complete(self):
|
||||
completion = _completion_through("REVIEW_DECISION")
|
||||
result = rmsm.can_approve(completion)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_merge_blocked_without_pre_merge_gates(self):
|
||||
completion = _completion_through("APPROVE_OR_REQUEST_CHANGES")
|
||||
result = rmsm.can_merge(completion)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(result["missing_pre_merge_gates"])
|
||||
|
||||
def test_merge_allowed_with_pre_merge_gates(self):
|
||||
completion = _completion_through("PRE_MERGE_RECHECK")
|
||||
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
|
||||
result = rmsm.can_merge(completion, pre_merge_gates=gates)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["allowed"])
|
||||
|
||||
def test_merge_blocked_when_head_sha_gate_fails(self):
|
||||
completion = _completion_through("PRE_MERGE_RECHECK")
|
||||
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
|
||||
gates["reviewed_head_sha_unchanged"] = False
|
||||
result = rmsm.can_merge(completion, pre_merge_gates=gates)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("reviewed_head_sha_unchanged", result["missing_pre_merge_gates"])
|
||||
|
||||
|
||||
class TestRecoveryHandoff(unittest.TestCase):
|
||||
def test_blocked_handoff_without_replay_passes(self):
|
||||
report = (
|
||||
"Blocked recovery handoff.\n"
|
||||
"Restart the full workflow after infra_stop clears.\n"
|
||||
"No approve/merge performed."
|
||||
)
|
||||
result = rmsm.assess_blocked_recovery_handoff(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_blocked_handoff_with_merge_replay_fails(self):
|
||||
report = "Please merge PR #12 now using gitea_merge_pr."
|
||||
result = rmsm.assess_blocked_recovery_handoff(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestFinalReportClaims(unittest.TestCase):
|
||||
def test_ready_to_merge_claim_without_gates_fails(self):
|
||||
result = rmsm.assess_final_report_state_claims(
|
||||
"PR is ready to merge after validation.",
|
||||
state_completion=_completion_through("VALIDATE"),
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_reviewed_claim_without_validate_fails(self):
|
||||
result = rmsm.assess_final_report_state_claims(
|
||||
"PR reviewed and looks good.",
|
||||
state_completion={"PRECHECK": True},
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -40,6 +40,9 @@ from review_proofs import ( # noqa: E402
|
||||
assess_create_issue_final_report,
|
||||
assess_create_issue_mode_isolation,
|
||||
assess_create_issue_workflow_source,
|
||||
assess_work_issue_final_report,
|
||||
assess_work_issue_mode_isolation,
|
||||
assess_work_issue_workflow_source,
|
||||
assess_edited_pr_inventory_coverage,
|
||||
assess_empty_queue_report,
|
||||
assess_fresh_issue_selection,
|
||||
@@ -2320,6 +2323,52 @@ class TestCreateIssueFinalReport(unittest.TestCase):
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
|
||||
class TestWorkIssueFinalReport(unittest.TestCase):
|
||||
"""#139: work-issue final-report verifier coverage."""
|
||||
|
||||
def _good_report(self):
|
||||
return "\n".join([
|
||||
"Task mode: work-issue",
|
||||
"Workflow source: skills/llm-project-workflow/workflows/work-issue.md",
|
||||
"## Controller Handoff",
|
||||
"- Task: work-issue",
|
||||
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"- Role: author",
|
||||
"- Identity: jcwalker3 / prgs-author",
|
||||
"- Active profile: prgs-author",
|
||||
"- Runtime context: prgs-author on prgs",
|
||||
"- Selected issue: #139",
|
||||
"- Branch name: feat/issue-139-role-aware-work-issue-routing",
|
||||
"- PR number: not created in this session",
|
||||
"- File edits by author: role_session_router.py",
|
||||
"- Blockers: none",
|
||||
"- Current status: implementing routing",
|
||||
"- Safe next action: open PR",
|
||||
"- Next: open PR",
|
||||
"- Safety statement: no review/merge",
|
||||
])
|
||||
|
||||
def test_complete_work_issue_report_earns_a(self):
|
||||
result = assess_work_issue_final_report(self._good_report())
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertFalse(result["downgraded"])
|
||||
|
||||
def test_missing_workflow_source_downgrades(self):
|
||||
report = self._good_report().replace(
|
||||
"workflows/work-issue.md", "SKILL.md only"
|
||||
)
|
||||
result = assess_work_issue_workflow_source(report)
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
def test_review_field_in_handoff_downgrades(self):
|
||||
report = self._good_report().replace(
|
||||
"- Safety statement:",
|
||||
"- Review decision: APPROVE\n- Safety statement:",
|
||||
)
|
||||
result = assess_work_issue_mode_isolation(report)
|
||||
self.assertTrue(result["downgraded"])
|
||||
|
||||
|
||||
class TestValidationEnvironmentProof(unittest.TestCase):
|
||||
"""Issue #311: exact validation command and execution environment."""
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for canonical review-workflow citation and version proof (#296).
|
||||
|
||||
PR review/merge reports must prove they loaded the canonical workflow
|
||||
(workflows/review-merge-pr.md) and cite the workflow version/hash used;
|
||||
a stale or missing hash fails so prompt drift is caught.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from review_proofs import ( # noqa: E402
|
||||
assess_review_workflow_source,
|
||||
compute_workflow_hash,
|
||||
)
|
||||
|
||||
|
||||
CANONICAL_TEXT = "# review-merge-pr workflow v1\nstep one\n"
|
||||
CANONICAL_HASH = compute_workflow_hash(CANONICAL_TEXT)
|
||||
|
||||
|
||||
def _report(citation=True, version=CANONICAL_HASH):
|
||||
lines = ["Task: review next eligible PR"]
|
||||
if citation:
|
||||
lines.append(
|
||||
"Workflow source: skills/llm-project-workflow/"
|
||||
"workflows/review-merge-pr.md (loaded via mcp_get_skill_guide)"
|
||||
)
|
||||
if version is not None:
|
||||
lines.append(f"- Workflow version: {version}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class TestComputeWorkflowHash(unittest.TestCase):
|
||||
def test_deterministic(self):
|
||||
self.assertEqual(
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
)
|
||||
|
||||
def test_changes_with_content(self):
|
||||
self.assertNotEqual(
|
||||
compute_workflow_hash(CANONICAL_TEXT),
|
||||
compute_workflow_hash(CANONICAL_TEXT + "extra rule\n"),
|
||||
)
|
||||
|
||||
def test_short_hex(self):
|
||||
h = compute_workflow_hash(CANONICAL_TEXT)
|
||||
self.assertEqual(len(h), 12)
|
||||
int(h, 16) # raises if not hex
|
||||
|
||||
|
||||
class TestReviewWorkflowSource(unittest.TestCase):
|
||||
def test_citation_and_matching_hash_passes(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["reasons"], [])
|
||||
|
||||
def test_missing_citation_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(citation=False), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("review-merge-pr" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_missing_version_field_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version=None), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("workflow version" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_stale_hash_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version="deadbeef0000"), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertTrue(
|
||||
any("stale" in r.lower() or "does not match" in r.lower()
|
||||
for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_version_none_fails(self):
|
||||
result = assess_review_workflow_source(
|
||||
_report(version="none"), canonical_hash=CANONICAL_HASH
|
||||
)
|
||||
self.assertFalse(result["complete"])
|
||||
|
||||
def test_without_canonical_hash_field_still_required(self):
|
||||
# No canonical hash supplied (e.g. offline validation): citation
|
||||
# and a populated version field are still required.
|
||||
self.assertTrue(
|
||||
assess_review_workflow_source(_report())["complete"]
|
||||
)
|
||||
self.assertFalse(
|
||||
assess_review_workflow_source(_report(version=None))["complete"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import unittest
|
||||
|
||||
from reviewer_already_landed_classification import ( # noqa: E402
|
||||
ALREADY_LANDED_ELIGIBILITY_CLASS,
|
||||
assess_already_landed_classification_report,
|
||||
)
|
||||
|
||||
|
||||
class TestAlreadyLandedClassification(unittest.TestCase):
|
||||
def test_non_landed_report_passes(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Selected the next eligible PR: PR #286."
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["landed_context"])
|
||||
|
||||
def test_oldest_eligible_pr_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"PR is already landed.\n"
|
||||
"Oldest eligible PR: PR #278."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_next_eligible_pr_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Ancestor proof: passed\n"
|
||||
"Selected the next eligible PR: PR #278."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_eligible_for_review_on_landed_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed on master and eligible for review next."
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_proper_landed_wording_passes(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Oldest open PR requiring action: PR #278\n"
|
||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
||||
"Candidate head SHA: abc123\n"
|
||||
"Reviewed head SHA: none\n"
|
||||
"Queue blocked by already-landed PR requiring reconciliation",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_pinned_reviewed_head_without_validation_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed.\n"
|
||||
"Pinned reviewed head: abc123def456",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_wrong_eligibility_class_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
"Already landed.\n"
|
||||
"Eligibility class: REVIEW_ELIGIBLE",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_missing_queue_blocker_blocks(self):
|
||||
result = assess_already_landed_classification_report(
|
||||
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
|
||||
"Candidate head SHA: abc123\n"
|
||||
"Reviewed head SHA: none",
|
||||
selected_pr_already_landed=True,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_already_landed_classification_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for already-landed handoff verifier (#299)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_already_landed_handoff import ( # noqa: E402
|
||||
ALREADY_LANDED_STATE,
|
||||
assess_already_landed_handoff_report,
|
||||
)
|
||||
|
||||
|
||||
def _good_report() -> str:
|
||||
return "\n".join([
|
||||
"## PR reconciliation summary",
|
||||
f"Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"Candidate head SHA: 2a544f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"Reviewed head SHA: none",
|
||||
"Review worktree used: false",
|
||||
"Ancestor proof: PR head is ancestor of prgs/master",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #278",
|
||||
f"- Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"- Candidate head SHA: 2a544f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Reviewed head SHA: none",
|
||||
"- Target branch: master",
|
||||
"- Target branch SHA: abc123def4567890abcdef1234567890abcdef12",
|
||||
"- Review worktree used: false",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Review decision: none",
|
||||
"- Merge result: none",
|
||||
"- Linked issue status: open",
|
||||
"- Safe next action: reconcile open PR and linked issue",
|
||||
])
|
||||
|
||||
|
||||
class TestAlreadyLandedHandoff(unittest.TestCase):
|
||||
def test_clean_handoff_passes(self):
|
||||
result = assess_already_landed_handoff_report(_good_report())
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertTrue(result["gate_active"])
|
||||
|
||||
def test_inactive_gate_skips_checks(self):
|
||||
report = "## Controller Handoff\n- Selected PR: #12\n- Review decision: approve"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["gate_active"])
|
||||
|
||||
def test_rejects_stale_pinned_reviewed_head(self):
|
||||
report = _good_report().replace(
|
||||
"- Candidate head SHA:",
|
||||
"- Pinned reviewed head: 2a544f582026b72a229d59a172c0a63ac4aaeaf9\n"
|
||||
"- Candidate head SHA:",
|
||||
)
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("pinned reviewed head" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_scratch_worktree_used(self):
|
||||
report = _good_report() + "\n- Scratch worktree used: false"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("scratch worktree" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_mutations_none_with_git_fetch(self):
|
||||
report = _good_report().replace(
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
"- Git ref mutations: none\n- Mutations: None",
|
||||
)
|
||||
result = assess_already_landed_handoff_report(
|
||||
report,
|
||||
command_log=[{"command": "git fetch prgs master", "performed": True}],
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
joined = " ".join(result["reasons"]).lower()
|
||||
self.assertIn("mutations", joined)
|
||||
self.assertIn("git ref", joined)
|
||||
|
||||
def test_rejects_workspace_mutations_none(self):
|
||||
report = _good_report() + "\n- Workspace mutations: None"
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("workspace mutations" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_reviewed_head_sha_when_gate_fired(self):
|
||||
report = _good_report().replace("- Reviewed head SHA: none", "- Reviewed head SHA: abc123")
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("reviewed head sha" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_rejects_narrative_handoff_eligibility_mismatch(self):
|
||||
report = _good_report().replace(
|
||||
f"Eligibility class: {ALREADY_LANDED_STATE}",
|
||||
"Eligibility class: NORMAL_REVIEW_CANDIDATE",
|
||||
1,
|
||||
)
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("eligibility class" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_session_gate_fired_without_text_marker(self):
|
||||
report = "## Controller Handoff\n- Selected PR: #1\n- Pinned reviewed head: abc"
|
||||
result = assess_already_landed_handoff_report(
|
||||
report,
|
||||
handoff_session={"gate_fired": True},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["gate_active"])
|
||||
|
||||
def test_infra_stop_style_normal_handoff_not_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #12",
|
||||
"- Review decision: request_changes",
|
||||
"- Pinned reviewed head: abc123def4567890abcdef1234567890abcdef12",
|
||||
])
|
||||
result = assess_already_landed_handoff_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
def test_review_proofs_reexport(self):
|
||||
from review_proofs import assess_already_landed_handoff_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for proof-backed reviewer handoff verifier (#395)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_proof_backed_handoff import ( # noqa: E402
|
||||
assess_proof_backed_handoff_report,
|
||||
)
|
||||
|
||||
|
||||
def _good_report() -> str:
|
||||
return "\n".join([
|
||||
"Inventory pagination proof: is_final_page: true, has_more: false, total_count: 11",
|
||||
"Earlier PRs skipped: #372 non-mergeable — merge simulation conflicts in review_proofs.py",
|
||||
"Baseline worktree path: branches/baseline-master-pr383",
|
||||
"Baseline target SHA: 4243b60ca32d79f1ee5e6b0f10d7570e9dea2937",
|
||||
"Baseline dirty before validation: false",
|
||||
"Baseline validation command: venv/bin/python -m pytest tests/ -q",
|
||||
"Baseline validation result: 1 failed (test_clean_reviewer_report_passes)",
|
||||
"Baseline dirty after validation: false",
|
||||
"Cleanup mutations: git worktree list showed session worktrees removed",
|
||||
"## Controller Handoff",
|
||||
"- Inventory pagination proof: inventory_complete: true, total_count: 11",
|
||||
"- Earlier PRs skipped: #372 conflict proof via merge simulation",
|
||||
"- Baseline worktree used: true",
|
||||
"- Baseline worktree path: branches/baseline-master-pr383",
|
||||
"- Cleanup mutations: git worktree remove branches/review-pr383",
|
||||
])
|
||||
|
||||
|
||||
class TestProofBackedHandoff(unittest.TestCase):
|
||||
def test_fully_proof_backed_report_passes(self):
|
||||
result = assess_proof_backed_handoff_report(
|
||||
_good_report(),
|
||||
action_log=[{"command": "gitea_list_prs", "result": "inventory_complete=true"}],
|
||||
baseline_session={
|
||||
"complete": True,
|
||||
"clean_before": True,
|
||||
"clean_after": True,
|
||||
"command": "pytest",
|
||||
"result": "passed",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_inventory_page_size_assumption_blocks(self):
|
||||
report = (
|
||||
"Inventory is complete because 13 results are less than page size 50."
|
||||
)
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("page-size" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_skip_without_conflict_proof_blocks(self):
|
||||
report = "\n".join([
|
||||
"Earlier PRs skipped: #372, #374",
|
||||
"## Controller Handoff",
|
||||
"- Earlier PRs skipped: #372, #374",
|
||||
])
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("skip" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_baseline_without_command_blocks(self):
|
||||
report = "Baseline validation passed; same as master failures."
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("baseline" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_without_worktree_list_blocks(self):
|
||||
report = "Worktrees were cleaned after merge."
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("cleanup" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_with_worktree_list_passes(self):
|
||||
report = "Cleanup: git worktree list confirmed removal."
|
||||
result = assess_proof_backed_handoff_report(
|
||||
report,
|
||||
action_log=[{"command": "git worktree list"}],
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_master_integration_requires_command(self):
|
||||
report = "Merged master into the review worktree before validation."
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("master integration" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_master_integration_with_action_log_passes(self):
|
||||
report = "Merged master into review worktree; merge_exit=0"
|
||||
result = assess_proof_backed_handoff_report(
|
||||
report,
|
||||
action_log=[{"command": "git merge --no-commit prgs/master"}],
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_live_proof_requires_source_classification(self):
|
||||
report = "Live proof shows inventory complete."
|
||||
result = assess_proof_backed_handoff_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("proof source" in r.lower() for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
def test_review_proofs_reexport(self):
|
||||
from review_proofs import assess_proof_backed_handoff_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
result = exported(_good_report(), inventory_session={"inventory_complete": True})
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for reconciliation inventory pagination verifier (#308)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_reconcile_inventory import assess_reconcile_inventory_report # noqa: E402
|
||||
|
||||
|
||||
def _good_report() -> str:
|
||||
return "\n".join([
|
||||
"## Already-landed reconciliation",
|
||||
"Open PR inventory:",
|
||||
"- Requested page size: 50",
|
||||
"- Pages fetched: 2",
|
||||
"- Returned PR count per page: page1=50, page2=6",
|
||||
"- Inventory pagination proof: is_final_page: true, has_more: false",
|
||||
"All already-landed PRs in the scanned open queue were found.",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #278",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
])
|
||||
|
||||
|
||||
class TestReconcileInventoryPagination(unittest.TestCase):
|
||||
def test_complete_metadata_passes(self):
|
||||
result = assess_reconcile_inventory_report(_good_report())
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertTrue(result["pagination_proven"])
|
||||
|
||||
def test_no_inventory_claim_skips_checks(self):
|
||||
report = "## Controller Handoff\n- Selected PR: #278"
|
||||
result = assess_reconcile_inventory_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["inventory_claimed"])
|
||||
|
||||
def test_first_page_below_limit_with_finality_passes(self):
|
||||
report = "\n".join([
|
||||
"Open PRs listed for reconciliation.",
|
||||
"- Requested page size: 50",
|
||||
"- Pages fetched: 1",
|
||||
"- Returned PR count per page: page1=6",
|
||||
"- Inventory pagination proof: is_final_page: true",
|
||||
])
|
||||
result = assess_reconcile_inventory_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_exactly_full_first_page_without_finality_blocks(self):
|
||||
report = "\n".join([
|
||||
"Complete queue scan of open PRs.",
|
||||
"- Requested page size: 50",
|
||||
"- Pages fetched: 1",
|
||||
"- Returned PR count per page: page1=50",
|
||||
"Listed 50 open PRs on the first page.",
|
||||
])
|
||||
result = assess_reconcile_inventory_report(
|
||||
report,
|
||||
inventory_session={"requested_page_size": 50, "returned_page_size": 50},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("full first" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_missing_pagination_metadata_blocks(self):
|
||||
report = "All open PRs were inventoried. Inventory is complete."
|
||||
result = assess_reconcile_inventory_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("metadata" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_default_page_size_assumption_blocks(self):
|
||||
report = (
|
||||
"Exhaustive PR inventory: fewer than the default Gitea page size returned."
|
||||
)
|
||||
result = assess_reconcile_inventory_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("page-size" in r.lower() or "page size" in r.lower()
|
||||
for r in result["reasons"]))
|
||||
|
||||
def test_session_pagination_complete_passes(self):
|
||||
report = "All already-landed PRs were found after scanning open PRs."
|
||||
result = assess_reconcile_inventory_report(
|
||||
report,
|
||||
inventory_session={
|
||||
"pagination_complete": True,
|
||||
"requested_page_size": 50,
|
||||
"pages_fetched": 2,
|
||||
"returned_per_page": [50, 3],
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
def test_review_proofs_reexport(self):
|
||||
from review_proofs import assess_reconcile_inventory_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for reconciliation linked-issue live proof verifier (#300)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_reconcile_linked_issue import assess_reconcile_linked_issue_report # noqa: E402
|
||||
|
||||
|
||||
def _good_handoff() -> str:
|
||||
return "\n".join([
|
||||
"## PR reconciliation summary",
|
||||
"Fetched linked issue #263 live with gitea_view_issue in this session.",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #278",
|
||||
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
|
||||
"- Linked issue: #263",
|
||||
"- Linked issue live status: Issue #263 (open)",
|
||||
"- Safe next action: reconcile open PR",
|
||||
])
|
||||
|
||||
|
||||
class TestReconcileLinkedIssueLiveProof(unittest.TestCase):
|
||||
def test_live_fetch_with_open_status_passes(self):
|
||||
result = assess_reconcile_linked_issue_report(
|
||||
_good_handoff(),
|
||||
reconcile_session={"live_fetched": True, "linked_issue_number": 263},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertEqual(result["linked_issue_number"], 263)
|
||||
|
||||
def test_no_linked_issue_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Selected PR: #99",
|
||||
"- Linked issue: none",
|
||||
"- Linked issue live status: not applicable",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertIsNone(result["linked_issue_number"])
|
||||
|
||||
def test_closed_status_without_live_proof_blocks(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Linked issue: #263",
|
||||
"- Linked issue live status: Issue #263 (closed)",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("live" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_not_verified_wording_passes_without_live_proof(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Linked issue: #263",
|
||||
"- Linked issue live status: not verified in this session",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_missing_issue_requires_not_verified(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Linked issue: #999",
|
||||
"- Linked issue live status: Issue #999 (open)",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(
|
||||
report,
|
||||
reconcile_session={"issue_missing": True, "linked_issue_number": 999},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("not verified" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_text_live_proof_without_session_lock_passes(self):
|
||||
report = _good_handoff()
|
||||
result = assess_reconcile_linked_issue_report(report)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
self.assertTrue(result["live_proof_present"])
|
||||
|
||||
def test_issue_action_recommendation_requires_capability_proof(self):
|
||||
report = "\n".join([
|
||||
_good_handoff(),
|
||||
"Recommended: close linked issue #263 after PR reconciliation.",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(
|
||||
report,
|
||||
reconcile_session={"live_fetched": True},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_issue_action_with_capability_proof_passes(self):
|
||||
report = "\n".join([
|
||||
_good_handoff(),
|
||||
"Recommended: close linked issue #263.",
|
||||
"Capability proof: gitea_close_issue allowed for prgs-author.",
|
||||
])
|
||||
result = assess_reconcile_linked_issue_report(
|
||||
report,
|
||||
reconcile_session={"live_fetched": True},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
def test_review_proofs_reexport(self):
|
||||
from review_proofs import assess_reconcile_linked_issue_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for transient validation failure history verifier (#396)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from reviewer_validation_failure_history import ( # noqa: E402
|
||||
assess_validation_failure_history_report,
|
||||
)
|
||||
|
||||
|
||||
def _full_history_report() -> str:
|
||||
return "\n".join([
|
||||
"Validation failure history",
|
||||
"Failure #1:",
|
||||
"Command: venv/bin/python -m pytest tests/test_worktrees.py -q",
|
||||
"Failing test: tests/test_worktrees.py::TestWorktreeStart::test_rejects_untraceable_branches",
|
||||
"Suspected cause: stale /tmp/gitea_issue_lock.json from prior session",
|
||||
"Reproduced: no",
|
||||
"On baseline master: no",
|
||||
"PR-caused: no",
|
||||
"What changed between runs: removed /tmp/gitea_issue_lock.json",
|
||||
"Environmental cleanup: lock file removed before rerun",
|
||||
"Validation: passed after transient failure investigation",
|
||||
"Final validation command: venv/bin/python -m pytest tests/ -q",
|
||||
"Final result: 1497 passed, 6 skipped",
|
||||
])
|
||||
|
||||
|
||||
class TestValidationFailureHistory(unittest.TestCase):
|
||||
def test_no_failures_observed_passes(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed",
|
||||
validation_session={"observed_failures": []},
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_omitted_failure_blocks(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed\n1497 passed, 6 skipped",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/test_worktrees.py -q",
|
||||
"failing_test": (
|
||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||
"test_rejects_untraceable_branches"
|
||||
),
|
||||
}],
|
||||
"final_validation_status": "passed",
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_history_with_transient_investigation_passes(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
_full_history_report(),
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": (
|
||||
"venv/bin/python -m pytest tests/test_worktrees.py -q"
|
||||
),
|
||||
"failing_test": (
|
||||
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||
"test_rejects_untraceable_branches"
|
||||
),
|
||||
"suspected_cause": "stale /tmp/gitea_issue_lock.json",
|
||||
"reproduced": "no",
|
||||
"on_baseline_master": "no",
|
||||
"pr_caused": "no",
|
||||
}],
|
||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||
"environmental_contamination": True,
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_baseline_equivalent_failure_documented(self):
|
||||
report = "\n".join([
|
||||
"Validation failure history",
|
||||
"Command: pytest tests/test_example.py -q",
|
||||
"Failing test: tests/test_example.py::test_flaky",
|
||||
"Suspected cause: pre-existing master failure",
|
||||
"On baseline master: yes",
|
||||
"PR-caused: no",
|
||||
"What changed between runs: none; failure matches baseline",
|
||||
"Validation: passed after transient failure investigation",
|
||||
])
|
||||
result = assess_validation_failure_history_report(
|
||||
report,
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/test_example.py -q",
|
||||
"failing_test": "tests/test_example.py::test_flaky",
|
||||
"on_baseline_master": "yes",
|
||||
"pr_caused": "no",
|
||||
}],
|
||||
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_unknown_cause_plain_pass_blocks(self):
|
||||
result = assess_validation_failure_history_report(
|
||||
"Validation: passed",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/ -q",
|
||||
"failing_test": "tests/test_foo.py::test_bar",
|
||||
"suspected_cause": "unknown",
|
||||
}],
|
||||
"final_validation_status": "passed",
|
||||
"cause_unknown": True,
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(any("transient" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_final_report_validator_rejects_omitted_failure(self):
|
||||
result = assess_final_report_validator(
|
||||
"Validation: passed",
|
||||
"review_pr",
|
||||
validation_session={
|
||||
"observed_failures": [{
|
||||
"command": "pytest tests/ -q",
|
||||
"failing_test": "tests/test_foo.py::test_bar",
|
||||
}],
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["blocked"] or result["downgraded"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f.get("rule_id") == "reviewer.validation_failure_history"
|
||||
for f in result.get("findings") or []
|
||||
)
|
||||
)
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_validation_failure_history_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -52,6 +52,22 @@ CONFIG = {
|
||||
],
|
||||
"execution_profile": "prgs-reviewer",
|
||||
},
|
||||
"prgs-reconciler": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "reconciler",
|
||||
"username": "sysadmin",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_RECONCILER"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.pr.comment", "gitea.issue.comment",
|
||||
"gitea.issue.close", "gitea.pr.close",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
],
|
||||
"execution_profile": "prgs-reconciler",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
@@ -88,6 +104,7 @@ class TestRoleSessionRouter(unittest.TestCase):
|
||||
"GITEA_MCP_PROFILE": profile,
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
||||
}
|
||||
|
||||
def test_reviewer_task_under_author_profile_wrong_role_stop(self):
|
||||
@@ -99,6 +116,24 @@ class TestRoleSessionRouter(unittest.TestCase):
|
||||
self.assertFalse(route["downstream_allowed"])
|
||||
self.assertIn("Wrong role/session for reviewer task", route["message"])
|
||||
|
||||
def test_pr_queue_cleanup_under_author_profile_wrong_role_stop(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author")):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(route["route_result"], role_session_router.ROUTE_WRONG_ROLE)
|
||||
self.assertFalse(route["downstream_allowed"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_pr_queue_cleanup_under_reviewer_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("prgs-reviewer")):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type="pr_queue_cleanup", remote="prgs"
|
||||
)
|
||||
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
|
||||
self.assertTrue(route["downstream_allowed"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_issue_creation_blocked_after_reviewer_wrong_role_stop(
|
||||
@@ -157,6 +192,31 @@ class TestRoleSessionRouter(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.get("number"), 999)
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_work_issue_task_under_author_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("prgs-author")):
|
||||
for task_type in ("work_issue", "work-issue"):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type=task_type, remote="prgs"
|
||||
)
|
||||
self.assertEqual(
|
||||
route["route_result"], role_session_router.ROUTE_ALLOWED
|
||||
)
|
||||
self.assertTrue(route["downstream_allowed"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_work_issue_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("prgs-reviewer")):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type="work-issue", remote="prgs"
|
||||
)
|
||||
self.assertEqual(
|
||||
route["route_result"], role_session_router.ROUTE_TO_AUTHOR
|
||||
)
|
||||
self.assertFalse(route["downstream_allowed"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||
def test_reviewer_task_under_reviewer_profile_allowed(self, _auth, _api):
|
||||
@@ -179,6 +239,24 @@ class TestRoleSessionRouter(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse(route["downstream_allowed"])
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token reconciler-pass")
|
||||
def test_reconciler_task_under_reconciler_profile_allowed(self, _auth, _api):
|
||||
with patch.dict(os.environ, self._env("prgs-reconciler")):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type="reconcile_already_landed_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
|
||||
self.assertTrue(route["downstream_allowed"])
|
||||
|
||||
def test_reconciler_task_under_author_profile_wrong_role_stop(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author")):
|
||||
route = mcp_server.gitea_route_task_session(
|
||||
task_type="reconcile_already_landed_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(route["route_result"], role_session_router.ROUTE_WRONG_ROLE)
|
||||
self.assertFalse(route["downstream_allowed"])
|
||||
|
||||
def test_activate_profile_blocked_in_static_mode(self):
|
||||
with patch.dict(os.environ, self._env("prgs-author")):
|
||||
result = mcp_server.gitea_activate_profile(
|
||||
|
||||
Reference in New Issue
Block a user