fix: resolve conflicts for PR #392
Merge prgs/master into feat/issue-389-review-workflow-load-proof and preserve workflow-load gates while adopting master test harness patterns (_seed_ready_review_decision, reviewer leases, expected_head_sha).
This commit is contained in:
+438
-87
@@ -190,14 +190,22 @@ def _ensure_process_start_porcelain() -> str:
|
||||
|
||||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
"""Resolve the workspace root inspected by pre-flight guards."""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = PROJECT_ROOT
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
return author_mutation_worktree.resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
PROJECT_ROOT,
|
||||
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
|
||||
"""Canonical workspace + repository root for runtime_context and guards (#460)."""
|
||||
return author_mutation_worktree.resolve_author_mutation_context(
|
||||
worktree_path,
|
||||
PROJECT_ROOT,
|
||||
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
|
||||
)
|
||||
|
||||
|
||||
def _get_git_root(path: str) -> str | None:
|
||||
@@ -267,21 +275,31 @@ def _format_preflight_files(files: list[str]) -> str:
|
||||
|
||||
|
||||
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
|
||||
workspace = _resolve_preflight_workspace_path(worktree_path)
|
||||
ctx = _resolve_author_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
inspected_root = _get_git_root(workspace)
|
||||
control_root = os.path.realpath(PROJECT_ROOT)
|
||||
process_root = ctx["process_project_root"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
active_root = os.path.realpath(inspected_root or workspace)
|
||||
if active_root == control_root:
|
||||
if active_root == canonical_root:
|
||||
dirty_scope = "control checkout"
|
||||
else:
|
||||
dirty_scope = "active task workspace"
|
||||
return {
|
||||
"mcp_server_process_root": control_root,
|
||||
details = {
|
||||
"mcp_server_process_root": process_root,
|
||||
"canonical_repository_root": canonical_root,
|
||||
"active_task_workspace_root": active_root,
|
||||
"inspected_git_root": inspected_root,
|
||||
"dirty_files": list(dirty_files),
|
||||
"dirty_scope": dirty_scope,
|
||||
"workspace_roots_aligned": ctx["roots_aligned"],
|
||||
}
|
||||
if not ctx["roots_aligned"]:
|
||||
details["workspace_root_mismatch"] = (
|
||||
"runtime_context and mutation guard use canonical repository root "
|
||||
f"'{canonical_root}' instead of MCP process root '{process_root}'"
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _format_preflight_workspace_details(details: dict) -> str:
|
||||
@@ -402,16 +420,12 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
||||
"""#274: author mutations must run from a branches/ session worktree."""
|
||||
if _preflight_resolved_role == "reviewer":
|
||||
return
|
||||
workspace = author_mutation_worktree.resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
PROJECT_ROOT,
|
||||
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
|
||||
)
|
||||
ctx = _resolve_author_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||
assessment = author_mutation_worktree.assess_author_mutation_worktree(
|
||||
workspace_path=workspace,
|
||||
project_root=PROJECT_ROOT,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=git_state.get("current_branch"),
|
||||
)
|
||||
if assessment["block"]:
|
||||
@@ -440,43 +454,23 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
|
||||
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
|
||||
)
|
||||
|
||||
workspace = author_mutation_worktree.resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
PROJECT_ROOT,
|
||||
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
|
||||
)
|
||||
ctx = _resolve_author_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
process_root = ctx["process_project_root"]
|
||||
real_workspace = os.path.realpath(workspace)
|
||||
real_root = os.path.realpath(PROJECT_ROOT)
|
||||
|
||||
if real_workspace != real_root:
|
||||
if real_workspace != process_root:
|
||||
if not _preflight_in_test_mode():
|
||||
if not os.path.exists(real_workspace):
|
||||
membership = author_mutation_worktree.assess_workspace_repo_membership(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=canonical_root,
|
||||
)
|
||||
if membership["block"]:
|
||||
raise RuntimeError(
|
||||
f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)"
|
||||
)
|
||||
if not os.path.isdir(real_workspace):
|
||||
raise RuntimeError(
|
||||
f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)"
|
||||
)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", real_workspace, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common_dir = os.path.realpath(res.stdout.strip())
|
||||
expected_dir = os.path.realpath(os.path.join(real_root, ".git"))
|
||||
if common_dir != expected_dir:
|
||||
raise RuntimeError(
|
||||
f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)"
|
||||
author_mutation_worktree.format_workspace_repo_membership_error(
|
||||
membership
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, RuntimeError):
|
||||
raise e
|
||||
raise RuntimeError(
|
||||
f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)"
|
||||
)
|
||||
|
||||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
|
||||
@@ -540,6 +534,9 @@ import review_workflow_load # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
@@ -549,11 +546,13 @@ import merged_cleanup_reconcile # noqa: E402
|
||||
import reconciler_profile # noqa: E402
|
||||
import reconciliation_workflow # noqa: E402
|
||||
import review_merge_state_machine # noqa: E402
|
||||
import pr_work_lease # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
|
||||
|
||||
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
|
||||
# consumed by gitea_create_pr and scripts/worktree-start.
|
||||
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
|
||||
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
|
||||
# Legacy global path retained only for test/doc references — do not seed manually.
|
||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
@@ -585,15 +584,59 @@ def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _load_existing_issue_lock() -> dict | None:
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
return None
|
||||
def _load_existing_issue_lock(
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
) -> dict | None:
|
||||
if remote and org and repo and issue_number is not None:
|
||||
return issue_lock_store.load_issue_lock(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
)
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
|
||||
|
||||
def _resolve_issue_lock_for_pr(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
) -> dict:
|
||||
lock_data = issue_lock_store.read_session_issue_lock()
|
||||
if not lock_data:
|
||||
lock_data = issue_lock_store.find_lock_for_branch(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
branch_name=head,
|
||||
)
|
||||
if not lock_data:
|
||||
raise RuntimeError(
|
||||
"Issue lock is missing (fail closed). Call gitea_lock_issue first."
|
||||
)
|
||||
return lock_data
|
||||
|
||||
|
||||
def _save_issue_lock(data: dict) -> str:
|
||||
existing = issue_lock_store.load_issue_lock(
|
||||
remote=str(data.get("remote") or ""),
|
||||
org=str(data.get("org") or ""),
|
||||
repo=str(data.get("repo") or ""),
|
||||
issue_number=int(data.get("issue_number") or 0),
|
||||
)
|
||||
overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return issue_lock_store.bind_session_lock(data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not write issue lock file: {e}") from e
|
||||
|
||||
|
||||
def _work_lease_claimant(host: str | None) -> dict:
|
||||
@@ -796,6 +839,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
|
||||
@@ -1243,8 +1299,9 @@ def gitea_lock_issue(
|
||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||
worktree_path, PROJECT_ROOT
|
||||
)
|
||||
active_lease_block = _active_work_lease_block(
|
||||
_load_existing_issue_lock(),
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
@@ -1268,7 +1325,6 @@ def gitea_lock_issue(
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
duplicate_gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
@@ -1284,6 +1340,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,
|
||||
@@ -1304,11 +1384,20 @@ def gitea_lock_issue(
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not write issue lock file: {e}")
|
||||
lock_file_path = _save_issue_lock(data)
|
||||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
|
||||
freshness = issue_lock_store.assess_lock_freshness(lock_record)
|
||||
competing = [
|
||||
entry
|
||||
for entry in issue_lock_store.list_live_locks()
|
||||
if entry.get("issue_number") != issue_number
|
||||
]
|
||||
lock_proof = issue_lock_store.format_lock_proof(
|
||||
lock_record,
|
||||
freshness=freshness,
|
||||
competing_live_locks=competing,
|
||||
released=False,
|
||||
)
|
||||
|
||||
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
|
||||
git_state.get("porcelain_status") or ""
|
||||
@@ -1323,7 +1412,24 @@ def gitea_lock_issue(
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
"lock_file_path": lock_file_path,
|
||||
"lock_freshness": freshness,
|
||||
"lock_proof": lock_proof,
|
||||
}
|
||||
if adoption["adopt"]:
|
||||
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=lock_file_path,
|
||||
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): "
|
||||
@@ -1415,15 +1521,8 @@ def gitea_create_pr(
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
|
||||
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
|
||||
lock_data = json.load(f)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
|
||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
|
||||
|
||||
lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
|
||||
lock_data
|
||||
@@ -1448,6 +1547,15 @@ def gitea_create_pr(
|
||||
f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
|
||||
)
|
||||
|
||||
ownership = issue_lock_store.verify_lock_for_mutation(
|
||||
lock_data,
|
||||
issue_number=locked_issue,
|
||||
branch_name=head,
|
||||
worktree_path=worktree_path,
|
||||
)
|
||||
if ownership["block"]:
|
||||
raise ValueError(ownership["reasons"][0])
|
||||
|
||||
# Check for forbidden terms anywhere in title/body
|
||||
forbidden_terms = ["equivalent", "related", "same as"]
|
||||
text_to_check = f"{title} {body}".lower()
|
||||
@@ -2376,6 +2484,10 @@ def gitea_get_pr_review_feedback(
|
||||
e for e in latest_by_reviewer.values()
|
||||
if e["verdict"] == "APPROVED" and not e["dismissed"]
|
||||
]
|
||||
approval_head = merge_approval_gate.assess_merge_approval_head(
|
||||
current_head_sha=current_head,
|
||||
latest_by_reviewer=latest_by_reviewer,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
@@ -2386,6 +2498,9 @@ def gitea_get_pr_review_feedback(
|
||||
login: e["verdict"] for login, e in latest_by_reviewer.items()},
|
||||
"has_blocking_change_requests": bool(blocking),
|
||||
"approval_visible": bool(approvals),
|
||||
"approval_at_current_head": approval_head["approval_at_current_head"],
|
||||
"latest_approved_head_sha": approval_head["latest_approved_head_sha"],
|
||||
"stale_approval_block_reason": approval_head["stale_approval_block_reason"],
|
||||
"latest_reviewed_head_sha": latest_reviewed_head,
|
||||
"review_feedback_stale": bool(
|
||||
latest_reviewed_head and current_head
|
||||
@@ -2397,6 +2512,50 @@ def gitea_get_pr_review_feedback(
|
||||
}
|
||||
|
||||
|
||||
def _list_pr_lease_comments(
|
||||
pr_number: int,
|
||||
*,
|
||||
remote: str,
|
||||
host: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
limit: int = 100,
|
||||
) -> list[dict]:
|
||||
"""Fetch PR/issue thread comments used for reviewer/conflict-fix leases."""
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
comments = api_request("GET", api, auth) or []
|
||||
return list(comments[:limit])
|
||||
|
||||
|
||||
def _pr_work_lease_reviewer_block(
|
||||
*,
|
||||
pr_number: int,
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
mutation: str,
|
||||
remote: str,
|
||||
host: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
) -> dict:
|
||||
comments = _list_pr_lease_comments(
|
||||
pr_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
return pr_work_lease.assess_reviewer_mutation_blocked(
|
||||
pr_number=pr_number,
|
||||
comments=comments,
|
||||
reviewed_head_sha=reviewed_head_sha,
|
||||
live_head_sha=live_head_sha,
|
||||
mutation=mutation,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_pr_review_submission(
|
||||
pr_number: int,
|
||||
action: str,
|
||||
@@ -2500,6 +2659,11 @@ def _evaluate_pr_review_submission(
|
||||
lock = _load_review_decision_lock() or {}
|
||||
if live and lock.get("ready_expected_head_sha"):
|
||||
pinned_sha = lock.get("ready_expected_head_sha")
|
||||
if live and not pinned_sha:
|
||||
reasons.append(
|
||||
"reviewed head SHA required before live review mutation (fail closed, #399)"
|
||||
)
|
||||
return result
|
||||
if pinned_sha and actual_sha and pinned_sha != actual_sha:
|
||||
reasons.append(
|
||||
"expected head SHA does not match current PR head (fail closed)"
|
||||
@@ -2509,6 +2673,21 @@ def _evaluate_pr_review_submission(
|
||||
reasons.append("PR head SHA unavailable (fail closed)")
|
||||
return result
|
||||
|
||||
lease_block = _pr_work_lease_reviewer_block(
|
||||
pr_number=pr_number,
|
||||
reviewed_head_sha=pinned_sha,
|
||||
live_head_sha=actual_sha,
|
||||
mutation=action,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if lease_block.get("block"):
|
||||
reasons.extend(lease_block.get("reasons") or [])
|
||||
result["pr_work_lease"] = lease_block
|
||||
return result
|
||||
|
||||
result["would_perform"] = True
|
||||
if not live:
|
||||
reasons.append(
|
||||
@@ -2658,6 +2837,39 @@ def gitea_mark_final_review_decision(
|
||||
f"{sorted(_REVIEW_ACTIONS)}"
|
||||
],
|
||||
}
|
||||
if not (expected_head_sha or "").strip():
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": [
|
||||
"expected_head_sha required before marking final review "
|
||||
"decision (fail closed, #399)"
|
||||
],
|
||||
}
|
||||
elig = gitea_check_pr_eligibility(
|
||||
pr_number=pr_number,
|
||||
action="review",
|
||||
remote=remote,
|
||||
host=None,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
live_head = elig.get("head_sha")
|
||||
lease_block = _pr_work_lease_reviewer_block(
|
||||
pr_number=pr_number,
|
||||
reviewed_head_sha=expected_head_sha,
|
||||
live_head_sha=live_head,
|
||||
mutation="mark_ready",
|
||||
remote=remote,
|
||||
host=None,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if lease_block.get("block"):
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": lease_block.get("reasons") or [],
|
||||
"pr_work_lease": lease_block,
|
||||
}
|
||||
if action == "request_changes":
|
||||
# Duplicate request-changes suppression (#332): an unresolved
|
||||
# REQUEST_CHANGES at the current head must not be duplicated.
|
||||
@@ -2978,13 +3190,7 @@ def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[d
|
||||
processed_files = []
|
||||
source_proofs = []
|
||||
|
||||
lock_data = {}
|
||||
if os.path.exists(ISSUE_LOCK_FILE):
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
|
||||
lock_data = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
lock_data = issue_lock_store.read_session_issue_lock() or {}
|
||||
|
||||
locked_worktree = lock_data.get("worktree_path")
|
||||
if locked_worktree:
|
||||
@@ -3313,8 +3519,27 @@ def gitea_merge_pr(
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
|
||||
# Gate 4 — reviewed head SHA is mandatory and must match live PR head (#399).
|
||||
actual_sha = result["head_sha"]
|
||||
if not (expected_head_sha or "").strip():
|
||||
reasons.append(
|
||||
"expected_head_sha required before merge (fail closed, #399)"
|
||||
)
|
||||
return result
|
||||
lease_block = _pr_work_lease_reviewer_block(
|
||||
pr_number=pr_number,
|
||||
reviewed_head_sha=expected_head_sha,
|
||||
live_head_sha=actual_sha,
|
||||
mutation="merge",
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if lease_block.get("block"):
|
||||
reasons.extend(lease_block.get("reasons") or [])
|
||||
result["pr_work_lease"] = lease_block
|
||||
return result
|
||||
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
|
||||
reasons.append(
|
||||
"expected head SHA does not match current PR head (fail closed)"
|
||||
@@ -3369,6 +3594,9 @@ def gitea_merge_pr(
|
||||
result["permission_report"] = feedback["permission_report"]
|
||||
return result
|
||||
result["approval_visible"] = feedback.get("approval_visible")
|
||||
result["approval_at_current_head"] = feedback.get("approval_at_current_head")
|
||||
result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha")
|
||||
result["review_feedback_stale"] = feedback.get("review_feedback_stale")
|
||||
result["has_blocking_change_requests"] = feedback.get(
|
||||
"has_blocking_change_requests")
|
||||
if feedback.get("has_blocking_change_requests"):
|
||||
@@ -3382,6 +3610,16 @@ def gitea_merge_pr(
|
||||
"completed before merge (fail closed)"
|
||||
)
|
||||
return result
|
||||
if not feedback.get("approval_at_current_head"):
|
||||
reasons.append(
|
||||
feedback.get("stale_approval_block_reason")
|
||||
or (
|
||||
"approval does not apply to current PR head SHA "
|
||||
"(fail closed); required next action: re-review PR at "
|
||||
"current head before merge"
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# Gate 8 — in-process mutation authority (#199): the last check before
|
||||
# the merge mutation, using the identity the eligibility gate proved.
|
||||
@@ -6637,6 +6875,110 @@ def gitea_post_heartbeat(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_acquire_conflict_fix_lease(
|
||||
pr_number: int,
|
||||
branch: str,
|
||||
worktree_path: str,
|
||||
head_before: str,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Acquire a conflict-fix lease on a PR branch before pushing (#399)."""
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission("comment_issue"))
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
comments = _list_pr_lease_comments(
|
||||
pr_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
reviewer_lease = pr_work_lease.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_number)
|
||||
if reviewer_lease:
|
||||
return {
|
||||
"acquired": False,
|
||||
"reasons": [
|
||||
f"active reviewer lease on PR #{pr_number}; cannot acquire "
|
||||
"conflict-fix lease (fail closed)"
|
||||
],
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
}
|
||||
profile_name = get_profile().get("profile_name") or "unknown"
|
||||
body = pr_work_lease.format_conflict_fix_lease_body(
|
||||
pr_number=pr_number,
|
||||
branch=branch,
|
||||
worktree=worktree_path,
|
||||
profile=profile_name,
|
||||
head_before=head_before,
|
||||
reviewer_active=bool(reviewer_lease),
|
||||
)
|
||||
posted = _post_structured_issue_comment(
|
||||
issue_number=pr_number,
|
||||
body=body,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
audit_op="conflict_fix_lease_acquire",
|
||||
)
|
||||
return {
|
||||
"acquired": posted.get("success", False),
|
||||
"pr_number": pr_number,
|
||||
"branch": branch,
|
||||
"worktree_path": worktree_path,
|
||||
"head_before": head_before,
|
||||
"comment_id": posted.get("comment_id"),
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
"reasons": [] if posted.get("success") else ["lease comment post failed"],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_conflict_fix_push(
|
||||
pr_number: int,
|
||||
branch_head_before: str,
|
||||
branch_head_after: str,
|
||||
worktree_path: str,
|
||||
push_cwd: str,
|
||||
is_fast_forward: bool = True,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only pre-push gate for author conflict-fix sessions (#399)."""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"push_allowed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
comments = _list_pr_lease_comments(
|
||||
pr_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
return pr_work_lease.assess_conflict_fix_push(
|
||||
pr_number=pr_number,
|
||||
comments=comments,
|
||||
branch_head_before=branch_head_before,
|
||||
branch_head_after=branch_head_after,
|
||||
worktree_path=worktree_path,
|
||||
push_cwd=push_cwd,
|
||||
is_fast_forward=is_fast_forward,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_reconcile_issue_claims(
|
||||
state: str = "open",
|
||||
@@ -6682,6 +7024,15 @@ def gitea_reconcile_issue_claims(
|
||||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||||
reclaim_after_minutes=reclaim_after_minutes,
|
||||
)
|
||||
live_locks = issue_lock_store.list_live_locks()
|
||||
inventory["live_issue_locks"] = live_locks
|
||||
inventory["live_issue_lock_numbers"] = sorted(
|
||||
{
|
||||
int(entry["issue_number"])
|
||||
for entry in live_locks
|
||||
if entry.get("issue_number") is not None
|
||||
}
|
||||
)
|
||||
inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory)
|
||||
inventory["success"] = True
|
||||
inventory["performed"] = False
|
||||
|
||||
Reference in New Issue
Block a user