feat: add own-branch lock adoption recovery to gitea_lock_issue (Closes #442)

Closes #442.

gitea_lock_issue treated ANY remote branch containing issue-<n> as
duplicate competing work (#400) and failed closed — including the issue's
own already-pushed branch. If the in-memory lock was lost (e.g. MCP server
restart) after the branch was pushed but before the PR, there was no
non-destructive way to reacquire the lock, deadlocking PR creation. This
surfaced during recovery of #420 (feat/issue-420-server-code-parity).

Adds a safe own-branch adoption path.

Changes:
- issue_lock_adoption.py — assess_own_branch_adoption() distinguishes the
  issue's exact requested branch (adopt) from a different same-issue branch
  (competing, still fail-closed); ambiguous (own + other) fails closed.
  build_adoption_proof() emits the required proof block.
- gitea_mcp_server.py — gitea_lock_issue consults the adoption assessment in
  place of the blanket branch-match block; on adoption the result carries an
  `adoption` proof (issue, branch, head commit, reason, no-existing-PR proof,
  no-competing-live-lock proof, lock file path/status). Open-PR and active-
  lease checks already run first, so adoption implies neither exists. Adds
  _branch_entry_commit_sha helper.
- Tests: tests/test_issue_lock_adoption.py (8 cases) + 3 MCP-level cases in
  test_mcp_server.py (adopt own branch, open PR blocks adoption, create_pr
  proceeds after adopted lock). Existing competing-branch block preserved
  (still raises "already has matching branch").

Safety:
- Different same-issue branch, competing branch, existing open PR, ambiguous
  ownership all remain fail-closed. #400 duplicate-work protection intact.
- #420's feature branch was only read (git ls-remote); not modified/deleted.

Validation:
  venv/bin/python -m pytest tests/ -q  ->  1611 passed, 6 skipped

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 17:21:53 -04:00
co-authored by Claude Opus 4.8
parent cc4741a4ce
commit 9e19655279
4 changed files with 418 additions and 0 deletions
+99
View File
@@ -3156,6 +3156,105 @@ class TestIssueLocking(unittest.TestCase):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("remote branch(es) already match issue pattern", str(ctx.exception))
@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_adopts_own_existing_branch(self, _auth, mock_api, _git_state):
# #442: the issue's own already-pushed branch is adoptable for recovery.
sha = "934688a71dd19d9b46369e73fb3ac356ee0cc211"
def _api(url, *a, **k):
if "/pulls" in url:
return []
if "/branches" in url:
return [{"name": "feat/issue-196-mutations", "commit": {"id": sha}}]
return []
mock_api.side_effect = _api
res = gitea_lock_issue(
issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs"
)
self.assertTrue(res["success"])
self.assertIn("adoption", res)
proof = res["adoption"]
self.assertEqual(proof["issue_number"], 196)
self.assertEqual(proof["branch_name"], "feat/issue-196-mutations")
self.assertEqual(proof["branch_head_commit"], sha)
self.assertTrue(proof["no_existing_pr_proof"])
self.assertTrue(proof["no_competing_live_lock_proof"])
self.assertEqual(proof["lock_file_path"], ISSUE_LOCK_FILE)
self.assertIn("adopt", proof["adoption_reason"].lower())
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
@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_open_pr_blocks_adoption(self, _auth, mock_api, _git_state):
# Even with the exact own branch present, an open PR blocks adoption.
def _api(url, *a, **k):
if "/pulls" in url:
return [{
"number": 200,
"head": {"ref": "feat/issue-196-mutations"},
"title": "WIP",
"body": "",
}]
if "/branches" in url:
return [{"name": "feat/issue-196-mutations", "commit": {"id": "abc"}}]
return []
mock_api.side_effect = _api
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(
issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs"
)
self.assertIn("already tied to an open PR", str(ctx.exception))
self.assertFalse(os.path.exists(ISSUE_LOCK_FILE))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_proceeds_after_adopted_lock(
self, _auth, mock_api, mock_req, _git_state, _role
):
# #442 recovery goal: after adopting the own branch, create_pr proceeds.
sha = "934688a71dd19d9b46369e73fb3ac356ee0cc211"
def _api(url, *a, **k):
if "/pulls" in url:
return []
if "/branches" in url:
return [{"name": "feat/issue-196-mutations", "commit": {"id": sha}}]
return []
mock_api.side_effect = _api
lock_res = gitea_lock_issue(
issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs"
)
self.assertIn("adoption", lock_res)
mock_req.return_value = {"number": 7, "html_url": "https://gitea.prgs.cc/pulls/7"}
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
pr = gitea_create_pr(
title="feat: parity gate Closes #196",
head="feat/issue-196-mutations",
base="master",
remote="prgs",
)
self.assertEqual(pr["number"], 7)
def test_lock_issue_blocks_active_same_operation_lease(self):
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump({