fix: resolve conflicts for PR #416
Merge current prgs/master into feat/issue-399-conflict-fix-leases. Preserve reviewer PR lease gate (#407) before conflict-fix stale-head gate (#399); document both as §26B and §26C. Align MCP and validator tests with mandatory expected_head_sha and stale-head proof fields.
This commit is contained in:
+531
-45
@@ -440,10 +440,48 @@ 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)"
|
||||
)
|
||||
|
||||
if worktree_path:
|
||||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
|
||||
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),
|
||||
)
|
||||
real_workspace = os.path.realpath(workspace)
|
||||
real_root = os.path.realpath(PROJECT_ROOT)
|
||||
|
||||
if real_workspace != real_root:
|
||||
if not _preflight_in_test_mode():
|
||||
if not os.path.exists(real_workspace):
|
||||
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)"
|
||||
)
|
||||
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)))
|
||||
if dirty_files:
|
||||
details = _preflight_workspace_details(worktree_path, dirty_files)
|
||||
details = _preflight_workspace_details(workspace, dirty_files)
|
||||
raise RuntimeError(
|
||||
"Pre-flight order violation: Active task workspace has tracked "
|
||||
"file edits before mutation (fail closed). "
|
||||
@@ -500,9 +538,12 @@ import task_capability_map # noqa: E402
|
||||
import review_proofs # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
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 reviewer_pr_lease # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
import reconciler_profile # noqa: E402
|
||||
import reconciliation_workflow # noqa: E402
|
||||
@@ -548,7 +589,7 @@ def _load_existing_issue_lock() -> dict | None:
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
return None
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
||||
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:
|
||||
@@ -642,6 +683,119 @@ def _branch_entry_name(branch: dict | str) -> str:
|
||||
return str(branch.get("name") or branch.get("ref") or "")
|
||||
|
||||
|
||||
def _live_fetch_issue_duplicate_context(
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
) -> tuple[list[dict], list[str], dict]:
|
||||
"""Live open PRs, remote branch names, and claim state for one issue."""
|
||||
base = repo_api_url(h, o, r)
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
branches = api_get_all(f"{base}/branches", auth)
|
||||
branch_names = [_branch_entry_name(b) for b in branches]
|
||||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||||
comments = api_request(
|
||||
"GET", f"{base}/issues/{issue_number}/comments", auth
|
||||
) or []
|
||||
claim_entry = issue_claim_heartbeat.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=comments,
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
)
|
||||
return open_prs, branch_names, claim_entry
|
||||
|
||||
|
||||
# Injectable duplicate-work context fetcher (#400). Production uses the live
|
||||
# Gitea API path above; unit tests patch this symbol instead of hitting the
|
||||
# network.
|
||||
issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context
|
||||
|
||||
|
||||
def _collect_issue_duplicate_context(
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
) -> tuple[list[dict], list[str], dict]:
|
||||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||||
|
||||
|
||||
def _assess_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
locked_branch: str | None = None,
|
||||
phase: str,
|
||||
) -> dict:
|
||||
open_prs, branch_names, claim_entry = _collect_issue_duplicate_context(
|
||||
h, o, r, auth, issue_number
|
||||
)
|
||||
return issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||
issue_number,
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
claim_entry=claim_entry,
|
||||
locked_branch=locked_branch,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_gate_block_response(gate: dict, **extra) -> dict:
|
||||
out = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": list(gate.get("reasons") or []),
|
||||
"duplicate_gate": gate,
|
||||
"safe_next_action": gate.get("safe_next_action"),
|
||||
}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def _enforce_locked_issue_duplicate_recheck(
|
||||
remote: str,
|
||||
phase: str,
|
||||
*,
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Re-check duplicate-work gates for the locked issue (#400)."""
|
||||
lock_data = _load_existing_issue_lock()
|
||||
if not lock_data:
|
||||
return None
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
locked_branch = lock_data.get("branch_name")
|
||||
if not issue_number:
|
||||
return None
|
||||
h, o, r = _resolve(
|
||||
remote or lock_data.get("remote") or "dadeschools",
|
||||
host or lock_data.get("host"),
|
||||
org or lock_data.get("org"),
|
||||
repo or lock_data.get("repo"),
|
||||
)
|
||||
auth = _auth(h)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=locked_branch,
|
||||
phase=phase,
|
||||
)
|
||||
if gate.get("block"):
|
||||
return gate
|
||||
return 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
|
||||
@@ -973,6 +1127,7 @@ def gitea_create_issue(
|
||||
repo: str | None = None,
|
||||
allow_duplicate_override: bool = False,
|
||||
split_from_issue: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new issue on a Gitea repository.
|
||||
|
||||
@@ -985,6 +1140,7 @@ def gitea_create_issue(
|
||||
repo: Override the repository name.
|
||||
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||
split_from_issue: Existing duplicate issue number when overriding.
|
||||
worktree_path: Optional path to verify branches-only guard.
|
||||
|
||||
Returns:
|
||||
dict with 'number' of the created issue ('url' only with the reveal opt-in).
|
||||
@@ -1011,7 +1167,7 @@ def gitea_create_issue(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote)
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
base = repo_api_url(h, o, r)
|
||||
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||
closed_issues = api_get_all(
|
||||
@@ -1112,48 +1268,21 @@ def gitea_lock_issue(
|
||||
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)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
||||
|
||||
try:
|
||||
prs = api_get_all(url, auth)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
|
||||
|
||||
for pr in prs:
|
||||
pr_head = pr.get("head", {}).get("ref", "")
|
||||
pr_title = pr.get("title", "")
|
||||
pr_body = pr.get("body", "")
|
||||
|
||||
if expected_pattern in pr_head:
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
|
||||
)
|
||||
|
||||
patterns = [
|
||||
f"closes #{issue_number}",
|
||||
f"fixes #{issue_number}",
|
||||
]
|
||||
text_to_check = f"{pr_title} {pr_body}".lower()
|
||||
if any(p in text_to_check for p in patterns):
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (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}")
|
||||
for branch in branches:
|
||||
name = _branch_entry_name(branch)
|
||||
if expected_pattern in name:
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} already has matching branch '{name}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
duplicate_gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=branch_name,
|
||||
phase=issue_work_duplicate_gate.PHASE_LOCK,
|
||||
)
|
||||
if duplicate_gate.get("block"):
|
||||
raise ValueError("; ".join(duplicate_gate.get("reasons") or [
|
||||
f"duplicate work gate blocked issue #{issue_number} (fail closed)"
|
||||
]))
|
||||
|
||||
work_lease = _build_author_issue_work_lease(
|
||||
issue_number=issue_number,
|
||||
@@ -1169,6 +1298,10 @@ def gitea_lock_issue(
|
||||
"repo": r,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1199,6 +1332,39 @@ def gitea_lock_issue(
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_work_issue_duplicate(
|
||||
issue_number: int,
|
||||
branch_name: str | None = None,
|
||||
phase: str = issue_work_duplicate_gate.PHASE_LOCK,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only duplicate-work gate for author sessions before mutations (#400)."""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=branch_name,
|
||||
phase=phase,
|
||||
)
|
||||
return {"success": not gate.get("block"), **gate}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_create_pr(
|
||||
title: str,
|
||||
@@ -1259,6 +1425,14 @@ def gitea_create_pr(
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
|
||||
|
||||
lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
|
||||
lock_data
|
||||
)
|
||||
if lock_provenance_check["block"]:
|
||||
raise RuntimeError(
|
||||
issue_lock_provenance.format_lock_provenance_error(lock_provenance_check)
|
||||
)
|
||||
|
||||
locked_issue = lock_data.get("issue_number")
|
||||
locked_branch = lock_data.get("branch_name")
|
||||
locked_worktree = lock_data.get("worktree_path")
|
||||
@@ -1290,6 +1464,21 @@ def gitea_create_pr(
|
||||
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
|
||||
)
|
||||
|
||||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||||
remote,
|
||||
issue_work_duplicate_gate.PHASE_CREATE_PR,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if duplicate_block:
|
||||
return _duplicate_gate_block_response(
|
||||
duplicate_block,
|
||||
number=None,
|
||||
issue_number=locked_issue,
|
||||
branch_name=locked_branch,
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||
@@ -1829,6 +2018,7 @@ def init_review_decision_lock(remote: str | None, task: str | None):
|
||||
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
or profile_name
|
||||
)
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
_save_review_decision_lock({
|
||||
"task": task,
|
||||
"remote": remote,
|
||||
@@ -2315,6 +2505,20 @@ def _evaluate_pr_review_submission(
|
||||
result["permission_report"] = elig["permission_report"]
|
||||
return result
|
||||
|
||||
if live:
|
||||
reasons.extend(_reviewer_pr_lease_gate(
|
||||
pr_number=pr_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
mutation=action,
|
||||
live_head_sha=result.get("head_sha"),
|
||||
pinned_head_sha=expected_head_sha,
|
||||
))
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
auth_user = result["authenticated_user"]
|
||||
pr_author = result["pr_author"]
|
||||
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
|
||||
@@ -2991,6 +3195,20 @@ def gitea_commit_files(
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||||
remote,
|
||||
issue_work_duplicate_gate.PHASE_COMMIT,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if duplicate_block:
|
||||
return _duplicate_gate_block_response(
|
||||
duplicate_block,
|
||||
commit="",
|
||||
branch="",
|
||||
)
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||
|
||||
@@ -3153,6 +3371,19 @@ def gitea_merge_pr(
|
||||
result["permission_report"] = elig["permission_report"]
|
||||
return result
|
||||
|
||||
reasons.extend(_reviewer_pr_lease_gate(
|
||||
pr_number=pr_number,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
mutation="merge",
|
||||
live_head_sha=result.get("head_sha"),
|
||||
pinned_head_sha=expected_head_sha,
|
||||
))
|
||||
if reasons:
|
||||
return result
|
||||
|
||||
# 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():
|
||||
@@ -4471,6 +4702,261 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
|
||||
return blocked
|
||||
|
||||
|
||||
def _fetch_pr_comments(
|
||||
pr_number: int,
|
||||
*,
|
||||
remote: str,
|
||||
host: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
) -> list[dict]:
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
return api_request("GET", api, auth) or []
|
||||
|
||||
|
||||
def _reviewer_pr_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
remote: str,
|
||||
host: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
mutation: str,
|
||||
live_head_sha: str | None,
|
||||
pinned_head_sha: str | None,
|
||||
) -> list[str]:
|
||||
"""Return block reasons when the session lacks an owned PR reviewer lease."""
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
session_id = (session or {}).get("session_id")
|
||||
identity = _authenticated_username(remote) or ""
|
||||
try:
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
except Exception as exc:
|
||||
return [f"cannot fetch PR comments for lease gate: {_redact(str(exc))}"]
|
||||
assessment = reviewer_pr_lease.assess_mutation_lease_gate(
|
||||
pr_number=pr_number,
|
||||
comments=comments,
|
||||
reviewer_identity=identity,
|
||||
session_id=session_id,
|
||||
mutation=mutation,
|
||||
live_head_sha=live_head_sha,
|
||||
pinned_head_sha=pinned_head_sha,
|
||||
)
|
||||
return list(assessment.get("reasons") or []) if assessment.get("block") else []
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_acquire_reviewer_pr_lease(
|
||||
pr_number: int,
|
||||
worktree: str,
|
||||
candidate_head: str | None = None,
|
||||
target_branch: str = "master",
|
||||
target_branch_sha: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
session_id: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": comment_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||
}
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
profile = get_profile()
|
||||
identity = _authenticated_username(remote) or profile.get("username") or ""
|
||||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||||
repo_label = f"{o}/{r}"
|
||||
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
assessment = reviewer_pr_lease.assess_acquire_lease(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
reviewer_identity=identity,
|
||||
profile=profile.get("profile_name") or "unknown",
|
||||
session_id=sid,
|
||||
repo=repo_label,
|
||||
issue_number=issue_number,
|
||||
worktree=worktree,
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
)
|
||||
if not assessment.get("acquire_allowed"):
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
"existing_lease": assessment.get("existing_lease"),
|
||||
}
|
||||
|
||||
body = assessment["lease_body"]
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "acquire_reviewer_pr_lease"},
|
||||
):
|
||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||
|
||||
session_lease = reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": pr_number,
|
||||
"issue_number": issue_number,
|
||||
"session_id": sid,
|
||||
"reviewer_identity": identity,
|
||||
"profile": profile.get("profile_name"),
|
||||
"worktree": worktree,
|
||||
"phase": "claimed",
|
||||
"candidate_head": candidate_head,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"repo": repo_label,
|
||||
"comment_id": posted.get("id"),
|
||||
})
|
||||
return {
|
||||
"success": True,
|
||||
"acquired": True,
|
||||
"pr_number": pr_number,
|
||||
"session_id": sid,
|
||||
"comment_id": posted.get("id"),
|
||||
"session_lease": session_lease,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_heartbeat_reviewer_pr_lease(
|
||||
pr_number: int,
|
||||
phase: str,
|
||||
worktree: str | None = None,
|
||||
candidate_head: str | None = None,
|
||||
target_branch_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Post a reviewer lease heartbeat / phase update on the PR thread (#407)."""
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
return {
|
||||
"success": False,
|
||||
"posted": False,
|
||||
"reasons": comment_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||
}
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
if not session or session.get("pr_number") != pr_number:
|
||||
return {
|
||||
"success": False,
|
||||
"posted": False,
|
||||
"reasons": [
|
||||
f"no in-session lease for PR #{pr_number}; acquire first "
|
||||
"(fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
body = reviewer_pr_lease.format_lease_body(
|
||||
repo=f"{o}/{r}",
|
||||
pr_number=pr_number,
|
||||
issue_number=session.get("issue_number"),
|
||||
reviewer_identity=session.get("reviewer_identity") or "",
|
||||
profile=session.get("profile") or "unknown",
|
||||
session_id=session.get("session_id") or reviewer_pr_lease.new_session_id(),
|
||||
worktree=worktree or session.get("worktree") or "",
|
||||
phase=phase,
|
||||
candidate_head=candidate_head or session.get("candidate_head"),
|
||||
target_branch=session.get("target_branch") or "master",
|
||||
target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
|
||||
)
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "heartbeat_reviewer_pr_lease", "phase": phase},
|
||||
):
|
||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||
|
||||
updated = reviewer_pr_lease.record_session_lease({
|
||||
**session,
|
||||
"phase": phase,
|
||||
"worktree": worktree or session.get("worktree"),
|
||||
"candidate_head": candidate_head or session.get("candidate_head"),
|
||||
"target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
|
||||
"last_comment_id": posted.get("id"),
|
||||
})
|
||||
return {
|
||||
"success": True,
|
||||
"posted": True,
|
||||
"pr_number": pr_number,
|
||||
"phase": phase,
|
||||
"comment_id": posted.get("id"),
|
||||
"session_lease": updated,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_reviewer_pr_lease(
|
||||
pr_number: int,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: assess active reviewer lease state for a PR (#407)."""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
active = reviewer_pr_lease.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_number)
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"active_lease": active,
|
||||
"session_lease": reviewer_pr_lease.get_session_lease(),
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_list_issue_comments(
|
||||
issue_number: int,
|
||||
|
||||
Reference in New Issue
Block a user