feat: add reconciliation workflow MCP tools for already-landed PRs (Closes #301)
Expose read-only assess/scan tools and capability planning so already-landed open PRs can be reconciled without review or merge. Register the gitea-reconcile-landed-pr skill and workflow-source verification.
This commit is contained in:
@@ -118,6 +118,16 @@ 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_reviewer_fallback_verifier_exported():
|
||||
from review_proofs import assess_reviewer_fallback_report
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user