Add non-terminal review draft save/resume tools so a fully validated reviewer verdict can survive MCP disconnect or stale-runtime restart without counting as a formal review. - gitea_save_review_draft: durable draft with PR, head SHA, target SHA, worktree, validation evidence, intended verdict, and blocker reason - gitea_resume_review_draft: live revalidation of head/target/#332/lease/ identity/worktree before mark-ready or optional submit - Drafts never substitute for formal approval without revalidation Closes #609
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""Tests for preserving and resuming prepared Gitea review drafts (#609)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
sys_path_root = str(Path(__file__).resolve().parent.parent)
|
|
if sys_path_root not in sys.path:
|
|
sys.path.insert(0, sys_path_root)
|
|
|
|
import mcp_session_state
|
|
import gitea_mcp_server
|
|
import reviewer_pr_lease
|
|
|
|
|
|
class TestReviewDrafts(unittest.TestCase):
|
|
def setUp(self):
|
|
self._tmpdir = tempfile.TemporaryDirectory()
|
|
self.state_dir = self._tmpdir.name
|
|
self._env = patch.dict(
|
|
os.environ,
|
|
{
|
|
mcp_session_state.STATE_DIR_ENV: self.state_dir,
|
|
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
|
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
|
"PYTEST_CURRENT_TEST": "1", # skip runtime stale checks
|
|
},
|
|
clear=False,
|
|
)
|
|
self._env.start()
|
|
|
|
# Mock workspace binding to pass in test context
|
|
self._nwb_patch = patch(
|
|
"gitea_mcp_server.nwb.assess_namespace_mutation_workspace",
|
|
return_value={"block": False, "mutation_workspace": "/tmp/test-worktree"},
|
|
)
|
|
self._nwb_patch.start()
|
|
|
|
# Mock authentication headers
|
|
self._auth_patch = patch(
|
|
"gitea_mcp_server.get_auth_header",
|
|
return_value="Bearer mock-token",
|
|
)
|
|
self._auth_patch.start()
|
|
|
|
self._username_patch = patch(
|
|
"gitea_mcp_server._authenticated_username",
|
|
return_value="sysadmin",
|
|
)
|
|
self._username_patch.start()
|
|
|
|
# Clear/Mock local session lease in-memory state
|
|
reviewer_pr_lease.clear_session_lease()
|
|
|
|
def tearDown(self):
|
|
self._username_patch.stop()
|
|
self._auth_patch.stop()
|
|
self._nwb_patch.stop()
|
|
self._env.stop()
|
|
self._tmpdir.cleanup()
|
|
reviewer_pr_lease.clear_session_lease()
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
def test_save_draft_success(self, mock_api):
|
|
mock_api.return_value = {
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
res = gitea_mcp_server.gitea_save_review_draft(
|
|
pr_number=587,
|
|
action="approve",
|
|
body="Good changes",
|
|
expected_head_sha="headsha1234567890",
|
|
validation_commands="pytest tests/",
|
|
validation_results="all pass",
|
|
blocker_reason="stale daemon",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertTrue(res.get("success"))
|
|
self.assertEqual(res.get("head_sha"), "headsha1234567890")
|
|
|
|
# Load draft and verify fields
|
|
draft = mcp_session_state.load_state(
|
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
profile_identity="prgs-reviewer",
|
|
)
|
|
self.assertIsNotNone(draft)
|
|
self.assertEqual(draft.get("pr_number"), 587)
|
|
self.assertEqual(draft.get("action"), "approve")
|
|
self.assertEqual(draft.get("body"), "Good changes")
|
|
self.assertEqual(draft.get("validation_commands"), "pytest tests/")
|
|
self.assertEqual(draft.get("validation_results"), "all pass")
|
|
self.assertEqual(draft.get("blocker_reason"), "stale daemon")
|
|
self.assertEqual(os.path.realpath(draft.get("worktree_path")), os.path.realpath("/tmp/test-worktree"))
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
|
@patch("gitea_mcp_server.gitea_submit_pr_review")
|
|
def test_resume_draft_and_submit_success(self, mock_submit, mock_comments, mock_api):
|
|
# 1. Save draft
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
|
|
gitea_mcp_server.gitea_save_review_draft(
|
|
pr_number=587,
|
|
action="approve",
|
|
body="Good changes",
|
|
expected_head_sha="headsha1234567890",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
|
|
# Seed local session lease
|
|
reviewer_pr_lease.record_session_lease({
|
|
"pr_number": 587,
|
|
"session_id": "session-1234",
|
|
})
|
|
|
|
# Mock Gitea comments to return active reviewer lease owned by us
|
|
mock_comments.return_value = [
|
|
{
|
|
"id": 1,
|
|
"user": {"username": "sysadmin"},
|
|
"body": (
|
|
"<!-- mcp-review-lease:v1 -->\n"
|
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
|
"pr: #587\n"
|
|
"reviewer_identity: sysadmin\n"
|
|
"profile: prgs-reviewer\n"
|
|
"session_id: session-1234\n"
|
|
"phase: claimed\n"
|
|
),
|
|
}
|
|
]
|
|
|
|
mock_submit.return_value = {"performed": True, "success": True}
|
|
|
|
# 2. Resume draft
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
submit=True,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
|
|
self.assertTrue(res.get("success"))
|
|
self.assertTrue(res.get("marked_ready"))
|
|
self.assertTrue(res.get("submitted"))
|
|
mock_submit.assert_called_once()
|
|
|
|
# Verify draft was deleted after successful submit
|
|
draft = mcp_session_state.load_state(
|
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
profile_identity="prgs-reviewer",
|
|
)
|
|
self.assertIsNone(draft)
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
|
def test_resume_draft_fails_checks(self, mock_comments, mock_api):
|
|
# Save a draft
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
gitea_mcp_server.gitea_save_review_draft(
|
|
pr_number=587,
|
|
action="approve",
|
|
body="Good changes",
|
|
expected_head_sha="headsha1234567890",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
|
|
# Seed local session lease
|
|
reviewer_pr_lease.record_session_lease({
|
|
"pr_number": 587,
|
|
"session_id": "session-1234",
|
|
})
|
|
|
|
# Mock Gitea comments to return active reviewer lease owned by us
|
|
mock_comments.return_value = [
|
|
{
|
|
"id": 1,
|
|
"user": {"username": "sysadmin"},
|
|
"body": (
|
|
"<!-- mcp-review-lease:v1 -->\n"
|
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
|
"pr: #587\n"
|
|
"reviewer_identity: sysadmin\n"
|
|
"profile: prgs-reviewer\n"
|
|
"session_id: session-1234\n"
|
|
"phase: claimed\n"
|
|
),
|
|
}
|
|
]
|
|
|
|
# Case A: PR closed
|
|
mock_api.return_value = {
|
|
"state": "closed",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertIn("PR #587 is not open", res.get("reasons")[0])
|
|
|
|
# Case B: Head changed
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha_NEW"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertIn("PR head SHA changed", res.get("reasons")[0])
|
|
|
|
# Case C: Target branch changed
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha_NEW"},
|
|
}
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertIn("target branch SHA changed", res.get("reasons")[0])
|
|
|
|
# Case D: Worktree mismatch
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/different-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
|
def test_resume_draft_foreign_lease(self, mock_comments, mock_api):
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
gitea_mcp_server.gitea_save_review_draft(
|
|
pr_number=587,
|
|
action="approve",
|
|
body="Good changes",
|
|
expected_head_sha="headsha1234567890",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
reviewer_pr_lease.record_session_lease({
|
|
"pr_number": 587,
|
|
"session_id": "my-session",
|
|
})
|
|
mock_comments.return_value = [
|
|
{
|
|
"id": 9,
|
|
"user": {"username": "sysadmin"},
|
|
"body": (
|
|
"<!-- mcp-review-lease:v1 -->\n"
|
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
|
"pr: #587\n"
|
|
"reviewer_identity: sysadmin\n"
|
|
"profile: prgs-reviewer\n"
|
|
"session_id: foreign-session-999\n"
|
|
"phase: claimed\n"
|
|
),
|
|
}
|
|
]
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertTrue(
|
|
any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or []))
|
|
)
|
|
|
|
@patch("gitea_mcp_server.api_request")
|
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
|
@patch("gitea_mcp_server.terminal_review_hard_stop_reasons")
|
|
def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api):
|
|
mock_api.return_value = {
|
|
"state": "open",
|
|
"head": {"sha": "headsha1234567890"},
|
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
|
}
|
|
gitea_mcp_server.gitea_save_review_draft(
|
|
pr_number=587,
|
|
action="approve",
|
|
body="Good changes",
|
|
expected_head_sha="headsha1234567890",
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
reviewer_pr_lease.record_session_lease({
|
|
"pr_number": 587,
|
|
"session_id": "session-1234",
|
|
})
|
|
mock_comments.return_value = [
|
|
{
|
|
"id": 1,
|
|
"user": {"username": "sysadmin"},
|
|
"body": (
|
|
"<!-- mcp-review-lease:v1 -->\n"
|
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
|
"pr: #587\n"
|
|
"reviewer_identity: sysadmin\n"
|
|
"profile: prgs-reviewer\n"
|
|
"session_id: session-1234\n"
|
|
"phase: claimed\n"
|
|
),
|
|
}
|
|
]
|
|
mock_hard_stop.return_value = [
|
|
"terminal review mutation already recorded for PR #587 (approve); #332 hard-stop"
|
|
]
|
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
|
pr_number=587,
|
|
remote="prgs",
|
|
org="Scaled-Tech-Consulting",
|
|
repo="Gitea-Tools",
|
|
worktree_path="/tmp/test-worktree",
|
|
)
|
|
self.assertFalse(res.get("success"))
|
|
self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or [])))
|
|
|