feat: allow reconciler profile to run merged cleanup directly (Closes #523)
This commit is contained in:
@@ -106,11 +106,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"reconcile_merged_cleanups": {
|
"reconcile_merged_cleanups": {
|
||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"reconciliation_cleanup": {
|
"reconciliation_cleanup": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"work_issue": {
|
"work_issue": {
|
||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase):
|
|||||||
required_permission("reconciliation_cleanup"),
|
required_permission("reconciliation_cleanup"),
|
||||||
"gitea.branch.delete",
|
"gitea.branch.delete",
|
||||||
)
|
)
|
||||||
self.assertEqual(required_role("reconciliation_cleanup"), "author")
|
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Integration tests for reconciler merged cleanup (#523)."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import task_capability_map
|
||||||
|
from audit_reconciliation_mode import clear_phase
|
||||||
|
|
||||||
|
RECONCILER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "reconciler",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcilerCleanupIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
clear_phase()
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_capability_called = False
|
||||||
|
mcp_server._preflight_resolved_role = None
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
|
mcp_server._preflight_whoami_violation = False
|
||||||
|
mcp_server._preflight_capability_violation = False
|
||||||
|
mcp_server._preflight_whoami_violation_files = []
|
||||||
|
mcp_server._preflight_capability_violation_files = []
|
||||||
|
self.mock_api = patch("gitea_auth.api_request").start()
|
||||||
|
self.mock_auth = patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token test"
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
clear_phase()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
|
||||||
|
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||||
|
def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile):
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups")
|
||||||
|
|
||||||
|
# Mock closed PRs and open PRs returned by API
|
||||||
|
self.mock_api.side_effect = [
|
||||||
|
[ # closed pulls
|
||||||
|
{
|
||||||
|
"number": 100,
|
||||||
|
"title": "merged feature",
|
||||||
|
"body": "Closes #100",
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-06T12:00:00Z",
|
||||||
|
"merge_commit_sha": "deadbeef",
|
||||||
|
"head": {"ref": "feat/issue-100", "sha": "cafebabe"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[], # open pulls
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("mcp_server._remote_branch_exists", return_value=True):
|
||||||
|
with patch("mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True):
|
||||||
|
result = mcp_server.gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=True,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["dry_run"])
|
||||||
|
self.assertEqual(result["entries"][0]["issue_number"], 100)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
|
||||||
|
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||||
|
def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile):
|
||||||
|
# We simulate verify_preflight_purity under reconciler role.
|
||||||
|
# It should succeed without raising RuntimeError even if CWD/workspace is the process root.
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups")
|
||||||
|
|
||||||
|
# Calling verify_preflight_purity directly
|
||||||
|
try:
|
||||||
|
mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups")
|
||||||
|
except RuntimeError as e:
|
||||||
|
self.fail(f"verify_preflight_purity raised RuntimeError unexpectedly: {e}")
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, clear=True)
|
||||||
|
@patch("mcp_server.get_profile", return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
})
|
||||||
|
def test_author_role_remains_blocked_on_root_checkout(self, _profile):
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="author", resolved_task="reconcile_merged_cleanups")
|
||||||
|
|
||||||
|
with patch("mcp_server.PROJECT_ROOT", "/Users/jasonwalker/Development/Gitea-Tools"):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups")
|
||||||
|
self.assertIn("author mutation blocked: workspace is the stable control checkout", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -11,7 +11,8 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
||||||
from issue_claim_heartbeat import build_claim_inventory
|
from issue_claim_heartbeat import build_claim_inventory
|
||||||
from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock
|
from merged_cleanup_reconcile import read_issue_lock
|
||||||
|
from issue_lock_provenance import ISSUE_LOCK_FILE
|
||||||
|
|
||||||
from webui.project_registry import ProjectRecord, load_registry
|
from webui.project_registry import ProjectRecord, load_registry
|
||||||
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs
|
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs
|
||||||
|
|||||||
Reference in New Issue
Block a user