PR #416 introduced pr_work_lease reviewer blocks and mandatory full 40-hex expected_head_sha on mark/merge paths. Update audit, review, permission-report, terminal hard-stop, inventory, and schema tests to patch lease comment fetches, seed ready review decisions, and use consistent HEAD_SHA constants so the full suite passes again.
553 lines
25 KiB
Python
553 lines
25 KiB
Python
import os
|
|
import sys
|
|
import unittest
|
|
import re
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
from mcp_server import (
|
|
gitea_list_prs,
|
|
gitea_view_pr,
|
|
gitea_review_pr,
|
|
gitea_check_pr_eligibility,
|
|
)
|
|
import gitea_config
|
|
|
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
|
|
|
|
|
def _final_page_fetch(items):
|
|
return (items, {
|
|
"page": 1,
|
|
"per_page": 50,
|
|
"returned_count": len(items),
|
|
"has_more": False,
|
|
"next_page": None,
|
|
"is_final_page": True,
|
|
})
|
|
|
|
|
|
class TestPRQueueInventory(unittest.TestCase):
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read", "gitea.pr.comment"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([
|
|
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True}
|
|
])
|
|
result = gitea_list_prs(state="open", remote="prgs")
|
|
self.assertEqual(len(result["prs"]), 1)
|
|
self.assertEqual(result["prs"][0]["number"], 1)
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
@patch("gitea_config.is_runtime_switching_enabled", return_value=True)
|
|
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([
|
|
{
|
|
"number": 1,
|
|
"title": "PR 1",
|
|
"state": "open",
|
|
"head": {"ref": "branch1", "sha": "abc1"},
|
|
"base": {"ref": "master"},
|
|
"mergeable": True,
|
|
"user": {"login": "jcwalker3"},
|
|
"labels": [{"name": "bug"}],
|
|
"body": "Fixes #10",
|
|
"updated_at": "2026-07-05T10:00:00Z"
|
|
},
|
|
{
|
|
"number": 2,
|
|
"title": "PR 2",
|
|
"state": "open",
|
|
"head": {"ref": "branch2", "sha": "abc2"},
|
|
"base": {"ref": "master"},
|
|
"mergeable": True,
|
|
"user": {"login": "other_user"},
|
|
"labels": [],
|
|
"body": "",
|
|
"updated_at": "2026-07-05T11:00:00Z"
|
|
}
|
|
])
|
|
mock_api.return_value = {"login": "jcwalker3"} # whoami
|
|
|
|
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
|
self.assertIn("Repository:", result["message"])
|
|
self.assertIn("Open PRs found: 2", result["message"])
|
|
self.assertIn("Review/merge blocked (Self-author). Requires a genuine non-author reviewer", result["message"])
|
|
self.assertIn("Review/merge blocked (Current profile is Author)", result["message"])
|
|
self.assertIn("gitea_activate_profile", result["message"])
|
|
# updated_at surfaced for reconciliation (#166)
|
|
self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"])
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
|
for call in mock_api.call_args_list:
|
|
method = call.args[0]
|
|
self.assertEqual(method, "GET")
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-reviewer",
|
|
"allowed_operations": ["read", "approve", "review"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
|
|
from mcp_server import init_review_decision_lock
|
|
from tests.test_mcp_server import (
|
|
FULL_HEAD_SHA,
|
|
_install_owned_reviewer_lease,
|
|
_seed_ready_review_decision,
|
|
)
|
|
import reviewer_pr_lease
|
|
|
|
head_sha = FULL_HEAD_SHA
|
|
mock_fetch.return_value = _final_page_fetch([
|
|
{"number": 1, "title": "PR 1", "state": "open",
|
|
"head": {"ref": "branch1", "sha": head_sha},
|
|
"base": {"ref": "master"}, "mergeable": True,
|
|
"user": {"login": "other_user"}}
|
|
])
|
|
mock_api.side_effect = [
|
|
{"login": "reviewer1"},
|
|
{"login": "reviewer1"},
|
|
{
|
|
"user": {"login": "other_user"},
|
|
"state": "open",
|
|
"head": {"sha": head_sha},
|
|
"mergeable": True,
|
|
},
|
|
{"id": 100, "state": "APPROVED"},
|
|
[
|
|
{
|
|
"id": 100,
|
|
"user": {"login": "reviewer1"},
|
|
"state": "APPROVED",
|
|
"commit_id": head_sha,
|
|
"submitted_at": "2026-07-06T10:00:00Z",
|
|
"dismissed": False,
|
|
}
|
|
],
|
|
]
|
|
|
|
with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
|
|
patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
|
init_review_decision_lock("prgs", "review_pr")
|
|
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
|
|
lease_patch = _install_owned_reviewer_lease(
|
|
1, head_sha=head_sha, session_id="inventory-review-lease",
|
|
)
|
|
lease_patch.start()
|
|
self.addCleanup(lease_patch.stop)
|
|
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
result = gitea_review_pr(
|
|
pr_number=1, event="APPROVE", remote="prgs",
|
|
final_review_decision_ready=True,
|
|
)
|
|
self.assertTrue(result["success"])
|
|
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
|
self.assertIn("Repository:", result["message"])
|
|
self.assertIn("Successfully submitted review", result["message"])
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
@patch("gitea_config.is_runtime_switching_enabled", return_value=False)
|
|
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertIn("Repository:", result["message"])
|
|
self.assertNotIn("gitea_activate_profile", result["message"])
|
|
self.assertIn("Switch to the reviewer MCP session", result["message"])
|
|
|
|
@patch("mcp_server.get_profile")
|
|
def test_stopped_before_listing_is_not_reported_as_no_prs(self, mock_get_profile):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-restricted",
|
|
"allowed_operations": [],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertIn("PR inventory not attempted", result["message"])
|
|
self.assertNotIn("Open PRs found: 0", result["message"])
|
|
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_reviewer_eligibility_check_not_run_for_author_profile(self, mock_get_profile, _auth, mock_api):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
result = gitea_check_pr_eligibility(pr_number=1, action="approve", remote="prgs")
|
|
self.assertFalse(result["eligible"])
|
|
self.assertEqual(mock_api.call_count, 0)
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
"""Inventory failure path must use redaction and not leak raw exception text."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
# Exception contains something that should be redacted or not leaked raw
|
|
mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
msg = result.get("message", "")
|
|
self.assertIn("=== PR Queue Inventory ===", msg)
|
|
self.assertIn("Repository:", msg)
|
|
self.assertIn("PR inventory failed:", msg)
|
|
# raw secret and potentially sensitive details must not appear
|
|
self.assertNotIn("supersecrettoken123", msg)
|
|
self.assertNotIn("token=supersecrettoken123", msg)
|
|
# uses project's redaction pattern (token prefix -> [REDACTED])
|
|
# even if url leaks, the exception is redacted per pattern
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
"""Failure paths must fully redact real service hostnames and URLs from inventory output."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
msg = result.get("message", "")
|
|
self.assertIn("=== PR Queue Inventory ===", msg)
|
|
self.assertIn("[REDACTED_URL]", msg)
|
|
self.assertNotIn("gitea.prgs.cc", msg)
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
"""Author profiles with read can perform read-only PR inventory (report produced, no mutations)."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([
|
|
{"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""}
|
|
])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
msg = result["message"]
|
|
self.assertIn("=== PR Queue Inventory ===", msg)
|
|
self.assertIn("Repository:", msg)
|
|
self.assertIn("Open PRs found: 1", msg)
|
|
# no mutation calls (covered by other test but reconfirm here)
|
|
for call in mock_api.call_args_list:
|
|
self.assertEqual(call.args[0], "GET")
|
|
|
|
@patch("mcp_server._local_git_remote_url")
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
|
|
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
|
|
):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
mock_local_url.return_value = None
|
|
|
|
result = gitea_review_pr(
|
|
pr_number=1,
|
|
event="APPROVE",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
)
|
|
self.assertFalse(result["success"])
|
|
msg = result["message"]
|
|
self.assertIn("pr_inventory_trust_gate.status: untrusted_empty", msg)
|
|
self.assertIn("Empty-queue claim blocked", msg)
|
|
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
|
|
|
|
@patch("mcp_server._local_git_remote_url")
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_empty_inventory_trusted_empty_when_gate_passes(
|
|
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
|
|
):
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
mock_local_url.return_value = (
|
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
|
)
|
|
|
|
result = gitea_review_pr(
|
|
pr_number=1,
|
|
event="APPROVE",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
)
|
|
self.assertFalse(result["success"])
|
|
msg = result["message"]
|
|
self.assertIn("pr_inventory_trust_gate.status: trusted_empty", msg)
|
|
self.assertIn("Open PRs found: 0 (trusted_empty)", msg)
|
|
|
|
@patch("mcp_server._local_git_remote_url")
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url):
|
|
"""Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory."""
|
|
mock_local_url.return_value = (
|
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
|
)
|
|
for event in ["APPROVE", "REQUEST_CHANGES"]:
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([])
|
|
mock_api.return_value = {"login": "jcwalker3"}
|
|
|
|
result = gitea_review_pr(pr_number=1, event=event, remote="prgs")
|
|
self.assertFalse(result["success"])
|
|
self.assertIn("Review/Merge Blocked", result["message"])
|
|
self.assertIn("does not have review or merge permissions", result["message"])
|
|
# ensure no mutation attempted
|
|
mutation_calls = [c for c in mock_api.call_args_list if c.args[0] != "GET"]
|
|
self.assertEqual(len(mutation_calls), 0)
|
|
|
|
# quick check merge path is still hard-gated (via eligibility in submit, but author profile blocks early)
|
|
# we already block before calling submit for non-reviewer
|
|
|
|
# --- Requirement-5 conflict/staleness scenario tests (Issue #166 / #170) ---
|
|
# These prove coverage for the five cases; they exercise live list/view
|
|
# (with staleness fields added in #166) under mocked conflicting states.
|
|
# Tests use direct list/view calls + inventory path to ensure fields and
|
|
# live data are available for detection without trusting prior handoffs.
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch):
|
|
"""Scenario 1: stale prior handoff says merged but live PR list says open."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_fetch.return_value = _final_page_fetch([{
|
|
"number": 99, "title": "Stale merge claim", "state": "open",
|
|
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
|
|
"mergeable": True, "user": {"login": "other"},
|
|
"labels": [], "body": "Fixes #200",
|
|
"updated_at": "2026-07-05T22:00:00Z"
|
|
}])
|
|
prs = gitea_list_prs(state="open", remote="prgs")
|
|
self.assertEqual(len(prs["prs"]), 1)
|
|
self.assertEqual(prs["prs"][0]["state"], "open")
|
|
self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged"
|
|
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_r5_s2_prior_handoff_open_but_live_pr_closed(self, mock_get_profile, _auth, mock_api):
|
|
"""Scenario 2: prior handoff says open but live view says closed."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_api.return_value = {
|
|
"number": 88, "title": "Closed but prior said open", "state": "closed",
|
|
"head": {"ref": "f88"}, "base": {"ref": "master"},
|
|
"mergeable": None, "body": "", "user": {"login": "u"},
|
|
"updated_at": "2026-07-05T21:00:00Z",
|
|
"merged_at": "2026-07-05T21:05:00Z",
|
|
"merge_commit_sha": "m123", "closed_at": "2026-07-05T21:05:00Z"
|
|
}
|
|
pr = gitea_view_pr(pr_number=88, remote="prgs")
|
|
self.assertEqual(pr["state"], "closed")
|
|
self.assertIsNotNone(pr.get("closed_at"))
|
|
# live closed would contradict prior "open" handoff
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
"""Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
# list says open
|
|
mock_fetch.return_value = _final_page_fetch([{
|
|
"number": 77, "title": "Disagree", "state": "open",
|
|
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
|
|
"mergeable": True, "user": {"login": "x"},
|
|
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
|
|
}])
|
|
# view says closed (simulating inconsistency)
|
|
mock_api.return_value = {
|
|
"number": 77, "title": "Disagree", "state": "closed",
|
|
"head": {"ref": "f77"}, "base": {"ref": "master"},
|
|
"mergeable": None, "body": "", "user": {"login": "x"},
|
|
"updated_at": "2026-07-05T23:01:00Z",
|
|
"merged_at": None, "merge_commit_sha": None, "closed_at": None
|
|
}
|
|
listed = gitea_list_prs(state="open", remote="prgs")
|
|
viewed = gitea_view_pr(pr_number=77, remote="prgs")
|
|
self.assertEqual(listed["prs"][0]["state"], "open")
|
|
self.assertEqual(viewed["state"], "closed")
|
|
# caller must reconcile; mismatch is hard blocker per rules
|
|
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_r5_s4_merge_commit_missing_after_claimed_merge(self, mock_get_profile, _auth, mock_api):
|
|
"""Scenario 4: merge commit missing after claimed merge."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
mock_api.return_value = {
|
|
"number": 55, "title": "Claimed merge no commit", "state": "closed",
|
|
"head": {"ref": "f55"}, "base": {"ref": "master"},
|
|
"mergeable": False, "body": "Fixes #55",
|
|
"user": {"login": "u"},
|
|
"updated_at": "2026-07-05T20:10:00Z",
|
|
"merged_at": "2026-07-05T20:09:00Z",
|
|
"merge_commit_sha": None, # missing!
|
|
"closed_at": "2026-07-05T20:09:00Z"
|
|
}
|
|
pr = gitea_view_pr(pr_number=55, remote="prgs")
|
|
self.assertEqual(pr["state"], "closed")
|
|
self.assertIsNone(pr.get("merge_commit_sha"))
|
|
# post-merge view must surface missing commit for detection
|
|
|
|
@patch("mcp_server.api_fetch_page")
|
|
@patch("mcp_server.api_request")
|
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("mcp_server.get_profile")
|
|
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
|
"""Scenario 5: linked issue remains open after claimed merge."""
|
|
mock_get_profile.return_value = {
|
|
"profile_name": "gitea-author",
|
|
"allowed_operations": ["read"],
|
|
"forbidden_operations": [],
|
|
"base_url": None,
|
|
}
|
|
# PR claims merge, links #300
|
|
mock_fetch.return_value = _final_page_fetch([{
|
|
"number": 300, "title": "PR for #300", "state": "closed",
|
|
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
|
|
"mergeable": True, "user": {"login": "u"},
|
|
"labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z"
|
|
}])
|
|
# But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link
|
|
# For this test we assert the PR data shows link, caller must check issue state live
|
|
mock_api.return_value = {
|
|
"number": 300, "title": "PR for #300", "state": "closed",
|
|
"head": {"ref": "f300"}, "base": {"ref": "master"},
|
|
"mergeable": False, "body": "Fixes #300",
|
|
"user": {"login": "u"},
|
|
"updated_at": "2026-07-05T20:20:00Z",
|
|
"merged_at": "2026-07-05T20:21:00Z", "merge_commit_sha": "mc300", "closed_at": "2026-07-05T20:21:00Z"
|
|
}
|
|
prs = gitea_list_prs(state="closed", remote="prgs")
|
|
pr = gitea_view_pr(pr_number=300, remote="prgs")
|
|
self.assertIn("#300", pr.get("body", ""))
|
|
self.assertIsNotNone(pr.get("merge_commit_sha"))
|
|
# real impl must cross-check live issue #300 state != closed or label still in-progress
|