Merge pull request 'feat: guarded merger lease adoption flow (#536)' (#538) from feat/issue-536-merger-lease-adoption into master
This commit was merged in pull request #538.
This commit is contained in:
@@ -672,6 +672,33 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
|||||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
- **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.`
|
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 (`<!-- mcp-review-lease-adoption:v1 -->`)
|
||||||
|
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
|
### Close the issue after merge / Reconciliation
|
||||||
|
|
||||||
- **Profile:** issue-manager or merger.
|
- **Profile:** issue-manager or merger.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
|
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
|
||||||
- `gitea_mark_final_review_decision` — mark validation complete.
|
- `gitea_mark_final_review_decision` — mark validation complete.
|
||||||
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
|
- `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).
|
- `gitea_merge_pr` — gated merge (only merge path).
|
||||||
|
|
||||||
## Read tools
|
## Read tools
|
||||||
|
|||||||
+188
-2
@@ -751,6 +751,7 @@ import root_checkout_guard # 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 reviewer_pr_lease # noqa: E402
|
||||||
|
import merger_lease_adoption # 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
|
||||||
@@ -5459,7 +5460,10 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
"target_branch_sha": target_branch_sha,
|
"target_branch_sha": target_branch_sha,
|
||||||
"repo": repo_label,
|
"repo": repo_label,
|
||||||
"comment_id": posted.get("id"),
|
"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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"acquired": True,
|
"acquired": True,
|
||||||
@@ -5471,6 +5475,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()
|
@mcp.tool()
|
||||||
def gitea_heartbeat_reviewer_pr_lease(
|
def gitea_heartbeat_reviewer_pr_lease(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -5531,6 +5705,17 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
):
|
):
|
||||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
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({
|
updated = reviewer_pr_lease.record_session_lease({
|
||||||
**session,
|
**session,
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
@@ -5538,7 +5723,8 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
"candidate_head": candidate_head or session.get("candidate_head"),
|
"candidate_head": candidate_head or session.get("candidate_head"),
|
||||||
"target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
|
"target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
|
||||||
"last_comment_id": posted.get("id"),
|
"last_comment_id": posted.get("id"),
|
||||||
})
|
"comment_id": posted.get("id"),
|
||||||
|
}, lease_provenance=heartbeat_provenance)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"posted": True,
|
"posted": True,
|
||||||
|
|||||||
@@ -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 = "<!-- mcp-review-lease-adoption:v1 -->"
|
||||||
|
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,
|
||||||
|
}
|
||||||
+31
-5
@@ -23,6 +23,7 @@ _ACTIVE_PHASES = frozenset({
|
|||||||
"approved",
|
"approved",
|
||||||
"request-changes",
|
"request-changes",
|
||||||
"merging",
|
"merging",
|
||||||
|
"adopted",
|
||||||
})
|
})
|
||||||
|
|
||||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
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
|
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)
|
return dict(_SESSION_LEASE)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,13 +429,25 @@ def assess_mutation_lease_gate(
|
|||||||
if not session:
|
if not session:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"no in-session reviewer lease recorded; acquire via "
|
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(
|
reasons.append(
|
||||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
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)")
|
reasons.append("session lease session_id mismatch (fail closed)")
|
||||||
|
|
||||||
if active:
|
if active:
|
||||||
|
|||||||
@@ -834,6 +834,21 @@ inventory before continuing.
|
|||||||
Final reports must include lease session id, acquisition proof, heartbeat
|
Final reports must include lease session id, acquisition proof, heartbeat
|
||||||
status, and release/blocked status.
|
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)
|
## 26C. Conflict-fix lease and stale-head protection (#399)
|
||||||
|
|
||||||
Before validating, approving, or merging a PR:
|
Before validating, approving, or merging a PR:
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
"adopt_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"blind_pr_queue_review": {
|
"blind_pr_queue_review": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ def _install_owned_reviewer_lease(
|
|||||||
session_id=_DEFAULT_LEASE_SESSION,
|
session_id=_DEFAULT_LEASE_SESSION,
|
||||||
head_sha="abc123",
|
head_sha="abc123",
|
||||||
):
|
):
|
||||||
|
import merger_lease_adoption as mla
|
||||||
import reviewer_pr_lease
|
import reviewer_pr_lease
|
||||||
|
|
||||||
reviewer_pr_lease.clear_session_lease()
|
reviewer_pr_lease.clear_session_lease()
|
||||||
@@ -135,7 +136,11 @@ def _install_owned_reviewer_lease(
|
|||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"candidate_head": head_sha,
|
"candidate_head": head_sha,
|
||||||
"target_branch": "master",
|
"target_branch": "master",
|
||||||
})
|
"comment_id": 9001,
|
||||||
|
}, lease_provenance=mla.build_lease_provenance(
|
||||||
|
source=mla.SOURCE_ACQUIRE,
|
||||||
|
comment_id=9001,
|
||||||
|
))
|
||||||
return patch(
|
return patch(
|
||||||
"mcp_server._fetch_pr_comments",
|
"mcp_server._fetch_pr_comments",
|
||||||
return_value=[
|
return_value=[
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import merger_lease_adoption as mla
|
||||||
import reviewer_pr_lease as leases
|
import reviewer_pr_lease as leases
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +124,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
|
|||||||
"session_id": "my-session",
|
"session_id": "my-session",
|
||||||
"candidate_head": head,
|
"candidate_head": head,
|
||||||
"target_branch": "master",
|
"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(
|
result = leases.assess_mutation_lease_gate(
|
||||||
pr_number=382,
|
pr_number=382,
|
||||||
comments=comments,
|
comments=comments,
|
||||||
@@ -143,7 +148,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
|
|||||||
"pr_number": 382,
|
"pr_number": 382,
|
||||||
"session_id": "my-session",
|
"session_id": "my-session",
|
||||||
"candidate_head": reviewed,
|
"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(
|
result = leases.assess_mutation_lease_gate(
|
||||||
pr_number=382,
|
pr_number=382,
|
||||||
comments=comments,
|
comments=comments,
|
||||||
|
|||||||
Reference in New Issue
Block a user