feat: add per-PR reviewer leases for parallel review (Closes #407)
Structured PR-thread leases with acquire/heartbeat MCP tools and mutation-time enforcement so reviewers cannot race the same PR queue. 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 author_mutation_worktree # noqa: E402
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import issue_work_duplicate_gate # noqa: E402
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
|
import reviewer_pr_lease # noqa: E402
|
||||||
import merged_cleanup_reconcile # noqa: E402
|
import merged_cleanup_reconcile # noqa: E402
|
||||||
import reconciler_profile # noqa: E402
|
import reconciler_profile # noqa: E402
|
||||||
import reconciliation_workflow # noqa: E402
|
import reconciliation_workflow # noqa: E402
|
||||||
@@ -2016,6 +2017,7 @@ def init_review_decision_lock(remote: str | None, task: str | None):
|
|||||||
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||||
or profile_name
|
or profile_name
|
||||||
)
|
)
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
_save_review_decision_lock({
|
_save_review_decision_lock({
|
||||||
"task": task,
|
"task": task,
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
@@ -2458,6 +2460,20 @@ def _evaluate_pr_review_submission(
|
|||||||
result["permission_report"] = elig["permission_report"]
|
result["permission_report"] = elig["permission_report"]
|
||||||
return result
|
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"]
|
auth_user = result["authenticated_user"]
|
||||||
pr_author = result["pr_author"]
|
pr_author = result["pr_author"]
|
||||||
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
|
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
|
||||||
@@ -3257,6 +3273,19 @@ def gitea_merge_pr(
|
|||||||
result["permission_report"] = elig["permission_report"]
|
result["permission_report"] = elig["permission_report"]
|
||||||
return result
|
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 — head SHA must match if the caller pinned a reviewed SHA.
|
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
|
||||||
actual_sha = result["head_sha"]
|
actual_sha = result["head_sha"]
|
||||||
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
|
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
|
||||||
@@ -4556,6 +4585,261 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
|
|||||||
return blocked
|
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()
|
@mcp.tool()
|
||||||
def gitea_list_issue_comments(
|
def gitea_list_issue_comments(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||||
|
|
||||||
|
_FIELD_RE = re.compile(
|
||||||
|
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
)
|
||||||
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||||
|
_ACTIVE_PHASES = frozenset({
|
||||||
|
"claimed",
|
||||||
|
"validating",
|
||||||
|
"approved",
|
||||||
|
"request-changes",
|
||||||
|
"merging",
|
||||||
|
})
|
||||||
|
|
||||||
|
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||||
|
STALE_WARNING_MINUTES = 30
|
||||||
|
RECLAIMABLE_MINUTES = 60
|
||||||
|
|
||||||
|
_SESSION_LEASE: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
return text if text and _FULL_SHA.match(text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pr_ref(value: str | None) -> int | None:
|
||||||
|
digits = re.sub(r"[^\d]", "", value or "")
|
||||||
|
return int(digits) if digits.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def new_session_id() -> str:
|
||||||
|
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_lease_body(
|
||||||
|
*,
|
||||||
|
repo: str,
|
||||||
|
pr_number: int,
|
||||||
|
issue_number: int | None,
|
||||||
|
reviewer_identity: str,
|
||||||
|
profile: str,
|
||||||
|
session_id: str,
|
||||||
|
worktree: str,
|
||||||
|
phase: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
target_branch: str,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
last_activity: datetime | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
blocker: str = "none",
|
||||||
|
) -> str:
|
||||||
|
now = last_activity or datetime.now(timezone.utc)
|
||||||
|
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
||||||
|
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
issue_text = f"#{issue_number}" if issue_number else "none"
|
||||||
|
lines = [
|
||||||
|
MARKER,
|
||||||
|
f"repo: {repo}",
|
||||||
|
f"pr: #{pr_number}",
|
||||||
|
f"issue: {issue_text}",
|
||||||
|
f"reviewer_identity: {reviewer_identity}",
|
||||||
|
f"profile: {profile}",
|
||||||
|
f"session_id: {session_id}",
|
||||||
|
f"worktree: {worktree}",
|
||||||
|
f"phase: {phase}",
|
||||||
|
f"candidate_head: {candidate_head or 'none'}",
|
||||||
|
f"target_branch: {target_branch}",
|
||||||
|
f"target_branch_sha: {target_branch_sha or 'none'}",
|
||||||
|
f"last_activity: {last_text}",
|
||||||
|
f"expires_at: {expires_text}",
|
||||||
|
f"blocker: {blocker}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lease_comment(body: str) -> dict[str, Any] | None:
|
||||||
|
text = body or ""
|
||||||
|
if MARKER not in text:
|
||||||
|
return None
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for match in _FIELD_RE.finditer(text):
|
||||||
|
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||||
|
if not fields:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"repo": fields.get("repo"),
|
||||||
|
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||||
|
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||||
|
"reviewer_identity": fields.get("reviewer_identity"),
|
||||||
|
"profile": fields.get("profile"),
|
||||||
|
"session_id": fields.get("session_id"),
|
||||||
|
"worktree": fields.get("worktree"),
|
||||||
|
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||||
|
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||||
|
"target_branch": fields.get("target_branch"),
|
||||||
|
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||||
|
"last_activity": fields.get("last_activity"),
|
||||||
|
"expires_at": fields.get("expires_at"),
|
||||||
|
"blocker": fields.get("blocker"),
|
||||||
|
"raw_fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
|
||||||
|
entries: list[dict] = []
|
||||||
|
for comment in comments or []:
|
||||||
|
parsed = parse_lease_comment(comment.get("body") or "")
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
if parsed.get("pr_number") not in (None, pr_number):
|
||||||
|
continue
|
||||||
|
entries.append({
|
||||||
|
**parsed,
|
||||||
|
"comment_id": comment.get("id"),
|
||||||
|
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||||
|
"created_at": comment.get("created_at"),
|
||||||
|
"updated_at": comment.get("updated_at"),
|
||||||
|
})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||||
|
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||||
|
return bool(expires_at and expires_at <= now)
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||||
|
last = _parse_timestamp(lease.get("last_activity"))
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
return (now - last).total_seconds() / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||||
|
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
phase = (lease.get("phase") or "").strip().lower()
|
||||||
|
if phase in _TERMINAL_PHASES:
|
||||||
|
return "terminal"
|
||||||
|
if _lease_expired(lease, now=now):
|
||||||
|
return "expired"
|
||||||
|
minutes = _minutes_since_activity(lease, now=now)
|
||||||
|
if minutes is None:
|
||||||
|
return "active"
|
||||||
|
if minutes >= RECLAIMABLE_MINUTES:
|
||||||
|
return "reclaimable"
|
||||||
|
if minutes >= STALE_WARNING_MINUTES:
|
||||||
|
return "stale_warning"
|
||||||
|
return "active"
|
||||||
|
|
||||||
|
|
||||||
|
def find_active_reviewer_lease(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
||||||
|
phase = (lease.get("phase") or "").strip().lower()
|
||||||
|
if phase in _TERMINAL_PHASES:
|
||||||
|
continue
|
||||||
|
if _lease_expired(lease, now=now):
|
||||||
|
continue
|
||||||
|
if phase in _ACTIVE_PHASES or phase:
|
||||||
|
lease = dict(lease)
|
||||||
|
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||||
|
return lease
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_acquire_lease(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
reviewer_identity: str,
|
||||||
|
profile: str,
|
||||||
|
session_id: str,
|
||||||
|
repo: str,
|
||||||
|
issue_number: int | None,
|
||||||
|
worktree: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
target_branch: str,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when another session holds an active lease."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
reasons: list[str] = []
|
||||||
|
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
if existing:
|
||||||
|
owner_session = (existing.get("session_id") or "").strip()
|
||||||
|
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
|
||||||
|
if owner_session and owner_session != session_id and freshness in {
|
||||||
|
"active", "stale_warning"
|
||||||
|
}:
|
||||||
|
reasons.append(
|
||||||
|
f"PR #{pr_number} already has active reviewer lease "
|
||||||
|
f"(session_id={owner_session}, phase={existing.get('phase')})"
|
||||||
|
)
|
||||||
|
elif owner_session and owner_session != session_id and freshness == "reclaimable":
|
||||||
|
reasons.append(
|
||||||
|
f"PR #{pr_number} lease is reclaimable but still held by "
|
||||||
|
f"session_id={owner_session}; explicit reclaim not implemented "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (reviewer_identity or "").strip():
|
||||||
|
reasons.append("reviewer identity required for lease acquisition")
|
||||||
|
if not (session_id or "").strip():
|
||||||
|
reasons.append("session_id required for lease acquisition")
|
||||||
|
if not (worktree or "").strip():
|
||||||
|
reasons.append("worktree path required for lease acquisition")
|
||||||
|
|
||||||
|
allowed = not reasons
|
||||||
|
body = None
|
||||||
|
if allowed:
|
||||||
|
body = format_lease_body(
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=issue_number,
|
||||||
|
reviewer_identity=reviewer_identity,
|
||||||
|
profile=profile,
|
||||||
|
session_id=session_id,
|
||||||
|
worktree=worktree,
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch=target_branch,
|
||||||
|
target_branch_sha=target_branch_sha,
|
||||||
|
last_activity=now,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"acquire_allowed": allowed,
|
||||||
|
"reasons": reasons,
|
||||||
|
"existing_lease": existing,
|
||||||
|
"lease_body": body,
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
global _SESSION_LEASE
|
||||||
|
_SESSION_LEASE = dict(lease)
|
||||||
|
return dict(_SESSION_LEASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_lease() -> None:
|
||||||
|
global _SESSION_LEASE
|
||||||
|
_SESSION_LEASE = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_lease() -> dict[str, Any] | None:
|
||||||
|
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_mutation_lease_gate(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
comments: list[dict],
|
||||||
|
reviewer_identity: str,
|
||||||
|
session_id: str | None,
|
||||||
|
mutation: str,
|
||||||
|
live_head_sha: str | None,
|
||||||
|
pinned_head_sha: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reviewer mutations require an owned, current PR lease."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
reasons: list[str] = []
|
||||||
|
session = get_session_lease()
|
||||||
|
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
reasons.append(
|
||||||
|
f"no in-session reviewer lease recorded; acquire via "
|
||||||
|
f"gitea_acquire_reviewer_pr_lease before {mutation}"
|
||||||
|
)
|
||||||
|
elif session.get("pr_number") != pr_number:
|
||||||
|
reasons.append(
|
||||||
|
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
||||||
|
)
|
||||||
|
elif (session.get("session_id") or "") != (session_id or session.get("session_id")):
|
||||||
|
reasons.append("session lease session_id mismatch (fail closed)")
|
||||||
|
|
||||||
|
if active:
|
||||||
|
owner = (active.get("session_id") or "").strip()
|
||||||
|
if owner and session_id and owner != session_id:
|
||||||
|
reasons.append(
|
||||||
|
f"active PR lease owned by session_id={owner}; current session "
|
||||||
|
f"cannot {mutation}"
|
||||||
|
)
|
||||||
|
pinned = _normalize_sha(pinned_head_sha)
|
||||||
|
live = _normalize_sha(live_head_sha)
|
||||||
|
lease_head = active.get("candidate_head")
|
||||||
|
if pinned and live and pinned != live:
|
||||||
|
reasons.append(
|
||||||
|
"PR head changed during lease; stop and re-validate before "
|
||||||
|
f"reviewer {mutation}"
|
||||||
|
)
|
||||||
|
if lease_head and live and lease_head != live:
|
||||||
|
reasons.append(
|
||||||
|
"live PR head differs from lease candidate_head; refresh lease "
|
||||||
|
f"before {mutation}"
|
||||||
|
)
|
||||||
|
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
|
||||||
|
if freshness in {"expired", "reclaimable"}:
|
||||||
|
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
|
||||||
|
else:
|
||||||
|
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||||
|
|
||||||
|
allowed = not reasons
|
||||||
|
return {
|
||||||
|
"mutation_allowed": allowed,
|
||||||
|
"block": not allowed,
|
||||||
|
"reasons": reasons,
|
||||||
|
"active_lease": active,
|
||||||
|
"session_lease": session,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_lease_inventory(
|
||||||
|
comments_by_pr: dict[int, list[dict]],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Summarize lease states across PR comment threads."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
active: list[dict] = []
|
||||||
|
stale: list[dict] = []
|
||||||
|
reclaimable: list[dict] = []
|
||||||
|
for pr_number, comments in (comments_by_pr or {}).items():
|
||||||
|
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
if not lease:
|
||||||
|
continue
|
||||||
|
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
|
||||||
|
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
|
||||||
|
if freshness == "stale_warning":
|
||||||
|
stale.append(entry)
|
||||||
|
elif freshness == "reclaimable":
|
||||||
|
reclaimable.append(entry)
|
||||||
|
else:
|
||||||
|
active.append(entry)
|
||||||
|
return {
|
||||||
|
"active_review_leases": active,
|
||||||
|
"stale_review_leases": stale,
|
||||||
|
"reclaimable_review_leases": reclaimable,
|
||||||
|
}
|
||||||
@@ -732,6 +732,26 @@ The final report must identify:
|
|||||||
* whether same-PR merge continuation was allowed
|
* whether same-PR merge continuation was allowed
|
||||||
* whether the run stopped as required
|
* whether the run stopped as required
|
||||||
|
|
||||||
|
## 26B. Per-PR reviewer lease (#407)
|
||||||
|
|
||||||
|
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||||
|
live PR lease.
|
||||||
|
|
||||||
|
Before validation or review mutation on a selected PR:
|
||||||
|
|
||||||
|
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
|
||||||
|
and target branch SHA.
|
||||||
|
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
|
||||||
|
after validation, before review mutation, and before merge.
|
||||||
|
3. Do not approve, request changes, or merge unless the in-session lease
|
||||||
|
matches the selected PR.
|
||||||
|
|
||||||
|
If PR head or target branch advances during the lease, stop and refresh
|
||||||
|
inventory before continuing.
|
||||||
|
|
||||||
|
Final reports must include lease session id, acquisition proof, heartbeat
|
||||||
|
status, and release/blocked status.
|
||||||
|
|
||||||
## 27. Merge rules
|
## 27. Merge rules
|
||||||
|
|
||||||
Before merge, rerun fresh live checks:
|
Before merge, rerun fresh live checks:
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Tests for per-PR reviewer leases (#407)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_comment(
|
||||||
|
pr_number: int,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
phase: str = "claimed",
|
||||||
|
minutes_ago: int = 0,
|
||||||
|
candidate_head: str = "a" * 40,
|
||||||
|
) -> dict:
|
||||||
|
now = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=295,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree="branches/review-pr382",
|
||||||
|
phase=phase,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=now,
|
||||||
|
)
|
||||||
|
return {"id": 1, "body": body, "user": {"login": "rev1"}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseAcquire(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_two_reviewers_cannot_lease_same_pr(self):
|
||||||
|
comments = [_lease_comment(382, "session-a")]
|
||||||
|
result = leases.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=382,
|
||||||
|
reviewer_identity="rev2",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="session-b",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=295,
|
||||||
|
worktree="branches/review-pr382-b",
|
||||||
|
candidate_head="c" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="d" * 40,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["acquire_allowed"])
|
||||||
|
self.assertTrue(any("already has active" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_two_reviewers_can_lease_different_prs(self):
|
||||||
|
comments = [_lease_comment(382, "session-a")]
|
||||||
|
result = leases.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=383,
|
||||||
|
reviewer_identity="rev2",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="session-b",
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
issue_number=296,
|
||||||
|
worktree="branches/review-pr383",
|
||||||
|
candidate_head="c" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="d" * 40,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["acquire_allowed"])
|
||||||
|
self.assertIsNotNone(result["lease_body"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseFreshness(unittest.TestCase):
|
||||||
|
def test_stale_warning_after_30_minutes(self):
|
||||||
|
lease = leases.parse_lease_comment(
|
||||||
|
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
leases.classify_lease_freshness(lease),
|
||||||
|
"stale_warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reclaimable_after_60_minutes(self):
|
||||||
|
lease = leases.parse_lease_comment(
|
||||||
|
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
leases.classify_lease_freshness(lease),
|
||||||
|
"reclaimable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseMutationGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_reviewer_without_lease_cannot_mutate(self):
|
||||||
|
head = "f" * 40
|
||||||
|
comments = [_lease_comment(382, "other-session", candidate_head=head)]
|
||||||
|
result = leases.assess_mutation_lease_gate(
|
||||||
|
pr_number=382,
|
||||||
|
comments=comments,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
session_id="my-session",
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha=head,
|
||||||
|
pinned_head_sha=head,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_owned_lease_allows_mutation(self):
|
||||||
|
head = "f" * 40
|
||||||
|
comments = [_lease_comment(382, "my-session", candidate_head=head)]
|
||||||
|
leases.record_session_lease({
|
||||||
|
"pr_number": 382,
|
||||||
|
"session_id": "my-session",
|
||||||
|
"candidate_head": head,
|
||||||
|
"target_branch": "master",
|
||||||
|
})
|
||||||
|
result = leases.assess_mutation_lease_gate(
|
||||||
|
pr_number=382,
|
||||||
|
comments=comments,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
session_id="my-session",
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha=head,
|
||||||
|
pinned_head_sha=head,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_head_change_invalidates_lease(self):
|
||||||
|
reviewed = "f" * 40
|
||||||
|
live = "e" * 40
|
||||||
|
comments = [_lease_comment(382, "my-session", candidate_head=reviewed)]
|
||||||
|
leases.record_session_lease({
|
||||||
|
"pr_number": 382,
|
||||||
|
"session_id": "my-session",
|
||||||
|
"candidate_head": reviewed,
|
||||||
|
})
|
||||||
|
result = leases.assess_mutation_lease_gate(
|
||||||
|
pr_number=382,
|
||||||
|
comments=comments,
|
||||||
|
reviewer_identity="rev1",
|
||||||
|
session_id="my-session",
|
||||||
|
mutation="merge",
|
||||||
|
live_head_sha=live,
|
||||||
|
pinned_head_sha=reviewed,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(any("head" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseMcpGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
patch("mcp_server.verify_preflight_purity").start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
mcp_server = __import__("mcp_server")
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", "reviewer")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_reviewer_pr_lease_gate_helper_blocks_without_session(self):
|
||||||
|
import mcp_server
|
||||||
|
head = "a" * 40
|
||||||
|
with patch("mcp_server._fetch_pr_comments", return_value=[]):
|
||||||
|
reasons = mcp_server._reviewer_pr_lease_gate(
|
||||||
|
pr_number=382,
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org=None,
|
||||||
|
repo=None,
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha=head,
|
||||||
|
pinned_head_sha=head,
|
||||||
|
)
|
||||||
|
self.assertTrue(any("lease" in r.lower() for r in reasons))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user