When gitea_lock_issue adopts an existing own branch, the live tool response now carries explicit, citable adoption-proof fields so #473-style recovery sessions can quote the lock output directly instead of inferring adoption from separate offline checks. - issue_lock_adoption.py: add DECISION_LABELS + decision_label()/ safe_next_action() helpers; extend build_adoption_proof() with adoption_decision, adopted, adopted_branch, adopted_branch_head, matcher_summary (boundary-safe reason), competing_branch_check, and safe_next_action for all outcomes; add build_non_adoption_lock_proof() for adoption-free NO_MATCH metadata. - gitea_mcp_server.py: attach adoption-free adoption_check block to normal (non-adopt) lock responses so they cannot be misread as claiming adoption. - docs/llm-workflow-runbooks.md: document the adoption/adoption_check response blocks and instruct recovery reports to cite them directly. - tests: explicit-field proof for ADOPT/BLOCK_COMPETING/NO_MATCH, substring-collision boundary safety (issue-42 vs issue-420), non-adoption proof, and MCP-level live-response assertions. BLOCK_COMPETING lock attempts still fail closed (raise); no raw API fallback, branch deletion, force-push, or manual lock seeding introduced. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
227 lines
8.9 KiB
Python
227 lines
8.9 KiB
Python
"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from issue_lock_adoption import ( # noqa: E402
|
|
ADOPT,
|
|
BLOCK_COMPETING,
|
|
NO_MATCH,
|
|
assess_own_branch_adoption,
|
|
build_adoption_proof,
|
|
build_non_adoption_lock_proof,
|
|
)
|
|
|
|
REQ = "feat/issue-420-server-code-parity"
|
|
|
|
|
|
class TestAssessOwnBranchAdoption(unittest.TestCase):
|
|
def test_exact_own_branch_is_adopted(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420,
|
|
requested_branch=REQ,
|
|
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
|
)
|
|
self.assertEqual(result["outcome"], ADOPT)
|
|
self.assertTrue(result["adopt"])
|
|
self.assertFalse(result["block"])
|
|
self.assertEqual(result["matched_branch"], REQ)
|
|
self.assertEqual(result["matched_head_sha"], "934688a")
|
|
|
|
def test_exact_own_branch_adopted_when_sha_missing(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
|
|
)
|
|
self.assertEqual(result["outcome"], ADOPT)
|
|
self.assertIsNone(result["matched_head_sha"])
|
|
|
|
def test_different_branch_same_issue_blocks(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420,
|
|
requested_branch=REQ,
|
|
existing_branches=[{"name": "feat/issue-420-other-work"}],
|
|
)
|
|
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
|
self.assertTrue(result["block"])
|
|
self.assertFalse(result["adopt"])
|
|
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
|
|
self.assertIn("fail closed", result["reason"])
|
|
|
|
def test_own_branch_plus_competing_branch_blocks(self):
|
|
# Ambiguous ownership: fail closed even though the exact branch exists.
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420,
|
|
requested_branch=REQ,
|
|
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
|
|
)
|
|
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
|
self.assertTrue(result["block"])
|
|
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
|
|
|
|
def test_no_matching_branch_is_normal_path(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420,
|
|
requested_branch=REQ,
|
|
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
|
)
|
|
self.assertEqual(result["outcome"], NO_MATCH)
|
|
self.assertFalse(result["block"])
|
|
self.assertFalse(result["adopt"])
|
|
|
|
def test_empty_branch_list_is_normal_path(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=420, requested_branch=REQ, existing_branches=[]
|
|
)
|
|
self.assertEqual(result["outcome"], NO_MATCH)
|
|
|
|
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
|
|
# issue-420 must not be treated as competing work for issue #42.
|
|
own_branch = "feat/issue-42-widget"
|
|
result = assess_own_branch_adoption(
|
|
issue_number=42,
|
|
requested_branch=own_branch,
|
|
existing_branches=[
|
|
{"name": own_branch, "commit_sha": "abc1234"},
|
|
{"name": "feat/issue-420-server-code-parity"},
|
|
],
|
|
)
|
|
self.assertEqual(result["outcome"], ADOPT)
|
|
self.assertTrue(result["adopt"])
|
|
self.assertFalse(result["block"])
|
|
self.assertEqual(result["matched_branch"], own_branch)
|
|
|
|
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
|
|
result = assess_own_branch_adoption(
|
|
issue_number=42,
|
|
requested_branch="feat/issue-42-thing",
|
|
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
|
|
)
|
|
self.assertEqual(result["outcome"], NO_MATCH)
|
|
self.assertFalse(result["block"])
|
|
self.assertFalse(result["adopt"])
|
|
|
|
|
|
class TestBuildAdoptionProof(unittest.TestCase):
|
|
def test_proof_has_all_required_fields(self):
|
|
assessment = assess_own_branch_adoption(
|
|
issue_number=420,
|
|
requested_branch=REQ,
|
|
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
|
)
|
|
proof = build_adoption_proof(
|
|
issue_number=420,
|
|
branch_name=REQ,
|
|
assessment=assessment,
|
|
open_pr_checked=True,
|
|
competing_lock_checked=True,
|
|
lock_file_path="/tmp/example-lock.json",
|
|
lock_file_status="written",
|
|
)
|
|
for key in (
|
|
"issue_number",
|
|
"branch_name",
|
|
"branch_head_commit",
|
|
"adoption_reason",
|
|
"no_existing_pr_proof",
|
|
"no_competing_live_lock_proof",
|
|
"lock_file_path",
|
|
"lock_file_status",
|
|
):
|
|
self.assertIn(key, proof)
|
|
self.assertEqual(proof["branch_head_commit"], "934688a")
|
|
self.assertTrue(proof["no_existing_pr_proof"])
|
|
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() |