diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index cb904c4..1832204 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -641,6 +641,33 @@ loop and do **not** substitute WebFetch/Playwright/manual base64. - **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and it is mergeable. Confirm with "MERGE PR N". Do not force-merge.` +#### Merger lease adoption (#536) + +When review and merge run in **separate sessions**, the merger must **not** +manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state. +That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not +canonical proof and is rejected by mutation gates. + +**Canonical merger handoff:** + +1. Confirm a reviewer session holds an active PR lease and posted **APPROVED** + at the current live head (`gitea_get_pr_review_feedback`). +2. In a clean merger worktree under `branches/`, call + `gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and + optional `issue_number`. +3. The tool posts durable adoption proof on the PR thread (``) + recording actor, profiles, adopted-from session/comment, adoption reason, and + timestamp, then records sanctioned in-session provenance. +4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`. + +**Forbidden:** Python one-liners or scripts that call `record_session_lease()` +without provenance from `gitea_acquire_reviewer_pr_lease`, +`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`. + +**Same-session review+merge:** the reviewer session may use +`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session +merger handoff. + ### Close the issue after merge / Reconciliation - **Profile:** issue-manager or merger. diff --git a/docs/wiki/MCP-Tools.md b/docs/wiki/MCP-Tools.md index 38af50e..cd46012 100644 --- a/docs/wiki/MCP-Tools.md +++ b/docs/wiki/MCP-Tools.md @@ -24,6 +24,8 @@ - `gitea_dry_run_pr_review` — validation-phase review mechanics. - `gitea_mark_final_review_decision` — mark validation complete. - `gitea_submit_pr_review` / `gitea_review_pr` — gated live review. +- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407). +- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536). - `gitea_merge_pr` — gated merge (only merge path). ## Read tools diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 4e6fdf3..db2a8e2 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -727,6 +727,7 @@ 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 merger_lease_adoption # noqa: E402 import merged_cleanup_reconcile # noqa: E402 import reconciler_profile # noqa: E402 import reconciliation_workflow # noqa: E402 @@ -5350,7 +5351,10 @@ def gitea_acquire_reviewer_pr_lease( "target_branch_sha": target_branch_sha, "repo": repo_label, "comment_id": posted.get("id"), - }) + }, lease_provenance=merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_ACQUIRE, + comment_id=posted.get("id"), + )) return { "success": True, "acquired": True, @@ -5362,6 +5366,176 @@ def gitea_acquire_reviewer_pr_lease( } +@mcp.tool() +def gitea_adopt_merger_pr_lease( + pr_number: int, + worktree: str, + expected_head_sha: str, + 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: + """Adopt an active reviewer PR lease for a merger session (#536). + + Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``. + This tool posts durable adoption proof on the PR thread and records + sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed + after a separate reviewer session held the lease. + + Args: + pr_number: Open PR to adopt. + worktree: Merger workspace under ``branches/``. + expected_head_sha: Approved head the merger will pin during merge. + issue_number: Optional linked issue for the adoption record. + session_id: Optional merger session id (generated when omitted). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with adoption proof, session lease, and block reasons when the + guarded preconditions are not met. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "adopted": 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, + "adopted": False, + "reasons": comment_block, + "permission_report": _permission_block_report("gitea.pr.comment"), + } + merge_block = _profile_operation_gate("gitea.pr.merge") + if merge_block: + return { + "success": False, + "adopted": False, + "reasons": merge_block, + "permission_report": _permission_block_report("gitea.pr.merge"), + } + + _verify_role_mutation_workspace( + remote, worktree=worktree, task="adopt_merger_pr_lease" + ) + verify_preflight_purity(remote, task="adopt_merger_pr_lease") + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + profile = get_profile() + profile_name = profile.get("profile_name") or "unknown" + 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}" + + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {} + pr_state = (pr_live.get("state") or "").strip().lower() + pr_open = pr_state == "open" and not pr_live.get("merged") + live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get( + "head_sha" + ) + + feedback = gitea_get_pr_review_feedback( + pr_number=pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + ) + approval_at_head = bool(feedback.get("approval_at_current_head")) + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + assessment = merger_lease_adoption.assess_adopt_merger_lease( + comments, + pr_number=pr_number, + adopter_identity=identity, + adopter_profile=profile_name, + adopter_session_id=sid, + repo=repo_label, + issue_number=issue_number, + worktree=worktree, + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + approval_at_head=approval_at_head, + pr_open=pr_open, + ) + if not assessment.get("adopt_allowed"): + return { + "success": False, + "adopted": False, + "pr_number": pr_number, + "reasons": assessment.get("reasons") or [], + "active_lease": assessment.get("active_lease"), + "expected_head_sha": expected_head_sha, + "live_head_sha": live_head, + } + + active = assessment.get("active_lease") or {} + body = assessment["adoption_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": "adopt_merger_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + provenance = merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_ADOPT, + comment_id=posted.get("id"), + adopted_from_session_id=active.get("session_id"), + adopted_from_profile=active.get("profile"), + adopted_from_reviewer_identity=active.get("reviewer_identity"), + adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON, + ) + session_lease = reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "issue_number": issue_number or active.get("issue_number"), + "session_id": sid, + "reviewer_identity": identity, + "profile": profile_name, + "worktree": worktree, + "phase": "adopted", + "candidate_head": live_head, + "target_branch": active.get("target_branch") or "master", + "target_branch_sha": active.get("target_branch_sha"), + "repo": repo_label, + "comment_id": posted.get("id"), + }, lease_provenance=provenance) + + return { + "success": True, + "adopted": True, + "pr_number": pr_number, + "session_id": sid, + "adoption_comment_id": posted.get("id"), + "adopted_from_session_id": active.get("session_id"), + "adopted_from_profile": active.get("profile"), + "adopted_from_reviewer_identity": active.get("reviewer_identity"), + "adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON, + "expected_head_sha": expected_head_sha, + "live_head_sha": live_head, + "session_lease": session_lease, + "lease_provenance": provenance, + "reasons": [], + } + + @mcp.tool() def gitea_heartbeat_reviewer_pr_lease( pr_number: int, @@ -5422,6 +5596,17 @@ def gitea_heartbeat_reviewer_pr_lease( ): posted = api_request("POST", comment_url, auth, {"body": body}) + prior_provenance = (session.get("lease_provenance") or {}).copy() + heartbeat_provenance = merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_HEARTBEAT, + comment_id=posted.get("id"), + adopted_from_session_id=prior_provenance.get("adopted_from_session_id"), + adopted_from_profile=prior_provenance.get("adopted_from_profile"), + adopted_from_reviewer_identity=prior_provenance.get( + "adopted_from_reviewer_identity" + ), + adoption_reason=prior_provenance.get("adoption_reason"), + ) updated = reviewer_pr_lease.record_session_lease({ **session, "phase": phase, @@ -5429,7 +5614,8 @@ def gitea_heartbeat_reviewer_pr_lease( "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"), - }) + "comment_id": posted.get("id"), + }, lease_provenance=heartbeat_provenance) return { "success": True, "posted": True, diff --git a/merger_lease_adoption.py b/merger_lease_adoption.py new file mode 100644 index 0000000..8c05de1 --- /dev/null +++ b/merger_lease_adoption.py @@ -0,0 +1,253 @@ +"""Guarded merger adoption of an existing reviewer PR lease (#536). + +Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable, +comment-backed adoption path for merger sessions that inherit a reviewer lease +after formal approval at the current PR head. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import reviewer_pr_lease as leases + +ADOPTION_MARKER = "" +DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head" + +SOURCE_ADOPT = "gitea_adopt_merger_pr_lease" +SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease" +SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease" + +SANCTIONED_PROVENANCE_SOURCES = frozenset({ + SOURCE_ADOPT, + SOURCE_ACQUIRE, + SOURCE_HEARTBEAT, +}) + +_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"}) + + +def format_adoption_body( + *, + repo: str, + pr_number: int, + issue_number: int | None, + adopter_identity: str, + adopter_profile: str, + adopter_session_id: str, + worktree: str, + candidate_head: str | None, + target_branch: str, + target_branch_sha: str | None, + adopted_from_session_id: str, + adopted_from_profile: str, + adopted_from_reviewer_identity: str, + adopted_from_comment_id: int | None, + adoption_reason: str = DEFAULT_ADOPTION_REASON, + adopted_at: datetime | None = None, +) -> str: + """Render a durable merger adoption proof comment.""" + adopted_at = adopted_at or datetime.now(timezone.utc) + adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + lease_body = leases.format_lease_body( + repo=repo, + pr_number=pr_number, + issue_number=issue_number, + reviewer_identity=adopter_identity, + profile=adopter_profile, + session_id=adopter_session_id, + worktree=worktree, + phase="adopted", + candidate_head=candidate_head, + target_branch=target_branch, + target_branch_sha=target_branch_sha, + last_activity=adopted_at, + blocker="none", + ) + lines = [ + ADOPTION_MARKER, + f"adopted_at: {adopted_text}", + f"adopted_by_identity: {adopter_identity}", + f"adopted_by_profile: {adopter_profile}", + f"adopted_from_session_id: {adopted_from_session_id}", + f"adopted_from_profile: {adopted_from_profile}", + f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}", + f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}", + f"adoption_reason: {adoption_reason}", + lease_body, + ] + return "\n".join(lines) + + +def is_adoption_comment(body: str) -> bool: + return ADOPTION_MARKER in (body or "") + + +def build_lease_provenance( + *, + source: str, + comment_id: int | None = None, + adopted_from_session_id: str | None = None, + adopted_from_profile: str | None = None, + adopted_from_reviewer_identity: str | None = None, + adoption_reason: str | None = None, + recorded_at: datetime | None = None, +) -> dict[str, Any]: + recorded_at = recorded_at or datetime.now(timezone.utc) + recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + proof = { + "source": source, + "recorded_at": recorded_text, + } + if comment_id is not None: + proof["comment_id"] = comment_id + if adopted_from_session_id: + proof["adopted_from_session_id"] = adopted_from_session_id + if adopted_from_profile: + proof["adopted_from_profile"] = adopted_from_profile + if adopted_from_reviewer_identity: + proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity + if adoption_reason: + proof["adoption_reason"] = adoption_reason + return proof + + +def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool: + if not session: + return False + provenance = session.get("lease_provenance") or {} + source = (provenance.get("source") or "").strip() + if source not in SANCTIONED_PROVENANCE_SOURCES: + return False + if source == SOURCE_ADOPT: + return bool(provenance.get("comment_id")) and bool( + provenance.get("adopted_from_session_id") + ) + if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}: + return bool(session.get("comment_id") or provenance.get("comment_id")) + return False + + +def assess_adopt_merger_lease( + comments: list[dict], + *, + pr_number: int, + adopter_identity: str, + adopter_profile: str, + adopter_session_id: str, + repo: str, + issue_number: int | None, + worktree: str, + expected_head_sha: str | None, + live_head_sha: str | None, + approval_at_head: bool, + pr_open: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """Decide whether a merger may adopt the active reviewer lease (#536).""" + now = now or datetime.now(timezone.utc) + reasons: list[str] = [] + pinned = leases._normalize_sha(expected_head_sha) + live = leases._normalize_sha(live_head_sha) + + if not pr_open: + reasons.append( + f"PR #{pr_number} is not open; merger lease adoption is only for open PRs" + ) + if not approval_at_head: + reasons.append( + "formal APPROVED review at the current PR head is required before " + "merger lease adoption (fail closed)" + ) + if not pinned: + reasons.append("expected_head_sha is required for merger lease adoption") + if not live: + reasons.append("live PR head SHA unavailable (fail closed)") + elif pinned and live and pinned != live: + reasons.append( + "expected_head_sha does not match live PR head; refresh approval before adoption" + ) + + active = leases.find_active_reviewer_lease( + comments, pr_number=pr_number, now=now + ) + if not active: + reasons.append( + f"no active reviewer lease on PR #{pr_number} to adopt; reviewer " + "must acquire first" + ) + else: + owner_session = (active.get("session_id") or "").strip() + freshness = active.get("freshness") or leases.classify_lease_freshness( + active, now=now + ) + if freshness not in _MERGER_ADOPTABLE_FRESHNESS: + reasons.append( + f"active reviewer lease freshness is '{freshness}'; explicit " + "reclaim is not implemented (fail closed)" + ) + lease_head = active.get("candidate_head") + if lease_head and live and lease_head != live: + reasons.append( + "active reviewer lease candidate_head differs from live PR head" + ) + if owner_session and owner_session == adopter_session_id: + # Same session already holds the thread lease — allow idempotent adopt + # only when the newest entry is not yet an adoption by this session. + entries = leases._lease_entries(comments, pr_number=pr_number) + newest = entries[-1] if entries else None + if newest and (newest.get("phase") or "") == "adopted": + reasons.append( + "merger session already recorded an adoption lease on this PR" + ) + elif owner_session and owner_session != adopter_session_id: + # Cross-session merger handoff: adopt the foreign reviewer lease. + pass + + if not (adopter_identity or "").strip(): + reasons.append("adopter identity required") + if not (adopter_session_id or "").strip(): + reasons.append("adopter session_id required") + if not (worktree or "").strip(): + reasons.append("merger worktree path required") + if "merger" not in (adopter_profile or "").lower(): + reasons.append( + f"profile '{adopter_profile}' is not a merger profile; adoption is " + "merger-only (fail closed)" + ) + + adopt_allowed = not reasons + adoption_body = None + if adopt_allowed and active: + adoption_body = format_adoption_body( + repo=repo, + pr_number=pr_number, + issue_number=issue_number or active.get("issue_number"), + adopter_identity=adopter_identity, + adopter_profile=adopter_profile, + adopter_session_id=adopter_session_id, + worktree=worktree, + candidate_head=live, + target_branch=active.get("target_branch") or "master", + target_branch_sha=active.get("target_branch_sha"), + adopted_from_session_id=active.get("session_id") or "", + adopted_from_profile=active.get("profile") or "unknown", + adopted_from_reviewer_identity=active.get("reviewer_identity") or "", + adopted_from_comment_id=active.get("comment_id"), + adopted_at=now, + ) + + return { + "adopt_allowed": adopt_allowed, + "reasons": reasons, + "active_lease": active, + "adoption_body": adoption_body, + "adopter_session_id": adopter_session_id, + "expected_head_sha": pinned, + "live_head_sha": live, + } \ No newline at end of file diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py index da1169e..f55390b 100644 --- a/reviewer_pr_lease.py +++ b/reviewer_pr_lease.py @@ -23,6 +23,7 @@ _ACTIVE_PHASES = frozenset({ "approved", "request-changes", "merging", + "adopted", }) DEFAULT_LEASE_TTL_MINUTES = 120 @@ -380,9 +381,22 @@ def assess_post_merge_moot_lease( } -def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]: +def record_session_lease( + lease: dict[str, Any], + *, + lease_provenance: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Record the in-session lease mirror for mutation gates. + + *lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare + manual seeding without provenance cannot satisfy merger/reviewer mutation + gates. + """ global _SESSION_LEASE - _SESSION_LEASE = dict(lease) + stored = dict(lease) + if lease_provenance: + stored["lease_provenance"] = dict(lease_provenance) + _SESSION_LEASE = stored return dict(_SESSION_LEASE) @@ -415,13 +429,25 @@ def assess_mutation_lease_gate( if not session: reasons.append( f"no in-session reviewer lease recorded; acquire via " - f"gitea_acquire_reviewer_pr_lease before {mutation}" + f"gitea_acquire_reviewer_pr_lease or adopt via " + f"gitea_adopt_merger_pr_lease before {mutation}" ) - elif session.get("pr_number") != pr_number: + else: + import merger_lease_adoption as mla + + if not mla.is_sanctioned_session_lease(session): + reasons.append( + "in-session lease lacks sanctioned provenance; manual " + "_SESSION_LEASE seeding is not canonical proof — use " + "gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease" + ) + if session and 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")): + elif session and (session.get("session_id") or "") != ( + session_id or session.get("session_id") + ): reasons.append("session lease session_id mismatch (fail closed)") if active: diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 76a7000..3f11833 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -796,6 +796,21 @@ inventory before continuing. Final reports must include lease session id, acquisition proof, heartbeat status, and release/blocked status. +## 26B-1. Merger lease adoption (#536) + +When review and merge are **different sessions**, the merger must adopt the +reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`. + +Before merge in a merger-only session: + +1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`. +2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under + `branches/`, passing `expected_head_sha` pinned to the approved head. +3. Quote the adoption comment id and `adopted_from_session_id` in the handoff. +4. Proceed to `gitea_merge_pr` only after adoption succeeds. + +Manual in-process lease seeding is rejected by mutation gates (fail closed). + ## 26C. Conflict-fix lease and stale-head protection (#399) Before validating, approving, or merging a PR: diff --git a/task_capability_map.py b/task_capability_map.py index 9e7d0ba..c86ce2a 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -68,6 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.pr.merge", "role": "reviewer", }, + "adopt_merger_pr_lease": { + "permission": "gitea.pr.comment", + "role": "reviewer", + }, "blind_pr_queue_review": { "permission": "gitea.pr.review", "role": "reviewer", diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d5a5035..25d9350 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -127,6 +127,7 @@ def _install_owned_reviewer_lease( session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123", ): + import merger_lease_adoption as mla import reviewer_pr_lease reviewer_pr_lease.clear_session_lease() @@ -135,7 +136,11 @@ def _install_owned_reviewer_lease( "session_id": session_id, "candidate_head": head_sha, "target_branch": "master", - }) + "comment_id": 9001, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=9001, + )) return patch( "mcp_server._fetch_pr_comments", return_value=[ diff --git a/tests/test_merger_lease_adoption.py b/tests/test_merger_lease_adoption.py new file mode 100644 index 0000000..688c14f --- /dev/null +++ b/tests/test_merger_lease_adoption.py @@ -0,0 +1,368 @@ +"""Merger lease adoption / recovery (#536).""" + +import os +import sys +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import merger_lease_adoption as mla +import reviewer_pr_lease as leases + + +def _lease_comment( + pr_number: int, + session_id: str, + *, + phase: str = "claimed", + profile: str = "prgs-reviewer", + identity: str = "sysadmin", + candidate_head: str = "a" * 40, + comment_id: int = 100, +) -> dict: + body = leases.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=536, + reviewer_identity=identity, + profile=profile, + session_id=session_id, + worktree="branches/review-pr536", + phase=phase, + candidate_head=candidate_head, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + return {"id": comment_id, "body": body, "user": {"login": identity}} + + +HEAD = "f" * 40 + + +class TestSanctionedProvenance(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_manual_session_seed_rejected_by_mutation_gate(self): + comments = [_lease_comment(536, "reviewer-session", candidate_head=HEAD)] + leases.record_session_lease({ + "pr_number": 536, + "session_id": "merger-session", + "candidate_head": HEAD, + "comment_id": 999, + }) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=comments, + reviewer_identity="sysadmin", + session_id="merger-session", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertTrue(result["block"]) + self.assertTrue( + any("sanctioned provenance" in r for r in result["reasons"]) + ) + + def test_sanctioned_acquire_provenance_allows_mutation(self): + comments = [_lease_comment(536, "my-session", candidate_head=HEAD)] + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "my-session", + "candidate_head": HEAD, + "comment_id": 101, + }, + lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=101, + ), + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=comments, + reviewer_identity="sysadmin", + session_id="my-session", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(result["block"]) + + +class TestAdoptAssessment(unittest.TestCase): + def test_adopt_allowed_for_merger_with_reviewer_lease_and_approval(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=True, + ) + self.assertTrue(result["adopt_allowed"]) + self.assertIn(mla.ADOPTION_MARKER, result["adoption_body"]) + self.assertIn("adopted_from_session_id: reviewer-sess", result["adoption_body"]) + + def test_adopt_blocked_without_approval(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=False, + pr_open=True, + ) + self.assertFalse(result["adopt_allowed"]) + self.assertTrue(any("APPROVED" in r for r in result["reasons"])) + + def test_adopt_blocked_for_non_merger_profile(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-reviewer", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/review-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=True, + ) + self.assertFalse(result["adopt_allowed"]) + self.assertTrue(any("merger profile" in r for r in result["reasons"])) + + def test_adopt_blocked_when_pr_not_open(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=False, + ) + self.assertFalse(result["adopt_allowed"]) + + +class TestMergerHandoffMutationGate(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_cross_session_merge_blocked_without_adoption_comment(self): + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + provenance = mla.build_lease_provenance( + source=mla.SOURCE_ADOPT, + comment_id=7001, + adopted_from_session_id="reviewer-sess", + ) + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "merger-sess", + "candidate_head": HEAD, + "comment_id": 7001, + "phase": "adopted", + }, + lease_provenance=provenance, + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment], + reviewer_identity="sysadmin", + session_id="merger-sess", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertTrue(result["block"]) + self.assertTrue(any("owned by session_id=reviewer-sess" in r for r in result["reasons"])) + + def test_cross_session_merge_allowed_after_adoption_comment(self): + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + adoption_body = mla.format_adoption_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=536, + issue_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + worktree="branches/merge-pr536", + candidate_head=HEAD, + target_branch="master", + target_branch_sha="b" * 40, + adopted_from_session_id="reviewer-sess", + adopted_from_profile="prgs-reviewer", + adopted_from_reviewer_identity="sysadmin", + adopted_from_comment_id=100, + ) + adoption_comment = { + "id": 7001, + "body": adoption_body, + "user": {"login": "sysadmin"}, + } + provenance = mla.build_lease_provenance( + source=mla.SOURCE_ADOPT, + comment_id=7001, + adopted_from_session_id="reviewer-sess", + ) + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "merger-sess", + "candidate_head": HEAD, + "comment_id": 7001, + "phase": "adopted", + }, + lease_provenance=provenance, + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment, adoption_comment], + reviewer_identity="sysadmin", + session_id="merger-sess", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(result["block"]) + + +class TestAdoptTool(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch("mcp_server._verify_role_mutation_workspace", return_value=None).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", "adopt_merger_pr_lease") + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", "reviewer", resolved_task="adopt_merger_pr_lease" + ) + + def tearDown(self): + patch.stopall() + leases.clear_session_lease() + + @patch("mcp_server._authenticated_username", return_value="sysadmin") + @patch("mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0") + @patch("mcp_server.get_profile") + @patch("mcp_server.api_request") + def test_adopt_tool_posts_proof_and_records_sanctioned_session( + self, mock_api, mock_profile, _mock_auth, _mock_user + ): + mock_profile.return_value = { + "profile_name": "prgs-merger", + "allowed_operations": [ + "gitea.read", + "gitea.pr.comment", + "gitea.pr.merge", + ], + "forbidden_operations": [], + } + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + posted_bodies: list[str] = [] + + def _side(method, url, auth=None, payload=None, *a, **k): + m = (method or "").upper() + if m == "POST": + if payload and payload.get("body"): + posted_bodies.append(payload["body"]) + return {"id": 7001} + if "/pulls/" in url and "/files" not in url: + return { + "state": "open", + "number": 536, + "head": {"sha": HEAD}, + "merged": False, + } + if "/reviews" in url: + return [] + if "/comments" in url: + return [reviewer_comment] + return {} + + mock_api.side_effect = _side + from mcp_server import gitea_adopt_merger_pr_lease + + with patch.object( + __import__("mcp_server"), + "gitea_get_pr_review_feedback", + return_value={ + "approval_at_current_head": True, + "latest_approved_head_sha": HEAD, + }, + ): + with patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "prgs-merger", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.merge", + }, + clear=True, + ): + result = gitea_adopt_merger_pr_lease( + pr_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + issue_number=536, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["adopted"]) + self.assertEqual(result["adopted_from_session_id"], "reviewer-sess") + self.assertEqual(result["adoption_comment_id"], 7001) + self.assertEqual(len(posted_bodies), 1) + self.assertIn(mla.ADOPTION_MARKER, posted_bodies[0]) + + session = leases.get_session_lease() + self.assertTrue(mla.is_sanctioned_session_lease(session)) + + adoption_comment = { + "id": 7001, + "body": posted_bodies[0], + "user": {"login": "sysadmin"}, + } + gate = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment, adoption_comment], + reviewer_identity="sysadmin", + session_id=result["session_id"], + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(gate["block"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_reviewer_pr_lease.py b/tests/test_reviewer_pr_lease.py index 69ee6d5..97e5a2e 100644 --- a/tests/test_reviewer_pr_lease.py +++ b/tests/test_reviewer_pr_lease.py @@ -7,6 +7,7 @@ from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import merger_lease_adoption as mla import reviewer_pr_lease as leases @@ -123,7 +124,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase): "session_id": "my-session", "candidate_head": head, "target_branch": "master", - }) + "comment_id": 101, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=101, + )) result = leases.assess_mutation_lease_gate( pr_number=382, comments=comments, @@ -143,7 +148,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase): "pr_number": 382, "session_id": "my-session", "candidate_head": reviewed, - }) + "comment_id": 102, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=102, + )) result = leases.assess_mutation_lease_gate( pr_number=382, comments=comments,