From 06c20692348214c8fc2cf4bccbcd0a5dc500ae6f Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 13:07:22 -0400 Subject: [PATCH 1/3] 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) --- gitea_mcp_server.py | 284 +++++++++++++ reviewer_pr_lease.py | 382 ++++++++++++++++++ .../workflows/review-merge-pr.md | 20 + tests/test_reviewer_pr_lease.py | 193 +++++++++ 4 files changed, 879 insertions(+) create mode 100644 reviewer_pr_lease.py create mode 100644 tests/test_reviewer_pr_lease.py diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 17938e3..cd24d8d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -543,6 +543,7 @@ import already_landed_reconcile # noqa: E402 import author_mutation_worktree # noqa: E402 import issue_claim_heartbeat # noqa: E402 import issue_work_duplicate_gate # noqa: E402 +import reviewer_pr_lease # noqa: E402 import merged_cleanup_reconcile # noqa: E402 import reconciler_profile # 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() or profile_name ) + reviewer_pr_lease.clear_session_lease() _save_review_decision_lock({ "task": task, "remote": remote, @@ -2458,6 +2460,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: @@ -3257,6 +3273,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 — head SHA must match if the caller pinned a reviewed SHA. actual_sha = result["head_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 +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, diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py new file mode 100644 index 0000000..6e28be5 --- /dev/null +++ b/reviewer_pr_lease.py @@ -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 = "" + +_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, + } \ No newline at end of file diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 5b0e10a..4f9f6eb 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -732,6 +732,26 @@ The final report must identify: * whether same-PR merge continuation was allowed * 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 Before merge, rerun fresh live checks: diff --git a/tests/test_reviewer_pr_lease.py b/tests/test_reviewer_pr_lease.py new file mode 100644 index 0000000..69ee6d5 --- /dev/null +++ b/tests/test_reviewer_pr_lease.py @@ -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() \ No newline at end of file -- 2.43.7 From 1830360850520e49a537bd4d9ec3bf730c20ce1b Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 16:32:23 -0400 Subject: [PATCH 2/3] test: fix reviewer lease gate regressions in MCP integration tests (#407) Align merge/review/audit mocks with the per-PR reviewer lease gate introduced in #407: install owned session leases after init_review_decision_lock, stub _fetch_pr_comments and _authenticated_username to preserve mock ordering, and update head-SHA mismatch expectations for lease-first fail-closed paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_audit.py | 32 ++++++- tests/test_mcp_server.py | 139 ++++++++++++++++++++++++++++--- tests/test_pr_queue_inventory.py | 19 ++++- 3 files changed, 172 insertions(+), 18 deletions(-) diff --git a/tests/test_audit.py b/tests/test_audit.py index adb9242..11cecd3 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -286,6 +286,21 @@ class TestSimpleToolAudit(_AuditWiringBase): class TestGatedToolAudit(_AuditWiringBase): + def setUp(self): + super().setUp() + from tests.test_mcp_server import _install_owned_reviewer_lease + import reviewer_pr_lease + + self._lease_patch = _install_owned_reviewer_lease(8) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def _pr(self, author, state="open", sha="abc123", mergeable=True): return {"user": {"login": author}, "state": state, "head": {"sha": sha}, "mergeable": mergeable} @@ -344,11 +359,22 @@ class TestGatedToolAudit(_AuditWiringBase): GITEA_ALLOWED_OPERATIONS="read,review,approve") with patch.dict(os.environ, env, clear=True): from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision + from tests.test_mcp_server import _install_owned_reviewer_lease + import reviewer_pr_lease + init_review_decision_lock("prgs", "review_pr") gitea_mark_final_review_decision(8, "approve", remote="prgs") - r = gitea_submit_pr_review(pr_number=8, action="approve", - body="LGTM", remote="prgs", - final_review_decision_ready=True) + lease_patch = _install_owned_reviewer_lease(8) + lease_patch.start() + try: + r = gitea_submit_pr_review( + pr_number=8, action="approve", + body="LGTM", remote="prgs", + final_review_decision_ready=True, + ) + finally: + lease_patch.stop() + reviewer_pr_lease.clear_session_lease() self.assertTrue(r["performed"]) recs = self._records() self.assertEqual(len(recs), 1) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index aabb7e7..d04cf38 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -79,6 +79,64 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"): return [_formal_review(reviewer, "APPROVED", sha=sha)] +_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease" + + +def _reviewer_lease_comment( + pr_number, + *, + session_id=_DEFAULT_LEASE_SESSION, + head_sha="abc123", + reviewer="reviewer-bot", +): + from datetime import datetime, timezone + + import reviewer_pr_lease + + body = reviewer_pr_lease.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=407, + reviewer_identity=reviewer, + profile="gitea-reviewer", + session_id=session_id, + worktree="branches/review-test", + phase="claimed", + candidate_head=head_sha, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + return {"id": 9001, "body": body, "user": {"login": reviewer}} + + +def _install_owned_reviewer_lease( + pr_number, + *, + session_id=_DEFAULT_LEASE_SESSION, + head_sha="abc123", +): + import reviewer_pr_lease + + reviewer_pr_lease.clear_session_lease() + reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "session_id": session_id, + "candidate_head": head_sha, + "target_branch": "master", + }) + return patch( + "mcp_server._fetch_pr_comments", + return_value=[ + _reviewer_lease_comment( + pr_number, + session_id=session_id, + head_sha=head_sha, + ) + ], + ) + + # Issue-write tools are profile-gated (#69). ISSUE_WRITE_ENV = { "GITEA_ALLOWED_OPERATIONS": ( @@ -539,6 +597,19 @@ class TestViewPR(unittest.TestCase): class TestMergePR(unittest.TestCase): """Gated merge workflow (#16). gitea_merge_pr is the only merge path.""" + def setUp(self): + import reviewer_pr_lease + + self._lease_patch = _install_owned_reviewer_lease(8) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def _pr(self, author, state="open", sha="abc123", mergeable=True): return { "user": {"login": author}, @@ -799,9 +870,11 @@ class TestMergePR(unittest.TestCase): pr_number=8, confirmation=self._confirm(8), expected_head_sha="deadbeef", remote="prgs") self.assertFalse(r["performed"]) - self.assertIn( - "expected head SHA does not match current PR head (fail closed)", - r["reasons"]) + self.assertTrue(any( + "expected head SHA does not match current PR head (fail closed)" in reason + or "PR head changed during lease" in reason + for reason in r["reasons"] + )) self._assert_no_merge_call(mock_api) @patch("mcp_server.api_request") @@ -1680,7 +1753,20 @@ class TestReviewDecisionValidationGate(unittest.TestCase): } def setUp(self): + import reviewer_pr_lease + init_review_decision_lock("prgs", "review_pr") + self._lease_patch = _install_owned_reviewer_lease( + self.PR, head_sha=self.SHA, + ) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) def _env(self): return patch.dict(os.environ, { @@ -1775,8 +1861,19 @@ class TestSubmitPrReview(unittest.TestCase): """Gated review-mutation tool (#15).""" def setUp(self): + import reviewer_pr_lease + init_review_decision_lock("prgs", "review_pr") gitea_mark_final_review_decision(8, "approve", remote="prgs") + self._lease_patch = _install_owned_reviewer_lease(8) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) def _pr(self, author, state="open", sha="abc123", mergeable=True): return { @@ -2014,9 +2111,11 @@ class TestSubmitPrReview(unittest.TestCase): final_review_decision_ready=True, ) self.assertFalse(r["performed"]) - self.assertIn( - "expected head SHA does not match current PR head (fail closed)", - r["reasons"]) + self.assertTrue(any( + "expected head SHA does not match current PR head (fail closed)" in reason + or "PR head changed during lease" in reason + for reason in r["reasons"] + )) self._assert_no_mutation(mock_api) def test_head_sha_match_allows(self): @@ -2079,9 +2178,9 @@ class TestSubmitPrReview(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve"} with patch.dict(os.environ, env, clear=True): - gitea_mark_final_review_decision(5, "approve", remote="prgs") + gitea_mark_final_review_decision(8, "approve", remote="prgs") r = gitea_submit_pr_review( - pr_number=5, action="approve", remote="prgs", + pr_number=8, action="approve", remote="prgs", final_review_decision_ready=True, ) self.assertFalse(r["performed"]) @@ -2300,6 +2399,13 @@ class TestTrackerHygieneCleanup(unittest.TestCase): self.assertEqual(res["cleanup_status"].get(1), "not present") def test_merge_pr_with_closes_removes_label(self): + import reviewer_pr_lease + + lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123") + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def api_side_effect(method, url, auth, payload=None): if method == "GET" and "/user" in url: return {"login": "merger"} @@ -2334,6 +2440,13 @@ class TestTrackerHygieneCleanup(unittest.TestCase): self.assertEqual(res["cleanup_status"].get(123), "released") def test_merge_pr_with_branch_name_removes_label(self): + import reviewer_pr_lease + + lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123") + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def api_side_effect(method, url, auth, payload=None): if method == "GET" and "/user" in url: return {"login": "merger"} @@ -3015,10 +3128,12 @@ class TestVerifyMutationAuthority(unittest.TestCase): # profile; the active profile resolves as reviewer — side-channel # override rejected even with a matching in-process authority. self._authority() - with patch.dict(os.environ, - {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}): - with self.assertRaises(RuntimeError) as ctx: - mcp_server.verify_mutation_authority("prgs") + with patch("mcp_server.gitea_config.is_runtime_switching_enabled", + return_value=False): + with patch.dict(os.environ, + {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_mutation_authority("prgs") self.assertIn("side-channel override rejected", str(ctx.exception)) def test_foreign_pid_authority_is_not_trusted(self): diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py index aeb8bc4..1e5a3c5 100644 --- a/tests/test_pr_queue_inventory.py +++ b/tests/test_pr_queue_inventory.py @@ -156,9 +156,22 @@ class TestPRQueueInventory(unittest.TestCase): ] from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision - init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(1, "approve", remote="prgs") - result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True) + from tests.test_mcp_server import _install_owned_reviewer_lease + import reviewer_pr_lease + + with patch("mcp_server._authenticated_username", return_value="reviewer1"): + init_review_decision_lock("prgs", "review_pr") + gitea_mark_final_review_decision(1, "approve", remote="prgs") + lease_patch = _install_owned_reviewer_lease( + 1, head_sha="abc1", session_id="inventory-review-lease", + ) + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + result = gitea_review_pr( + pr_number=1, event="APPROVE", remote="prgs", + final_review_decision_ready=True, + ) self.assertTrue(result["success"]) self.assertIn("=== PR Queue Inventory ===", result["message"]) self.assertIn("Repository:", result["message"]) -- 2.43.7 From 3e4b721d60e97147ba0704773cf57cd0d42cbe31 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 17:18:00 -0400 Subject: [PATCH 3/3] test: align duplicate gate lock fixtures with #447 provenance (#407) Rebase onto master (#413/#463) requires sanctioned lock_provenance in create_pr duplicate-recheck tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_issue_work_duplicate_gate.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py index 6e9ea5a..ac4ccea 100644 --- a/tests/test_issue_work_duplicate_gate.py +++ b/tests/test_issue_work_duplicate_gate.py @@ -9,6 +9,7 @@ from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import issue_lock_provenance import issue_work_duplicate_gate as dup_gate import mcp_server from issue_work_duplicate_gate import ( @@ -172,11 +173,23 @@ class TestMcpDuplicateRecheck(unittest.TestCase): self._dir.cleanup() def _write_lock(self, issue_number=400, branch="feat/issue-400-x"): + work_lease = { + "operation_type": "author_issue_work", + "issue_number": issue_number, + "branch": branch, + "claimant": {"username": "test-user", "profile": "test-author"}, + "expires_at": "2999-01-01T00:00:00Z", + } with open(self.lock_path, "w", encoding="utf-8") as fh: json.dump({ "issue_number": issue_number, "branch_name": branch, "remote": "prgs", + "work_lease": work_lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease.get("claimant"), + ), }, fh) @patch("mcp_server._assess_issue_duplicate_gate") -- 2.43.7