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:
@@ -543,6 +543,7 @@ import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
import reconciler_profile # noqa: E402
|
||||
@@ -795,6 +796,19 @@ def _enforce_locked_issue_duplicate_recheck(
|
||||
return None
|
||||
|
||||
|
||||
def _branch_entry_commit_sha(branch: dict | str) -> str | None:
|
||||
"""Best-effort head SHA for a Gitea branch entry (None when absent)."""
|
||||
if not isinstance(branch, dict):
|
||||
return None
|
||||
commit = branch.get("commit")
|
||||
if isinstance(commit, dict):
|
||||
sha = commit.get("id") or commit.get("sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
sha = branch.get("commit_sha")
|
||||
return str(sha) if sha else None
|
||||
|
||||
|
||||
def _reveal_endpoints() -> bool:
|
||||
"""Admin/debug opt-in (#120): include endpoint URLs and token source
|
||||
names in tool output. Off by default so normal LLM-facing responses
|
||||
@@ -1283,6 +1297,30 @@ def gitea_lock_issue(
|
||||
f"duplicate work gate blocked issue #{issue_number} (fail closed)"
|
||||
]))
|
||||
|
||||
branch_url = f"{repo_api_url(h, o, r)}/branches"
|
||||
try:
|
||||
branches = api_get_all(branch_url, auth)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
|
||||
existing_branch_entries = [
|
||||
{
|
||||
"name": _branch_entry_name(branch),
|
||||
"commit_sha": _branch_entry_commit_sha(branch),
|
||||
}
|
||||
for branch in branches
|
||||
]
|
||||
adoption = issue_lock_adoption.assess_own_branch_adoption(
|
||||
issue_number=issue_number,
|
||||
requested_branch=branch_name,
|
||||
existing_branches=existing_branch_entries,
|
||||
)
|
||||
if adoption["block"]:
|
||||
competing = ", ".join(adoption["competing_branches"])
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} already has matching branch '{competing}' "
|
||||
"that is not the requested branch (fail closed)"
|
||||
)
|
||||
|
||||
work_lease = _build_author_issue_work_lease(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
@@ -1323,6 +1361,21 @@ def gitea_lock_issue(
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
}
|
||||
if adoption["adopt"]:
|
||||
# #442: recovery lock over the issue's own already-pushed branch.
|
||||
result["adoption"] = issue_lock_adoption.build_adoption_proof(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
assessment=adoption,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path=ISSUE_LOCK_FILE,
|
||||
lock_file_status="written",
|
||||
)
|
||||
result["message"] = (
|
||||
f"Adopted existing branch '{branch_name}' and locked issue "
|
||||
f"#{issue_number} for recovery (fail-closed check complete)."
|
||||
)
|
||||
if agent_artifacts:
|
||||
result["warnings"] = [
|
||||
"Agent temp artifacts at repo root (delete before implementation): "
|
||||
|
||||
Reference in New Issue
Block a user