fix: resolve conflicts for PR #490

Merge prgs/master into feat/issue-470-preflight-contract, keeping stacked-PR
lock base support (#484) and explicit task binding on lock_issue preflight (#470).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 03:59:51 -04:00
co-authored by Claude Opus 4.8
19 changed files with 1491 additions and 10 deletions
+89
View File
@@ -11,6 +11,7 @@ from issue_lock_adoption import ( # noqa: E402
NO_MATCH,
assess_own_branch_adoption,
build_adoption_proof,
build_non_adoption_lock_proof,
)
REQ = "feat/issue-420-server-code-parity"
@@ -134,5 +135,93 @@ class TestBuildAdoptionProof(unittest.TestCase):
self.assertTrue(proof["no_competing_live_lock_proof"])
class TestExplicitAdoptionProofFields(unittest.TestCase):
"""#477: explicit, citable adoption-proof fields for all outcomes."""
def _proof(self, assessment, branch):
return build_adoption_proof(
issue_number=420,
branch_name=branch,
assessment=assessment,
open_pr_checked=True,
competing_lock_checked=True,
lock_file_path="/tmp/example-lock.json",
lock_file_status="written",
)
def test_adopt_proof_exposes_explicit_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertTrue(proof["adopted"])
self.assertEqual(proof["adopted_branch"], REQ)
self.assertEqual(proof["adopted_branch_head"], "934688a")
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
self.assertIn("gitea_create_pr", proof["safe_next_action"])
self.assertIn("exactly matches", proof["matcher_summary"])
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-420-rogue"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
self.assertIn(
"feat/issue-420-rogue",
proof["competing_branch_check"]["competing_branches"],
)
self.assertIn("fail closed", proof["safe_next_action"])
def test_no_match_proof_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
def test_substring_collision_stays_boundary_safe(self):
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
own = "feat/issue-42-widget"
assessment = assess_own_branch_adoption(
issue_number=42,
requested_branch=own,
existing_branches=[
{"name": own, "commit_sha": "abc1234"},
{"name": "feat/issue-420-server-code-parity"},
],
)
proof = self._proof(assessment, own)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertEqual(proof["adopted_branch"], own)
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
def test_non_adoption_lock_proof_is_adoption_free(self):
proof = build_non_adoption_lock_proof(
issue_number=196, branch_name="feat/issue-196-mutations"
)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertIn("no adoption", proof["safe_next_action"].lower())
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -157,6 +157,30 @@ class TestIssueLockStore(unittest.TestCase):
)
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
def test_approved_stacked_base_survives_round_trip(self):
# #484: the approved stacked base recorded on the lock must persist so
# gitea_create_pr can validate the non-master base at PR time.
record = _lock_record(
issue_number=482,
branch_name="feat/issue-482-skip-stale-request-changes-pr",
approved_stacked_base={
"branch": "feat/issue-478-mcp-menu-shell",
"pr_number": 479,
"verified_open": True,
},
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=482,
)
ils.save_lock_file(path, record)
stored = ils.read_lock_file(path)
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
def test_atomic_write_preserves_unrelated_lock(self):
path_a = ils.lock_file_path(
remote="prgs",
+53
View File
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
self.assertTrue(result["proven"])
class TestStackedBaseEquivalence(unittest.TestCase):
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
def _git(self, *args):
import subprocess
subprocess.run(
["git", "-C", self.repo, *args],
check=True,
capture_output=True,
text=True,
)
def setUp(self):
import subprocess
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
self._git("config", "user.email", "t@t")
self._git("config", "user.name", "t")
self._git("commit", "--allow-empty", "-q", "-m", "base")
# Rename the default branch away from master/main/dev so no *base* branch
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
self._git("branch", "-m", "trunk")
# Create a non-master "dependency" branch at the same commit, then a
# feature branch off it — mirrors a stacked worktree.
self._git("branch", "feat/issue-100-dep")
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
def tearDown(self):
self.tmp.cleanup()
def test_stacked_base_not_equivalent_without_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(self.repo)
# HEAD does not match master/main/dev, so base-equivalence is False.
self.assertFalse(state["base_equivalent"])
def test_stacked_base_equivalent_with_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/issue-100-dep",)
)
self.assertTrue(state["base_equivalent"])
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
def test_unrelated_extra_base_does_not_anchor(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/does-not-exist",)
)
self.assertFalse(state["base_equivalent"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
+6
View File
@@ -213,6 +213,12 @@ def test_validation_failure_history_verifier_exported():
assert callable(assess_validation_failure_history_report)
def test_validation_cwd_proof_verifier_exported():
from review_proofs import assess_validation_cwd_proof_report
assert callable(assess_validation_cwd_proof_report)
def test_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
+39
View File
@@ -3460,6 +3460,45 @@ class TestIssueLocking(unittest.TestCase):
self.assertIn("adoption", res)
self.assertEqual(res["adoption"]["branch_head_commit"], "abc123")
@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_adoption_response_has_explicit_proof(self, _auth, mock_api, _git_state):
# #477 AC1: the live lock response must carry citable adoption proof.
branch = "feat/issue-196-mutations"
self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"})
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
proof = res["adoption"]
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertTrue(proof["adopted"])
self.assertEqual(proof["adopted_branch"], branch)
self.assertEqual(proof["adopted_branch_head"], "abc123")
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertIn("gitea_create_pr", proof["safe_next_action"])
self.assertIn("196", proof["matcher_summary"])
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_no_match_response_does_not_claim_adoption(self, _auth, _api, _git_state):
# #477 AC2/AC3: a normal (NO_MATCH) lock must carry adoption-free proof
# and must NOT expose an ``adoption`` block.
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertNotIn("adoption", res)
check = res["adoption_check"]
self.assertEqual(check["adoption_decision"], "NO_MATCH")
self.assertFalse(check["adopted"])
self.assertIsNone(check["adopted_branch"])
self.assertEqual(check["competing_branch_check"]["result"], "clear")
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
@@ -0,0 +1,76 @@
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_server import ( # noqa: E402
_list_pr_lease_comments,
gitea_list_issue_comments,
)
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
}
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
mock_api.return_value = {"message": "Unauthorized"}
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
mock_api.return_value = None
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
mock_api.return_value = [comment]
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None, limit=5)
self.assertEqual(result, [comment])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api):
mock_api.return_value = {"message": "Unauthorized"}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(result["comments"], [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api):
mock_api.return_value = [
{
"id": 101,
"user": {"login": "alice"},
"body": "hello",
"created_at": "2026-07-03T00:00:00Z",
"updated_at": "2026-07-03T01:00:00Z",
}
]
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(len(result["comments"]), 1)
self.assertEqual(result["comments"][0]["author"], "alice")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,89 @@
"""Reconciler close_pr must not require author branches/ worktree (#468)."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = "token test"
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"audit_label": "prgs-reconciler",
}
class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
@patch("gitea_mcp_server.api_request")
def test_reconciler_close_pr_from_control_checkout_succeeds(
self, mock_api, _profile, _ns, _auth
):
srv._preflight_resolved_role = "reconciler"
mock_api.return_value = {
"number": 414,
"title": "old",
"body": "",
"state": "closed",
"html_url": "https://gitea.example.com/pulls/414",
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
result = srv.gitea_edit_pr(414, state="closed", remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(result["state"], "closed")
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
)
def test_author_create_issue_still_blocked_on_control_checkout(
self, _git, _get_all, _role, _ns, _prof, _auth
):
srv._preflight_resolved_role = "author"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test", body="body")
self.assertIn("stable control checkout", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
"""Tests for validation cwd/HEAD proof verifier (#398)."""
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_cwd_proof import assess_validation_cwd_proof_report # noqa: E402
ROOT = "/Users/jasonwalker/Development/Gitea-Tools"
WORKTREE = f"{ROOT}/branches/review-feat-issue-398"
HEAD = "f5953549aad5e822f14f52d3ea3c6d7990109384"
def _proof_backed_report() -> str:
return "\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {WORKTREE}",
f"git rev-parse HEAD: {HEAD}",
"git status --short --branch: ## feat/issue-398...prgs/master",
f"Validation command: cd {WORKTREE} && venv/bin/python -m pytest tests/ -q",
"Result: 1497 passed, 6 skipped",
])
class TestValidationCwdProof(unittest.TestCase):
def test_no_validation_claim_passes(self):
result = assess_validation_cwd_proof_report("Review decision: approve")
self.assertTrue(result["proven"])
def test_missing_cwd_proof_fails(self):
result = assess_validation_cwd_proof_report(
f"Validation command: pytest tests/\nCandidate head SHA: {HEAD}",
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_main_checkout_cwd_blocks(self):
result = assess_validation_cwd_proof_report(
"\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {ROOT}",
f"git rev-parse HEAD: {HEAD}",
"git status --short --branch: ## master",
"Validation command: pytest tests/ -q",
]),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(result["violations"])
def test_wrong_head_blocks(self):
wrong = "a" * 40
result = assess_validation_cwd_proof_report(
"\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {WORKTREE}",
f"git rev-parse HEAD: {wrong}",
"git status --short --branch: clean",
f"Validation command: cd {WORKTREE} && pytest -q",
]),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(result["violations"])
def test_fully_proof_backed_passes(self):
result = assess_validation_cwd_proof_report(
_proof_backed_report(),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertTrue(result["proven"], result["reasons"])
def test_baseline_without_cwd_fails(self):
result = assess_validation_cwd_proof_report(
"\n".join([
_proof_backed_report(),
"Baseline validation command: pytest tests/ -q",
]),
validation_session={
"validation_ran": True,
"expected_head_sha": HEAD,
"baseline_validation_ran": True,
},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(
any("baseline" in r.lower() for r in result["reasons"])
)
def test_baseline_with_full_proof_passes(self):
result = assess_validation_cwd_proof_report(
"\n".join([
_proof_backed_report(),
f"Baseline worktree: {ROOT}/branches/baseline-master-pr376",
f"Baseline target SHA: {HEAD}",
f"Baseline validation command: cd {ROOT}/branches/baseline-master-pr376 && pytest -q",
]),
validation_session={
"validation_ran": True,
"expected_head_sha": HEAD,
"baseline_validation_ran": True,
},
project_root=ROOT,
)
self.assertTrue(result["proven"], result["reasons"])
def test_final_report_validator_integration(self):
result = assess_final_report_validator(
"Validation command: pytest tests/ -q",
"review_pr",
validation_session={"validation_ran": True},
)
self.assertTrue(result["blocked"] or result["downgraded"])
self.assertTrue(
any(
f.get("rule_id") == "reviewer.validation_cwd_proof"
for f in result.get("findings") or []
)
)
def test_exported_from_review_proofs(self):
from review_proofs import assess_validation_cwd_proof_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+185
View File
@@ -0,0 +1,185 @@
"""Unit tests for stacked-PR base policy (#484)."""
import unittest
import stacked_pr_support as sps
def _pr(number, branch, state="open"):
return {"number": number, "state": state, "head": {"ref": branch}}
OPEN_PRS = [
_pr(479, "feat/issue-478-mcp-menu-shell"),
_pr(481, "feat/issue-477-lock-adoption-proof"),
]
# Motivating case (#482 stacked on #479 / #478).
STACKED_BODY = (
"Closes #482.\n\n"
"Stacked on PR #479 / issue #478.\n"
"Base branch: feat/issue-478-mcp-menu-shell\n"
"Head branch: feat/issue-482-skip-stale-request-changes-pr\n"
"Do not merge before PR #479 lands."
)
class TestIsBaseBranch(unittest.TestCase):
def test_master_main_dev_are_base(self):
for b in ("master", "main", "dev"):
self.assertTrue(sps.is_base_branch(b))
def test_feature_branch_is_not_base(self):
self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell"))
class TestStackedBaseDeclaration(unittest.TestCase):
def test_no_declaration_is_normal_path(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS
)
self.assertFalse(out["block"])
self.assertFalse(out["declared"])
self.assertIsNone(out["approved"])
def test_valid_open_pr_base_is_approved(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(out["approved"]["pr_number"], 479)
self.assertTrue(out["approved"]["verified_open"])
def test_missing_pr_number_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=None,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("without stacked_base_pr", out["reasons"][0])
def test_arbitrary_branch_with_no_open_pr_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/random-unrelated-branch",
stacked_base_pr=999,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0])
def test_stale_merged_base_blocks(self):
merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")]
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=merged,
)
self.assertTrue(out["block"])
self.assertIsNone(out["approved"])
def test_pr_number_mismatch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=481, # wrong PR for this branch
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the open", out["reasons"][0])
def test_declaring_a_base_branch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS
)
self.assertTrue(out["block"])
self.assertIn("already a normal base branch", out["reasons"][0])
class TestStackedPrBody(unittest.TestCase):
def test_complete_body_has_no_missing_fields(self):
missing = sps.assess_stacked_pr_body(
STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(missing, [])
def test_missing_all_fields(self):
missing = sps.assess_stacked_pr_body(
"just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 3)
def test_missing_merge_ordering_only(self):
body = "Base branch feat/issue-478-mcp-menu-shell for PR #479"
missing = sps.assess_stacked_pr_body(
body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 1)
self.assertIn("merge-ordering", missing[0])
class TestCreatePrBase(unittest.TestCase):
APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True}
def test_master_base_passes_without_stacked_metadata(self):
out = sps.assess_create_pr_base(
base="master", approved_stacked_base=None, body="Closes #1", open_prs=[]
)
self.assertFalse(out["block"])
self.assertFalse(out["stacked"])
def test_non_base_without_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=None,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("no approved stacked base", out["reasons"][0])
def test_non_base_mismatched_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/some-other-branch",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0])
def test_approved_base_with_good_body_passes(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertTrue(out["stacked"])
self.assertEqual(out["stacked_base_pr"], 479)
def test_approved_base_now_stale_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")],
)
self.assertTrue(out["block"])
self.assertIn("no longer corresponds to an OPEN", out["reasons"][0])
def test_approved_base_with_incomplete_body_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body="Closes #482 only",
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("must document the stack", out["reasons"][0])
if __name__ == "__main__":
unittest.main()