Merge pull request 'feat(mcp): bind issue lock to author scratch worktree (closes #249)' (#250) from feat/issue-249-issue-lock-scratch-worktree into master

This commit was merged in pull request #250.
This commit is contained in:
2026-07-06 11:24:37 -05:00
5 changed files with 421 additions and 5 deletions
+33 -1
View File
@@ -260,6 +260,7 @@ import role_session_router # noqa: E402
import role_namespace_gate # noqa: E402
import task_capability_map # noqa: E402
import review_proofs # noqa: E402
import issue_lock_worktree # noqa: E402
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
@@ -683,6 +684,7 @@ def gitea_lock_issue(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
@@ -693,6 +695,8 @@ def gitea_lock_issue(
host: Override Gitea host.
org: Override Org.
repo: Override Repo.
worktree_path: Author scratch-clone path to validate (defaults to
GITEA_AUTHOR_WORKTREE or the MCP server project root).
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
@@ -701,6 +705,20 @@ def gitea_lock_issue(
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
)
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, PROJECT_ROOT
)
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
current_branch=git_state.get("current_branch"),
porcelain_status=git_state.get("porcelain_status") or "",
)
if lock_assessment["block"]:
raise RuntimeError(
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
)
# 2. Check if the issue already has an open PR (reuse protection)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -737,6 +755,7 @@ def gitea_lock_issue(
"remote": remote,
"org": o,
"repo": r,
"worktree_path": resolved_worktree,
}
try:
@@ -747,9 +766,13 @@ def gitea_lock_issue(
return {
"success": True,
"message": f"Successfully locked issue #{issue_number} to branch '{branch_name}' (fail-closed check complete).",
"message": (
f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
f"from worktree '{resolved_worktree}' (fail-closed check complete)."
),
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": resolved_worktree,
}
@@ -763,6 +786,7 @@ def gitea_create_pr(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Create a pull request on a Gitea repository.
@@ -775,6 +799,7 @@ def gitea_create_pr(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Author worktree path; must match the path stored at lock time.
Returns:
dict with 'number' of the created PR ('url' only with the reveal opt-in).
@@ -813,6 +838,13 @@ def gitea_create_pr(
locked_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name")
locked_worktree = lock_data.get("worktree_path")
worktree_check = issue_lock_worktree.verify_pr_worktree_matches_lock(
locked_worktree, worktree_path, PROJECT_ROOT
)
if worktree_check["block"]:
raise ValueError(worktree_check["reasons"][0])
if head != locked_branch:
raise ValueError(