Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1844e29880 | ||
|
|
237656702f | ||
|
|
6d86db793b | ||
|
|
56f1230a10 | ||
|
|
d302602567 | ||
|
|
22698c1b5f | ||
|
|
de27053f74 | ||
|
|
72b18143f8 | ||
|
|
8886ce201f |
+914
-113
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
|
|
||||||
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
||||||
"gitea_cleanup_post_merge_moot_lease",
|
"gitea_cleanup_post_merge_moot_lease",
|
||||||
|
"gitea_cleanup_obsolete_reviewer_comment_lease",
|
||||||
"gitea_reconcile_merged_cleanups",
|
"gitea_reconcile_merged_cleanups",
|
||||||
"gitea_delete_branch",
|
"gitea_delete_branch",
|
||||||
"gitea_cleanup_merged_pr_branch",
|
"gitea_cleanup_merged_pr_branch",
|
||||||
|
|||||||
+657
-43
@@ -524,13 +524,26 @@ def assess_lease_inventory(
|
|||||||
"reclaimable_review_leases": reclaimable,
|
"reclaimable_review_leases": reclaimable,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Canonical next-action vocabulary for reviewer lease handoff (#599).
|
# Canonical next-action vocabulary for reviewer lease handoff (#599, #691).
|
||||||
NEXT_ACTION_ACQUIRE = "acquire"
|
NEXT_ACTION_ACQUIRE = "acquire"
|
||||||
NEXT_ACTION_WAIT = "wait"
|
NEXT_ACTION_WAIT = "wait"
|
||||||
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION = "resume_exact_owner_session"
|
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION = "resume_exact_owner_session"
|
||||||
NEXT_ACTION_RELEASE_EXPIRED_LEASE = "release_expired_lease"
|
NEXT_ACTION_RELEASE_EXPIRED_LEASE = "release_expired_lease"
|
||||||
NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP = "operator_authorized_cleanup"
|
NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP = "operator_authorized_cleanup"
|
||||||
NEXT_ACTION_REPAIR_WORKTREE_BINDING = "repair_worktree_binding"
|
NEXT_ACTION_REPAIR_WORKTREE_BINDING = "repair_worktree_binding"
|
||||||
|
NEXT_ACTION_CLEANUP_OBSOLETE_LEASE = "cleanup_obsolete_reviewer_comment_lease"
|
||||||
|
|
||||||
|
CLEANUP_OBSOLETE_LEASE_TOOL = "gitea_cleanup_obsolete_reviewer_comment_lease"
|
||||||
|
CLEANUP_OBSOLETE_LEASE_CONFIRMATION_PREFIX = "CLEANUP OBSOLETE REVIEWER LEASE "
|
||||||
|
|
||||||
|
# Formal terminal review verdicts that complete a leased-head workflow (#691).
|
||||||
|
_TERMINAL_REVIEW_VERDICTS = frozenset({
|
||||||
|
"APPROVED",
|
||||||
|
"REQUEST_CHANGES",
|
||||||
|
"approved",
|
||||||
|
"request_changes",
|
||||||
|
"REQUESTCHANGES",
|
||||||
|
})
|
||||||
|
|
||||||
_HANDOFF_CLASSIFICATIONS = frozenset({
|
_HANDOFF_CLASSIFICATIONS = frozenset({
|
||||||
"no_lease",
|
"no_lease",
|
||||||
@@ -539,6 +552,12 @@ _HANDOFF_CLASSIFICATIONS = frozenset({
|
|||||||
"foreign_active",
|
"foreign_active",
|
||||||
"foreign_reclaimable",
|
"foreign_reclaimable",
|
||||||
"foreign_expired",
|
"foreign_expired",
|
||||||
|
"foreign_active_current_head",
|
||||||
|
"foreign_expired_current_head",
|
||||||
|
"foreign_completed_superseded_head",
|
||||||
|
"foreign_expired_superseded_head",
|
||||||
|
"orphaned_owner_missing",
|
||||||
|
"ambiguous_conflicting_evidence",
|
||||||
"instructed_lease_missing_with_replacement",
|
"instructed_lease_missing_with_replacement",
|
||||||
"worktree_binding_mismatch",
|
"worktree_binding_mismatch",
|
||||||
})
|
})
|
||||||
@@ -551,6 +570,476 @@ def _norm_path(value: str | None) -> str:
|
|||||||
return os.path.normpath(text.rstrip("/"))
|
return os.path.normpath(text.rstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_verdict(value: str | None) -> str:
|
||||||
|
text = (value or "").strip().upper().replace("-", "_").replace(" ", "_")
|
||||||
|
if text in {"REQUESTCHANGES", "REQUEST_CHANGES", "CHANGES_REQUESTED"}:
|
||||||
|
return "REQUEST_CHANGES"
|
||||||
|
if text in {"APPROVED", "APPROVE"}:
|
||||||
|
return "APPROVED"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def formal_terminal_review_for_head(
|
||||||
|
formal_reviews: list[dict] | None,
|
||||||
|
*,
|
||||||
|
leased_head: str | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return the latest undismissed terminal formal review for *leased_head*."""
|
||||||
|
head = _normalize_sha(leased_head)
|
||||||
|
if not head:
|
||||||
|
return None
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for review in formal_reviews or []:
|
||||||
|
if review.get("dismissed"):
|
||||||
|
continue
|
||||||
|
verdict = _normalize_verdict(
|
||||||
|
review.get("verdict") or review.get("state") or review.get("body_state")
|
||||||
|
)
|
||||||
|
if verdict not in {"APPROVED", "REQUEST_CHANGES"}:
|
||||||
|
continue
|
||||||
|
rhead = _normalize_sha(
|
||||||
|
review.get("reviewed_head_sha")
|
||||||
|
or review.get("commit_id")
|
||||||
|
or review.get("head_sha")
|
||||||
|
)
|
||||||
|
if rhead != head:
|
||||||
|
continue
|
||||||
|
matches.append({**review, "verdict": verdict, "reviewed_head_sha": rhead})
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return matches[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def find_newest_nonterminal_lease(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
include_expired: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Newest non-terminal lease marker, optionally including expired ones (#691).
|
||||||
|
|
||||||
|
Unlike ``find_active_reviewer_lease``, this retains expired non-terminal
|
||||||
|
markers so diagnosis/cleanup can still name the obsolete lease after
|
||||||
|
``expires_at`` (comment-backed ledger; no control-plane ``lease_id``).
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
|
||||||
|
for entry in entries:
|
||||||
|
phase = (entry.get("phase") or "").strip().lower()
|
||||||
|
if phase in _TERMINAL_PHASES:
|
||||||
|
# Newest terminal ends the ledger chain for active acquisition, but
|
||||||
|
# an older non-terminal is not "active". Stop at newest marker.
|
||||||
|
return None
|
||||||
|
expired = _lease_expired(entry, now=now)
|
||||||
|
if expired and not include_expired:
|
||||||
|
return None
|
||||||
|
lease = dict(entry)
|
||||||
|
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||||
|
lease["expired"] = expired
|
||||||
|
return lease
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_confirmation_for_pr(pr_number: int) -> str:
|
||||||
|
return f"{CLEANUP_OBSOLETE_LEASE_CONFIRMATION_PREFIX}{int(pr_number)}"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
formal_reviews: list[dict] | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
expected_repo: str | None = None,
|
||||||
|
requesting_session_id: str | None = None,
|
||||||
|
controller_recovery_authorized: bool = False,
|
||||||
|
worktree_exists: bool | None = None,
|
||||||
|
worktree_clean: bool | None = None,
|
||||||
|
worktree_has_unpreserved_work: bool | None = None,
|
||||||
|
owner_process_alive: bool | None = None,
|
||||||
|
owner_pid_observed: int | None = None,
|
||||||
|
requesting_pid: int | None = None,
|
||||||
|
current_head_review_in_progress: bool = False,
|
||||||
|
expected_lease_comment_id: int | None = None,
|
||||||
|
expected_session_id: str | None = None,
|
||||||
|
expected_leased_head: str | None = None,
|
||||||
|
confirmation: str | None = None,
|
||||||
|
apply: bool = False,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Guarded cleanup eligibility for obsolete comment-backed reviewer leases (#691).
|
||||||
|
|
||||||
|
Cleanup is never transfer/adoption/repoint and never uses PID equality as
|
||||||
|
ownership. Eligible only when evidence proves the lease is past expiry and/or
|
||||||
|
pinned to a superseded head with a completed formal terminal review, and
|
||||||
|
every safety boundary holds.
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
reasons: list[str] = []
|
||||||
|
fail_closed_reasons: list[str] = []
|
||||||
|
current_head = _normalize_sha(current_head_sha)
|
||||||
|
req_session = (requesting_session_id or "").strip()
|
||||||
|
|
||||||
|
lease = find_newest_nonterminal_lease(
|
||||||
|
comments, pr_number=pr_number, now=now, include_expired=True
|
||||||
|
)
|
||||||
|
if not lease:
|
||||||
|
# Fall back to active finder (unexpired only) for report consistency.
|
||||||
|
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
|
||||||
|
classification = "no_lease"
|
||||||
|
cleanup_allowed = False
|
||||||
|
release_body: str | None = None
|
||||||
|
audit_comment_body: str | None = None
|
||||||
|
terminal_review = None
|
||||||
|
leased_head = None
|
||||||
|
head_superseded = False
|
||||||
|
past_expiry = False
|
||||||
|
expires_parseable = True
|
||||||
|
|
||||||
|
if not lease:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
f"PR #{pr_number}: no non-terminal comment-backed reviewer lease to clean"
|
||||||
|
)
|
||||||
|
classification = "no_lease"
|
||||||
|
else:
|
||||||
|
leased_head = _normalize_sha(lease.get("candidate_head"))
|
||||||
|
expires_raw = lease.get("expires_at")
|
||||||
|
expires_at = _parse_timestamp(expires_raw)
|
||||||
|
if expires_raw and expires_at is None:
|
||||||
|
expires_parseable = False
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease expires_at cannot be parsed or trusted (fail closed)"
|
||||||
|
)
|
||||||
|
past_expiry = bool(expires_at and expires_at <= now)
|
||||||
|
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
|
||||||
|
owner_session = (lease.get("session_id") or "").strip()
|
||||||
|
is_requesting_owner = bool(
|
||||||
|
req_session and owner_session and req_session == owner_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Identity pins (repo / PR / session / head / comment) must match when
|
||||||
|
# the caller supplies expected evidence.
|
||||||
|
lease_repo = (lease.get("repo") or "").strip()
|
||||||
|
exp_repo = (expected_repo or repo or "").strip()
|
||||||
|
if exp_repo and lease_repo and exp_repo != lease_repo:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
f"repository identity mismatch: lease repo={lease_repo!r} "
|
||||||
|
f"expected={exp_repo!r}"
|
||||||
|
)
|
||||||
|
if lease.get("pr_number") not in (None, pr_number):
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
f"PR identity mismatch: lease pr={lease.get('pr_number')} "
|
||||||
|
f"requested={pr_number}"
|
||||||
|
)
|
||||||
|
if expected_lease_comment_id is not None:
|
||||||
|
cid = lease.get("comment_id")
|
||||||
|
if cid is None or int(cid) != int(expected_lease_comment_id):
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease comment_id does not match expected evidence "
|
||||||
|
f"(lease={cid}, expected={expected_lease_comment_id})"
|
||||||
|
)
|
||||||
|
if expected_session_id:
|
||||||
|
if owner_session != expected_session_id.strip():
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease session_id does not match expected evidence "
|
||||||
|
f"(lease={owner_session}, expected={expected_session_id})"
|
||||||
|
)
|
||||||
|
if expected_leased_head:
|
||||||
|
exp_head = _normalize_sha(expected_leased_head)
|
||||||
|
if not exp_head or exp_head != leased_head:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"leased head does not match expected evidence "
|
||||||
|
f"(lease={leased_head}, expected={exp_head})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID equality is never ownership proof (#691).
|
||||||
|
if (
|
||||||
|
owner_pid_observed is not None
|
||||||
|
and requesting_pid is not None
|
||||||
|
and int(owner_pid_observed) == int(requesting_pid)
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
"observed PID equals requesting PID but PID equality is NOT "
|
||||||
|
"ownership proof; ignored for authorization"
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_requesting_owner:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"requesting session owns the lease; use "
|
||||||
|
"gitea_release_reviewer_pr_lease (owner path), not non-owner cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_head_review_in_progress:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"a current-head review submission is in progress; cleanup denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_head:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"current PR head SHA missing or unparseable (fail closed)"
|
||||||
|
)
|
||||||
|
if not leased_head:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease candidate_head missing or unparseable (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
head_superseded = bool(
|
||||||
|
current_head and leased_head and current_head != leased_head
|
||||||
|
)
|
||||||
|
head_matches_current = bool(
|
||||||
|
current_head and leased_head and current_head == leased_head
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal_review = formal_terminal_review_for_head(
|
||||||
|
formal_reviews, leased_head=leased_head
|
||||||
|
)
|
||||||
|
has_terminal = terminal_review is not None
|
||||||
|
|
||||||
|
# Worktree safety.
|
||||||
|
if worktree_has_unpreserved_work is True or worktree_clean is False:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease worktree is dirty or has unpreserved work; cleanup denied"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
worktree_exists is True
|
||||||
|
and worktree_clean is None
|
||||||
|
and worktree_has_unpreserved_work is None
|
||||||
|
):
|
||||||
|
# Unknown cleanliness when path exists — fail closed.
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease worktree exists but cleanliness is unknown (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classification matrix (#691).
|
||||||
|
if head_matches_current and freshness in {"active", "stale_warning"}:
|
||||||
|
classification = "foreign_active_current_head"
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"genuinely active foreign lease on the current PR head; "
|
||||||
|
"cleanup denied (fail closed)"
|
||||||
|
)
|
||||||
|
elif head_matches_current and past_expiry:
|
||||||
|
classification = "foreign_expired_current_head"
|
||||||
|
# Expired on current head: owner-missing / orphan path may apply.
|
||||||
|
if owner_process_alive is True:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease expired on current head but owner process still alive "
|
||||||
|
"with plausible activity; cleanup denied"
|
||||||
|
)
|
||||||
|
elif worktree_clean is False:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"owner process absent but worktree dirty; cleanup denied"
|
||||||
|
)
|
||||||
|
elif owner_process_alive is False and (
|
||||||
|
worktree_clean is True or worktree_exists is False
|
||||||
|
):
|
||||||
|
if controller_recovery_authorized:
|
||||||
|
classification = "orphaned_owner_missing"
|
||||||
|
else:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"controller/recovery capability not authorized for orphan cleanup"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"insufficient orphan evidence for expired current-head lease "
|
||||||
|
"(need owner_process_alive=false and clean/absent worktree)"
|
||||||
|
)
|
||||||
|
elif head_superseded and has_terminal and past_expiry:
|
||||||
|
classification = "foreign_expired_superseded_head"
|
||||||
|
elif head_superseded and has_terminal and not past_expiry:
|
||||||
|
classification = "foreign_completed_superseded_head"
|
||||||
|
elif head_superseded and not has_terminal:
|
||||||
|
classification = "ambiguous_conflicting_evidence"
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease head is superseded but no formal terminal review exists "
|
||||||
|
"for the leased head (fail closed)"
|
||||||
|
)
|
||||||
|
elif not head_superseded and not past_expiry and freshness in {
|
||||||
|
"active", "stale_warning"
|
||||||
|
}:
|
||||||
|
classification = "foreign_active_current_head"
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"foreign lease still active on current head; wait or use owner release"
|
||||||
|
)
|
||||||
|
elif past_expiry and not head_superseded:
|
||||||
|
classification = "foreign_expired_current_head"
|
||||||
|
else:
|
||||||
|
classification = "ambiguous_conflicting_evidence"
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease evidence is ambiguous; cleanup denied (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recent owner progress on a non-superseded active lease blocks cleanup.
|
||||||
|
minutes = _minutes_since_activity(lease, now=now)
|
||||||
|
if (
|
||||||
|
head_matches_current
|
||||||
|
and freshness == "active"
|
||||||
|
and minutes is not None
|
||||||
|
and minutes < STALE_WARNING_MINUTES
|
||||||
|
):
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"owner session has recent authenticated progress on current head; "
|
||||||
|
"cleanup denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authority + confirmation for apply.
|
||||||
|
if not controller_recovery_authorized:
|
||||||
|
if classification in {
|
||||||
|
"foreign_completed_superseded_head",
|
||||||
|
"foreign_expired_superseded_head",
|
||||||
|
"foreign_expired_current_head",
|
||||||
|
"orphaned_owner_missing",
|
||||||
|
}:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"controller/recovery capability required "
|
||||||
|
"(controller_recovery_authorized=true)"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_conf = cleanup_confirmation_for_pr(pr_number)
|
||||||
|
if apply:
|
||||||
|
if (confirmation or "").strip() != expected_conf:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
f"confirmation must equal exactly {expected_conf!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Superseded + terminal path does not require past_expiry (AC1).
|
||||||
|
eligible_class = classification in {
|
||||||
|
"foreign_completed_superseded_head",
|
||||||
|
"foreign_expired_superseded_head",
|
||||||
|
"orphaned_owner_missing",
|
||||||
|
"foreign_expired_current_head",
|
||||||
|
}
|
||||||
|
# foreign_expired_current_head needs orphan/clean worktree + authority.
|
||||||
|
if classification == "foreign_expired_current_head":
|
||||||
|
if worktree_clean is not True and worktree_exists is not False:
|
||||||
|
if "worktree" not in " ".join(fail_closed_reasons):
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"expired current-head cleanup requires proven-clean "
|
||||||
|
"worktree or absent worktree"
|
||||||
|
)
|
||||||
|
if owner_process_alive is True:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"owner process still alive on expired current-head lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup_allowed = (
|
||||||
|
eligible_class
|
||||||
|
and expires_parseable
|
||||||
|
and not fail_closed_reasons
|
||||||
|
and not is_requesting_owner
|
||||||
|
)
|
||||||
|
|
||||||
|
if cleanup_allowed:
|
||||||
|
release_body = format_lease_body(
|
||||||
|
repo=lease.get("repo") or exp_repo or "",
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=lease.get("issue_number"),
|
||||||
|
reviewer_identity=lease.get("reviewer_identity") or "",
|
||||||
|
profile=lease.get("profile") or "unknown",
|
||||||
|
session_id=owner_session or "unknown",
|
||||||
|
worktree=lease.get("worktree") or "",
|
||||||
|
phase="released",
|
||||||
|
candidate_head=leased_head,
|
||||||
|
target_branch=lease.get("target_branch") or "master",
|
||||||
|
target_branch_sha=lease.get("target_branch_sha"),
|
||||||
|
last_activity=now,
|
||||||
|
blocker="obsolete-superseded-or-expired-lease",
|
||||||
|
)
|
||||||
|
audit_comment_body = (
|
||||||
|
"## Canonical obsolete reviewer lease cleanup (#691)\n\n"
|
||||||
|
f"- pr: #{pr_number}\n"
|
||||||
|
f"- lease_comment_id: {lease.get('comment_id')}\n"
|
||||||
|
f"- session_id: {owner_session}\n"
|
||||||
|
f"- leased_head: {leased_head}\n"
|
||||||
|
f"- current_head: {current_head}\n"
|
||||||
|
f"- expires_at: {lease.get('expires_at')}\n"
|
||||||
|
f"- classification: {classification}\n"
|
||||||
|
f"- terminal_review_verdict: "
|
||||||
|
f"{(terminal_review or {}).get('verdict')}\n"
|
||||||
|
f"- tool: {CLEANUP_OBSOLETE_LEASE_TOOL}\n"
|
||||||
|
"- action: posted terminal phase=released lease marker; "
|
||||||
|
"did not transfer validation, decision, or workflow proof; "
|
||||||
|
"did not repoint lease to the new head; did not delete history\n"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
f"cleanup eligible ({classification}); post terminal released "
|
||||||
|
"marker via sanctioned tool"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.extend(fail_closed_reasons)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"classification": classification,
|
||||||
|
"blocker_kind": classification if not cleanup_allowed else "none",
|
||||||
|
"cleanup_allowed": cleanup_allowed,
|
||||||
|
"mutation_allowed": False, # never grants review mutation
|
||||||
|
"mutation_eligibility": "prohibited" if not cleanup_allowed else "cleanup_only",
|
||||||
|
"exact_next_action": (
|
||||||
|
NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
if cleanup_allowed
|
||||||
|
else (
|
||||||
|
NEXT_ACTION_WAIT
|
||||||
|
if classification
|
||||||
|
in {
|
||||||
|
"foreign_active_current_head",
|
||||||
|
"ambiguous_conflicting_evidence",
|
||||||
|
}
|
||||||
|
else NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"cleanup_tool": CLEANUP_OBSOLETE_LEASE_TOOL,
|
||||||
|
"required_confirmation": cleanup_confirmation_for_pr(pr_number),
|
||||||
|
"leased_head": leased_head,
|
||||||
|
"current_head": current_head,
|
||||||
|
"head_superseded": head_superseded,
|
||||||
|
"past_expiry": past_expiry,
|
||||||
|
"expires_at": (lease or {}).get("expires_at") if lease else None,
|
||||||
|
"expires_parseable": expires_parseable,
|
||||||
|
"terminal_review": terminal_review,
|
||||||
|
"terminal_review_present": terminal_review is not None,
|
||||||
|
"worktree_state": {
|
||||||
|
"path": (lease or {}).get("worktree") if lease else None,
|
||||||
|
"exists": worktree_exists,
|
||||||
|
"clean": worktree_clean,
|
||||||
|
"has_unpreserved_work": worktree_has_unpreserved_work,
|
||||||
|
},
|
||||||
|
"owner_session_evidence": {
|
||||||
|
"session_id": (lease or {}).get("session_id") if lease else None,
|
||||||
|
"reviewer_identity": (
|
||||||
|
(lease or {}).get("reviewer_identity") if lease else None
|
||||||
|
),
|
||||||
|
"process_alive": owner_process_alive,
|
||||||
|
"pid_observed": owner_pid_observed,
|
||||||
|
"pid_is_not_ownership_proof": True,
|
||||||
|
"requesting_session_is_owner": bool(
|
||||||
|
lease
|
||||||
|
and req_session
|
||||||
|
and (lease.get("session_id") or "").strip() == req_session
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"active_lease": lease,
|
||||||
|
"release_body": release_body,
|
||||||
|
"audit_comment_body": audit_comment_body,
|
||||||
|
"controller_recovery_authorized": controller_recovery_authorized,
|
||||||
|
"reasons": reasons if reasons else fail_closed_reasons,
|
||||||
|
"fail_closed_reasons": fail_closed_reasons,
|
||||||
|
"forbidden": [
|
||||||
|
"manual comment deletion",
|
||||||
|
"database edits",
|
||||||
|
"mtime manipulation",
|
||||||
|
"PID-based ownership",
|
||||||
|
"direct session-state seeding",
|
||||||
|
"lease stealing or adoption",
|
||||||
|
"repointing old lease to new head",
|
||||||
|
"transfer of validation or decision state",
|
||||||
|
"worktree reuse by replacement reviewer",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def diagnose_reviewer_pr_lease_handoff(
|
def diagnose_reviewer_pr_lease_handoff(
|
||||||
comments: list[dict],
|
comments: list[dict],
|
||||||
*,
|
*,
|
||||||
@@ -561,40 +1050,50 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
env_bound_worktree: str | None = None,
|
env_bound_worktree: str | None = None,
|
||||||
instructed_session_id: str | None = None,
|
instructed_session_id: str | None = None,
|
||||||
instructed_comment_id: int | None = None,
|
instructed_comment_id: int | None = None,
|
||||||
|
current_head_sha: str | None = None,
|
||||||
|
formal_reviews: list[dict] | None = None,
|
||||||
|
worktree_exists: bool | None = None,
|
||||||
|
worktree_clean: bool | None = None,
|
||||||
|
owner_process_alive: bool | None = None,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Classify open-PR reviewer lease handoff and emit a canonical next action (#599).
|
"""Classify open-PR reviewer lease handoff and emit a canonical next action (#599, #691).
|
||||||
|
|
||||||
Read-only diagnosis. Never steals, releases, or adopts a foreign lease.
|
Read-only diagnosis. Never steals, releases, or adopts a foreign lease.
|
||||||
Fail-closed acquisition rules for active foreign leases remain intact.
|
Fail-closed acquisition rules for active foreign leases remain intact.
|
||||||
|
|
||||||
Returns a structured diagnosis with:
|
Returns a structured diagnosis with:
|
||||||
- classification
|
- classification (including #691 superseded/expired distinctions)
|
||||||
- next_action (one of wait / resume_exact_owner_session /
|
- next_action (including cleanup_obsolete_reviewer_comment_lease)
|
||||||
release_expired_lease / operator_authorized_cleanup /
|
|
||||||
repair_worktree_binding / acquire)
|
|
||||||
- active_lease identity fields when present
|
- active_lease identity fields when present
|
||||||
- worktree_binding match result
|
- worktree_binding match result
|
||||||
- instructed-lease mismatch flags
|
- instructed-lease mismatch flags
|
||||||
|
- cleanup tool/confirmation when cleanup-eligible
|
||||||
"""
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
session_id = (current_session_id or "").strip()
|
session_id = (current_session_id or "").strip()
|
||||||
identity = (current_reviewer_identity or "").strip()
|
identity = (current_reviewer_identity or "").strip()
|
||||||
instructed_sid = (instructed_session_id or "").strip()
|
instructed_sid = (instructed_session_id or "").strip()
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
|
current_head = _normalize_sha(current_head_sha)
|
||||||
|
|
||||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
# Also surface expired non-terminal newest marker for #691 diagnosis.
|
||||||
|
newest_nt = find_newest_nonterminal_lease(
|
||||||
|
comments, pr_number=pr_number, now=now, include_expired=True
|
||||||
|
)
|
||||||
|
lease_for_class = active or newest_nt
|
||||||
session_lease = get_session_lease()
|
session_lease = get_session_lease()
|
||||||
|
|
||||||
# Worktree binding: env-bound vs proposed vs active lease worktree.
|
# Worktree binding: env-bound vs proposed vs active lease worktree.
|
||||||
env_wt = _norm_path(env_bound_worktree)
|
env_wt = _norm_path(env_bound_worktree)
|
||||||
prop_wt = _norm_path(proposed_worktree)
|
prop_wt = _norm_path(proposed_worktree)
|
||||||
lease_wt = _norm_path((active or {}).get("worktree") if active else None)
|
lease_wt = _norm_path((lease_for_class or {}).get("worktree") if lease_for_class else None)
|
||||||
binding_mismatch = False
|
binding_mismatch = False
|
||||||
binding_details: dict[str, Any] = {
|
binding_details: dict[str, Any] = {
|
||||||
"env_bound_worktree": env_bound_worktree or None,
|
"env_bound_worktree": env_bound_worktree or None,
|
||||||
"proposed_worktree": proposed_worktree or None,
|
"proposed_worktree": proposed_worktree or None,
|
||||||
"lease_worktree": (active or {}).get("worktree") if active else None,
|
"lease_worktree": (lease_for_class or {}).get("worktree") if lease_for_class else None,
|
||||||
"match": True,
|
"match": True,
|
||||||
}
|
}
|
||||||
paths = [p for p in (env_wt, prop_wt, lease_wt) if p]
|
paths = [p for p in (env_wt, prop_wt, lease_wt) if p]
|
||||||
@@ -608,13 +1107,13 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
# Instructed lease gone while a different lease is active (PR #592-style).
|
# Instructed lease gone while a different lease is active (PR #592-style).
|
||||||
instructed_missing_with_replacement = False
|
instructed_missing_with_replacement = False
|
||||||
if instructed_sid or instructed_comment_id is not None:
|
if instructed_sid or instructed_comment_id is not None:
|
||||||
if not active:
|
if not lease_for_class:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"instructed lease is gone and no active replacement lease remains"
|
"instructed lease is gone and no active replacement lease remains"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
owner = (active.get("session_id") or "").strip()
|
owner = (lease_for_class.get("session_id") or "").strip()
|
||||||
cid = active.get("comment_id")
|
cid = lease_for_class.get("comment_id")
|
||||||
sid_mismatch = bool(instructed_sid and owner and owner != instructed_sid)
|
sid_mismatch = bool(instructed_sid and owner and owner != instructed_sid)
|
||||||
cid_mismatch = (
|
cid_mismatch = (
|
||||||
instructed_comment_id is not None
|
instructed_comment_id is not None
|
||||||
@@ -631,35 +1130,94 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
# Classification + next_action.
|
# Classification + next_action.
|
||||||
classification = "no_lease"
|
classification = "no_lease"
|
||||||
next_action = NEXT_ACTION_ACQUIRE
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
cleanup_hint: dict[str, Any] | None = None
|
||||||
|
|
||||||
if active:
|
if lease_for_class:
|
||||||
owner = (active.get("session_id") or "").strip()
|
owner = (lease_for_class.get("session_id") or "").strip()
|
||||||
freshness = active.get("freshness") or classify_lease_freshness(
|
freshness = lease_for_class.get("freshness") or classify_lease_freshness(
|
||||||
active, now=now
|
lease_for_class, now=now
|
||||||
)
|
)
|
||||||
owner_identity = (active.get("reviewer_identity") or "").strip()
|
owner_identity = (lease_for_class.get("reviewer_identity") or "").strip()
|
||||||
is_own = bool(session_id and owner and owner == session_id)
|
is_own = bool(session_id and owner and owner == session_id)
|
||||||
# Same identity alone is NOT ownership for resume; session_id must match.
|
# Same identity alone is NOT ownership for resume; session_id must match.
|
||||||
same_identity = bool(
|
same_identity = bool(
|
||||||
identity and owner_identity and identity == owner_identity
|
identity and owner_identity and identity == owner_identity
|
||||||
)
|
)
|
||||||
|
leased_head = _normalize_sha(lease_for_class.get("candidate_head"))
|
||||||
|
head_superseded = bool(
|
||||||
|
current_head and leased_head and current_head != leased_head
|
||||||
|
)
|
||||||
|
head_current = bool(
|
||||||
|
current_head and leased_head and current_head == leased_head
|
||||||
|
)
|
||||||
|
past_expiry = bool(lease_for_class.get("expired")) or freshness == "expired"
|
||||||
|
if not past_expiry:
|
||||||
|
past_expiry = _lease_expired(lease_for_class, now=now)
|
||||||
|
terminal = formal_terminal_review_for_head(
|
||||||
|
formal_reviews, leased_head=leased_head
|
||||||
|
)
|
||||||
|
|
||||||
if is_own and freshness in {"active", "stale_warning"}:
|
if is_own and freshness in {"active", "stale_warning"} and not past_expiry:
|
||||||
classification = "own_active"
|
classification = "own_active"
|
||||||
next_action = NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
next_action = NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
elif is_own and freshness in {"reclaimable", "expired"}:
|
elif is_own and (freshness in {"reclaimable", "expired"} or past_expiry):
|
||||||
classification = "own_expired"
|
classification = "own_expired"
|
||||||
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"own lease freshness is '{freshness}'; release via "
|
f"own lease freshness is '{freshness}'; release via "
|
||||||
"gitea_release_reviewer_pr_lease then re-acquire"
|
"gitea_release_reviewer_pr_lease then re-acquire"
|
||||||
)
|
)
|
||||||
elif not is_own and freshness in {"active", "stale_warning"}:
|
elif not is_own and head_superseded and terminal and past_expiry:
|
||||||
classification = "foreign_active"
|
classification = "foreign_expired_superseded_head"
|
||||||
|
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
reasons.append(
|
||||||
|
"foreign lease expired and pinned to superseded head with "
|
||||||
|
"formal terminal review; use guarded obsolete-lease cleanup"
|
||||||
|
)
|
||||||
|
elif not is_own and head_superseded and terminal and not past_expiry:
|
||||||
|
classification = "foreign_completed_superseded_head"
|
||||||
|
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
reasons.append(
|
||||||
|
"foreign lease pinned to superseded head with completed formal "
|
||||||
|
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
|
||||||
|
)
|
||||||
|
elif not is_own and head_superseded and not terminal:
|
||||||
|
classification = "ambiguous_conflicting_evidence"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
"lease head superseded but no formal terminal review for leased "
|
||||||
|
"head; fail closed / wait (no indefinite steal)"
|
||||||
|
)
|
||||||
|
elif not is_own and head_current and past_expiry:
|
||||||
|
classification = "foreign_expired_current_head"
|
||||||
|
if owner_process_alive is False and worktree_clean is True:
|
||||||
|
classification = "orphaned_owner_missing"
|
||||||
|
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
reasons.append(
|
||||||
|
"foreign expired lease on current head; owner process absent "
|
||||||
|
"and worktree clean — guarded orphan cleanup eligible"
|
||||||
|
)
|
||||||
|
elif owner_process_alive is False and worktree_clean is False:
|
||||||
|
classification = "ambiguous_conflicting_evidence"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
"owner process absent but worktree dirty; cleanup denied"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
reasons.append(
|
||||||
|
"foreign expired lease on current head; use guarded cleanup "
|
||||||
|
"when worktree/process evidence permits"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness in {"active", "stale_warning"} and not past_expiry:
|
||||||
|
classification = (
|
||||||
|
"foreign_active_current_head" if head_current or not current_head
|
||||||
|
else "foreign_active"
|
||||||
|
)
|
||||||
next_action = NEXT_ACTION_WAIT
|
next_action = NEXT_ACTION_WAIT
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"foreign active reviewer lease (session_id={owner}, "
|
f"foreign active reviewer lease (session_id={owner}, "
|
||||||
f"phase={active.get('phase')}, freshness={freshness}); "
|
f"phase={lease_for_class.get('phase')}, freshness={freshness}); "
|
||||||
"do not submit; do not steal"
|
"do not submit; do not steal"
|
||||||
)
|
)
|
||||||
if same_identity:
|
if same_identity:
|
||||||
@@ -674,11 +1232,12 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
f"foreign reclaimable lease (session_id={owner}); clear only via "
|
f"foreign reclaimable lease (session_id={owner}); clear only via "
|
||||||
"sanctioned gitea_release_reviewer_pr_lease when reclaimable"
|
"sanctioned gitea_release_reviewer_pr_lease when reclaimable"
|
||||||
)
|
)
|
||||||
elif not is_own and freshness == "expired":
|
elif not is_own and (freshness == "expired" or past_expiry):
|
||||||
classification = "foreign_expired"
|
classification = "foreign_expired"
|
||||||
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"foreign expired lease (session_id={owner}); use sanctioned release"
|
f"foreign expired lease (session_id={owner}); use sanctioned release "
|
||||||
|
f"or {CLEANUP_OBSOLETE_LEASE_TOOL}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
classification = "foreign_active"
|
classification = "foreign_active"
|
||||||
@@ -691,13 +1250,26 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
if instructed_missing_with_replacement and classification.startswith(
|
if instructed_missing_with_replacement and classification.startswith(
|
||||||
"foreign"
|
"foreign"
|
||||||
):
|
):
|
||||||
classification = "instructed_lease_missing_with_replacement"
|
# Preserve #691 cleanup path when superseded/expired; otherwise
|
||||||
# Foreign active still means wait; reclaimable still release.
|
# keep the PR #592 instructed-missing label.
|
||||||
if next_action == NEXT_ACTION_WAIT:
|
if next_action != NEXT_ACTION_CLEANUP_OBSOLETE_LEASE:
|
||||||
reasons.append(
|
classification = "instructed_lease_missing_with_replacement"
|
||||||
"replacement foreign lease is active — wait; "
|
if next_action == NEXT_ACTION_WAIT:
|
||||||
"operator_authorized_cleanup only with explicit operator authority"
|
reasons.append(
|
||||||
)
|
"replacement foreign lease is active — wait; "
|
||||||
|
"operator_authorized_cleanup only with explicit operator authority"
|
||||||
|
)
|
||||||
|
|
||||||
|
if next_action == NEXT_ACTION_CLEANUP_OBSOLETE_LEASE:
|
||||||
|
cleanup_hint = {
|
||||||
|
"cleanup_tool": CLEANUP_OBSOLETE_LEASE_TOOL,
|
||||||
|
"required_confirmation": cleanup_confirmation_for_pr(pr_number),
|
||||||
|
"controller_recovery_authorized_required": True,
|
||||||
|
"leased_head": leased_head,
|
||||||
|
"current_head": current_head,
|
||||||
|
"expires_at": lease_for_class.get("expires_at"),
|
||||||
|
"terminal_review_verdict": (terminal or {}).get("verdict"),
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
classification = "no_lease"
|
classification = "no_lease"
|
||||||
next_action = NEXT_ACTION_ACQUIRE
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
@@ -720,29 +1292,55 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
)
|
)
|
||||||
|
|
||||||
lease_summary = None
|
lease_summary = None
|
||||||
if active:
|
if lease_for_class:
|
||||||
lease_summary = {
|
lease_summary = {
|
||||||
"comment_id": active.get("comment_id"),
|
"comment_id": lease_for_class.get("comment_id"),
|
||||||
"session_id": active.get("session_id"),
|
"session_id": lease_for_class.get("session_id"),
|
||||||
"phase": active.get("phase"),
|
"phase": lease_for_class.get("phase"),
|
||||||
"candidate_head": active.get("candidate_head"),
|
"candidate_head": lease_for_class.get("candidate_head"),
|
||||||
"expires_at": active.get("expires_at"),
|
"expires_at": lease_for_class.get("expires_at"),
|
||||||
"last_activity": active.get("last_activity"),
|
"last_activity": lease_for_class.get("last_activity"),
|
||||||
"freshness": active.get("freshness")
|
"freshness": lease_for_class.get("freshness")
|
||||||
or classify_lease_freshness(active, now=now),
|
or classify_lease_freshness(lease_for_class, now=now),
|
||||||
"reviewer_identity": active.get("reviewer_identity"),
|
"reviewer_identity": lease_for_class.get("reviewer_identity"),
|
||||||
"profile": active.get("profile"),
|
"profile": lease_for_class.get("profile"),
|
||||||
"worktree": active.get("worktree"),
|
"worktree": lease_for_class.get("worktree"),
|
||||||
"blocker": active.get("blocker"),
|
"blocker": lease_for_class.get("blocker"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"classification": classification,
|
"classification": classification,
|
||||||
|
"blocker_kind": classification,
|
||||||
"next_action": next_action,
|
"next_action": next_action,
|
||||||
|
"exact_next_action": next_action,
|
||||||
"active_lease": lease_summary,
|
"active_lease": lease_summary,
|
||||||
"session_lease": session_lease,
|
"session_lease": session_lease,
|
||||||
"worktree_binding": binding_details,
|
"worktree_binding": binding_details,
|
||||||
|
"leased_head": (lease_summary or {}).get("candidate_head"),
|
||||||
|
"current_head": current_head,
|
||||||
|
"expires_at": (lease_summary or {}).get("expires_at"),
|
||||||
|
"terminal_review_state": (
|
||||||
|
formal_terminal_review_for_head(
|
||||||
|
formal_reviews,
|
||||||
|
leased_head=(lease_summary or {}).get("candidate_head"),
|
||||||
|
)
|
||||||
|
if lease_summary
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"worktree_state": {
|
||||||
|
"exists": worktree_exists,
|
||||||
|
"clean": worktree_clean,
|
||||||
|
"path": (lease_summary or {}).get("worktree"),
|
||||||
|
},
|
||||||
|
"owner_session_evidence": {
|
||||||
|
"session_id": (lease_summary or {}).get("session_id"),
|
||||||
|
"process_alive": owner_process_alive,
|
||||||
|
"pid_is_not_ownership_proof": True,
|
||||||
|
},
|
||||||
|
"cleanup": cleanup_hint,
|
||||||
|
"cleanup_tool": (cleanup_hint or {}).get("cleanup_tool"),
|
||||||
|
"required_confirmation": (cleanup_hint or {}).get("required_confirmation"),
|
||||||
"instructed_session_id": instructed_session_id,
|
"instructed_session_id": instructed_session_id,
|
||||||
"instructed_comment_id": instructed_comment_id,
|
"instructed_comment_id": instructed_comment_id,
|
||||||
"instructed_lease_missing_with_replacement": instructed_missing_with_replacement,
|
"instructed_lease_missing_with_replacement": instructed_missing_with_replacement,
|
||||||
@@ -751,6 +1349,19 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
and not binding_mismatch
|
and not binding_mismatch
|
||||||
and bool(session_lease)
|
and bool(session_lease)
|
||||||
),
|
),
|
||||||
|
"mutation_eligibility": (
|
||||||
|
"allowed"
|
||||||
|
if (
|
||||||
|
next_action == NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
and not binding_mismatch
|
||||||
|
and bool(session_lease)
|
||||||
|
)
|
||||||
|
else (
|
||||||
|
"cleanup_only"
|
||||||
|
if next_action == NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
else "prohibited"
|
||||||
|
)
|
||||||
|
),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"forbidden": [
|
"forbidden": [
|
||||||
"manual lock deletion",
|
"manual lock deletion",
|
||||||
@@ -758,5 +1369,8 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
"mtime manipulation",
|
"mtime manipulation",
|
||||||
"direct _SESSION_LEASE seeding",
|
"direct _SESSION_LEASE seeding",
|
||||||
"silent foreign lease steal",
|
"silent foreign lease steal",
|
||||||
|
"PID-based ownership",
|
||||||
|
"repointing old lease to new head",
|
||||||
|
"transfer of validation or decision state",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ _READONLY_REVIEWER_GIT = re.compile(
|
|||||||
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# #673: Canonical pattern for identifying review worktrees under branches/.
|
||||||
|
REVIEW_WORKTREE_RE = re.compile(
|
||||||
|
r"branches/(?:review-pr\d+[\w/-]*|merge-simulation-pr\d+|review-[\w-]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_review_worktree_path(path: str) -> bool:
|
||||||
|
"""True when the path belongs to a reviewer or simulation worktree."""
|
||||||
|
normalized = (path or "").replace("\\", "/")
|
||||||
|
return bool(REVIEW_WORKTREE_RE.search(normalized))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
||||||
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SESSION_OWNED_RE = re.compile(
|
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
|
||||||
)
|
|
||||||
_WORKTREE_PATH_RE = re.compile(
|
_WORKTREE_PATH_RE = re.compile(
|
||||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|||||||
+457
-13
@@ -1,4 +1,4 @@
|
|||||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
||||||
|
|
||||||
#332 correctly hard-stops a reviewer session after a terminal live review
|
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||||
mutation. After #559 those locks are durable on disk, so they can outlive the
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||||
@@ -12,6 +12,11 @@ on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
|||||||
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
||||||
(#594); new-head re-review is allowed without deleting the durable lock.
|
(#594); new-head re-review is allowed without deleting the durable lock.
|
||||||
|
|
||||||
|
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
|
||||||
|
must not block a fresh formal decision on open PR B on the same durable
|
||||||
|
profile lock (cross-PR isolation). Correction authorization is scoped to the
|
||||||
|
named prior review's PR/head and cannot unlock a different PR.
|
||||||
|
|
||||||
This module is pure policy (no Gitea I/O). The MCP tool
|
This module is pure policy (no Gitea I/O). The MCP tool
|
||||||
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||||
helpers, and only then clears durable state when cleanup is allowed.
|
helpers, and only then clears durable state when cleanup is allowed.
|
||||||
@@ -80,17 +85,45 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
|||||||
return terminals[-1] if terminals else None
|
return terminals[-1] if terminals else None
|
||||||
|
|
||||||
|
|
||||||
|
def correction_applies(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int | None = None,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when operator correction is active and scoped to the target (#693).
|
||||||
|
|
||||||
|
Global ``correction_authorized`` alone is not enough: correction must name
|
||||||
|
the same PR (and head when recorded) as the decision being re-opened.
|
||||||
|
Missing scope fields on legacy locks fail closed (do not apply).
|
||||||
|
"""
|
||||||
|
if not lock or not lock.get("correction_authorized"):
|
||||||
|
return False
|
||||||
|
corr_pr = lock.get("correction_pr_number")
|
||||||
|
if corr_pr is None:
|
||||||
|
# Legacy unscoped correction — refuse as generic unlock (#693).
|
||||||
|
return False
|
||||||
|
if pr_number is not None and int(corr_pr) != int(pr_number):
|
||||||
|
return False
|
||||||
|
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
|
||||||
|
target = normalize_head_sha(expected_head_sha)
|
||||||
|
if corr_head and target and not heads_equal(corr_head, target):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def prior_live_mutations_block_boundary(
|
def prior_live_mutations_block_boundary(
|
||||||
lock: dict | None,
|
lock: dict | None,
|
||||||
*,
|
*,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
expected_head_sha: str | None,
|
expected_head_sha: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when prior live mutations block a new decision for PR+head (#620).
|
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
||||||
|
|
||||||
Historical terminals on a **different head of the same PR** do not block.
|
Historical terminals on a **different head of the same PR** do not block.
|
||||||
Any prior mutation on another PR, the same head, or without a comparable
|
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
||||||
head SHA fails closed (blocks).
|
Same PR + same head (or same PR without a comparable head SHA) blocks
|
||||||
|
unless a scoped correction applies.
|
||||||
|
|
||||||
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||||
that only stored ``ready_expected_head_sha`` still compare correctly
|
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||||
@@ -98,7 +131,9 @@ def prior_live_mutations_block_boundary(
|
|||||||
"""
|
"""
|
||||||
if not lock:
|
if not lock:
|
||||||
return False
|
return False
|
||||||
if lock.get("correction_authorized"):
|
if correction_applies(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
prior = list(lock.get("live_mutations") or [])
|
prior = list(lock.get("live_mutations") or [])
|
||||||
if not prior:
|
if not prior:
|
||||||
@@ -108,10 +143,14 @@ def prior_live_mutations_block_boundary(
|
|||||||
if not isinstance(m, dict):
|
if not isinstance(m, dict):
|
||||||
return True
|
return True
|
||||||
m_pr = m.get("pr_number")
|
m_pr = m.get("pr_number")
|
||||||
|
if m_pr is None:
|
||||||
|
return True # fail closed — unscoped mutation
|
||||||
|
if int(m_pr) != int(pr_number):
|
||||||
|
# #693: foreign-PR history on the shared durable lock does not block.
|
||||||
|
continue
|
||||||
m_head = mutation_head_sha(m, lock)
|
m_head = mutation_head_sha(m, lock)
|
||||||
if (
|
if (
|
||||||
m_pr == pr_number
|
target
|
||||||
and target
|
|
||||||
and m_head
|
and m_head
|
||||||
and not heads_equal(m_head, target)
|
and not heads_equal(m_head, target)
|
||||||
):
|
):
|
||||||
@@ -128,23 +167,34 @@ def terminal_boundary_allows_fresh_decision(
|
|||||||
expected_head_sha: str | None,
|
expected_head_sha: str | None,
|
||||||
operation: str,
|
operation: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
||||||
|
|
||||||
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
For ``mark_ready`` / ``review`` / ``resume``:
|
||||||
the requested head differs from the last terminal's head. Merge is never
|
|
||||||
reopened by head change (approved head merge path is separate).
|
* **same PR, different head** — allowed (#620)
|
||||||
|
* **different PR than last terminal** — allowed (#693 cross-PR isolation)
|
||||||
|
* **same PR, same head** — not allowed (unless scoped correction)
|
||||||
|
|
||||||
|
Merge is never reopened by head change or cross-PR isolation (approved
|
||||||
|
head merge path is separate).
|
||||||
"""
|
"""
|
||||||
if operation not in ("mark_ready", "review", "resume"):
|
if operation not in ("mark_ready", "review", "resume"):
|
||||||
return False
|
return False
|
||||||
if not lock:
|
if not lock:
|
||||||
return False
|
return False
|
||||||
if lock.get("correction_authorized"):
|
if correction_applies(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
last = last_terminal_mutation(lock)
|
last = last_terminal_mutation(lock)
|
||||||
if last is None:
|
if last is None:
|
||||||
return True
|
return True
|
||||||
if last.get("pr_number") != pr_number:
|
last_pr = last.get("pr_number")
|
||||||
|
if last_pr is None:
|
||||||
return False
|
return False
|
||||||
|
if int(last_pr) != int(pr_number):
|
||||||
|
# #693: last terminal belongs to another PR — do not hard-stop this PR.
|
||||||
|
return True
|
||||||
locked_head = mutation_head_sha(last, lock)
|
locked_head = mutation_head_sha(last, lock)
|
||||||
target = normalize_head_sha(expected_head_sha)
|
target = normalize_head_sha(expected_head_sha)
|
||||||
if not locked_head or not target:
|
if not locked_head or not target:
|
||||||
@@ -438,3 +488,397 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|||||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# --- #693 classification / recovery / correction audit -----------------------
|
||||||
|
|
||||||
|
# Deterministic classification labels for diagnostics and recovery routing.
|
||||||
|
CLASS_NO_LOCK = "no_lock"
|
||||||
|
CLASS_READY_FOR_TARGET = "ready_for_target"
|
||||||
|
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
|
||||||
|
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
|
||||||
|
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
|
||||||
|
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
|
||||||
|
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
|
||||||
|
CLASS_CORRECTION_ACTIVE = "correction_active"
|
||||||
|
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
|
||||||
|
CLASS_AMBIGUOUS = "ambiguous"
|
||||||
|
|
||||||
|
|
||||||
|
def classify_review_decision_lock(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
target_pr_number: int | None = None,
|
||||||
|
target_head_sha: str | None = None,
|
||||||
|
pr_live: dict | None = None,
|
||||||
|
pr_lookup_error: str | None = None,
|
||||||
|
active_profile_identity: str | None = None,
|
||||||
|
session_blockers: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Deterministic review-decision-lock classification (#693).
|
||||||
|
|
||||||
|
Returns classification, exact next_action, owner/evidence fields, and
|
||||||
|
whether mark_final / cleanup / correction are appropriate.
|
||||||
|
"""
|
||||||
|
summary = lock_summary(lock)
|
||||||
|
target_head = normalize_head_sha(target_head_sha)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"classification": CLASS_NO_LOCK,
|
||||||
|
"has_lock": lock is not None,
|
||||||
|
"lock_summary": summary,
|
||||||
|
"target_pr_number": target_pr_number,
|
||||||
|
"target_head_sha": target_head,
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"locked_head_sha": None,
|
||||||
|
"owner_profile_identity": (summary or {}).get("profile_identity"),
|
||||||
|
"session_profile": (summary or {}).get("session_profile"),
|
||||||
|
"updated_at": (summary or {}).get("updated_at"),
|
||||||
|
"correction_authorized": bool((lock or {}).get("correction_authorized")),
|
||||||
|
"correction_pr_number": (lock or {}).get("correction_pr_number"),
|
||||||
|
"correction_head_sha": normalize_head_sha(
|
||||||
|
(lock or {}).get("correction_head_sha")
|
||||||
|
),
|
||||||
|
"mark_final_allowed": True,
|
||||||
|
"cleanup_allowed": False,
|
||||||
|
"correction_eligible": False,
|
||||||
|
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if lock is None:
|
||||||
|
result["reasons"].append("no review decision lock present")
|
||||||
|
return result
|
||||||
|
|
||||||
|
session_blockers = list(session_blockers or [])
|
||||||
|
if session_blockers:
|
||||||
|
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||||
|
result["mark_final_allowed"] = False
|
||||||
|
result["next_action"] = (
|
||||||
|
"reconnect matching prgs-reviewer session or wait for lock TTL; "
|
||||||
|
"do not use gitea_authorize_review_correction as unlock; "
|
||||||
|
"do not delete session-state files"
|
||||||
|
)
|
||||||
|
result["reasons"].extend(session_blockers)
|
||||||
|
return result
|
||||||
|
|
||||||
|
stored = (
|
||||||
|
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
active = (active_profile_identity or "").strip()
|
||||||
|
if active and stored and active != stored:
|
||||||
|
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||||
|
result["mark_final_allowed"] = False
|
||||||
|
result["next_action"] = (
|
||||||
|
f"switch to profile '{stored}' or wait for moot cleanup; "
|
||||||
|
"do not use correction authorization as a generic unlock"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"lock profile '{stored}' does not match active '{active}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
ready_pr = lock.get("ready_pr_number")
|
||||||
|
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||||
|
if (
|
||||||
|
target_pr_number is not None
|
||||||
|
and ready_pr == target_pr_number
|
||||||
|
and ready_head
|
||||||
|
and target_head
|
||||||
|
and heads_equal(ready_head, target_head)
|
||||||
|
and lock.get("final_review_decision_ready")
|
||||||
|
):
|
||||||
|
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
|
||||||
|
result["mark_final_allowed"] = True # idempotent re-mark
|
||||||
|
result["next_action"] = (
|
||||||
|
"gitea_submit_pr_review with final_review_decision_ready=true "
|
||||||
|
"for the ready PR/head"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
"final decision already marked ready for this PR/head (idempotent)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["classification"] = CLASS_READY_FOR_TARGET
|
||||||
|
result["next_action"] = "gitea_mark_final_review_decision then submit"
|
||||||
|
result["reasons"].append("no terminal live mutations; mark_final allowed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
last_pr = last.get("pr_number")
|
||||||
|
last_action = last.get("action")
|
||||||
|
locked_head = mutation_head_sha(last, lock)
|
||||||
|
result["last_terminal_pr"] = last_pr
|
||||||
|
result["last_terminal_action"] = last_action
|
||||||
|
result["locked_head_sha"] = locked_head
|
||||||
|
result["evidence"] = {
|
||||||
|
"last_review_id": last.get("review_id"),
|
||||||
|
"live_mutations_count": len(lock.get("live_mutations") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
if correction_applies(
|
||||||
|
lock, pr_number=target_pr_number, expected_head_sha=target_head
|
||||||
|
):
|
||||||
|
result["classification"] = CLASS_CORRECTION_ACTIVE
|
||||||
|
result["mark_final_allowed"] = True
|
||||||
|
result["next_action"] = (
|
||||||
|
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
|
||||||
|
"then submit once"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Live state of last terminal PR when provided.
|
||||||
|
live = None
|
||||||
|
if pr_lookup_error:
|
||||||
|
result["classification"] = CLASS_AMBIGUOUS
|
||||||
|
result["mark_final_allowed"] = False
|
||||||
|
result["next_action"] = (
|
||||||
|
"retry diagnosis after live PR state is available; "
|
||||||
|
"fail closed under ambiguous evidence"
|
||||||
|
)
|
||||||
|
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
|
||||||
|
return result
|
||||||
|
if pr_live is not None:
|
||||||
|
live = classify_pr_live_state(pr_live)
|
||||||
|
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
|
||||||
|
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
|
||||||
|
if live.get("pr_merged_or_closed"):
|
||||||
|
result["classification"] = CLASS_MOOT_MERGED_CLOSED
|
||||||
|
result["cleanup_allowed"] = True
|
||||||
|
result["mark_final_allowed"] = target_pr_number is not None and (
|
||||||
|
int(last_pr) != int(target_pr_number)
|
||||||
|
if last_pr is not None and target_pr_number is not None
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
result["next_action"] = (
|
||||||
|
"gitea_cleanup_stale_review_decision_lock(apply=true, "
|
||||||
|
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if target_pr_number is None:
|
||||||
|
result["classification"] = CLASS_AMBIGUOUS
|
||||||
|
result["mark_final_allowed"] = False
|
||||||
|
result["next_action"] = "re-run diagnosis with target_pr_number"
|
||||||
|
result["reasons"].append("target_pr_number required for open-PR classification")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if last_pr is not None and int(last_pr) != int(target_pr_number):
|
||||||
|
# Cross-PR isolation: foreign terminal is history, not a block.
|
||||||
|
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
|
||||||
|
result["mark_final_allowed"] = True
|
||||||
|
result["correction_eligible"] = False # must not use correction to "unlock"
|
||||||
|
result["next_action"] = (
|
||||||
|
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
|
||||||
|
f"— foreign terminal on PR #{last_pr} does not block (#693); "
|
||||||
|
"do not call gitea_authorize_review_correction for cross-PR unlock"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
|
||||||
|
f"target PR #{target_pr_number} is isolated (#693)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Same PR as target.
|
||||||
|
if (
|
||||||
|
locked_head
|
||||||
|
and target_head
|
||||||
|
and not heads_equal(locked_head, target_head)
|
||||||
|
):
|
||||||
|
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
|
||||||
|
result["mark_final_allowed"] = True
|
||||||
|
result["next_action"] = (
|
||||||
|
f"gitea_mark_final_review_decision with expected_head_sha="
|
||||||
|
f"{target_head} (#620 same-PR new head)"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal {last_action} on head {locked_head[:12]}…; target head "
|
||||||
|
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Same PR + same head (or missing head comparison) — active terminal.
|
||||||
|
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
|
||||||
|
result["mark_final_allowed"] = False
|
||||||
|
result["correction_eligible"] = True
|
||||||
|
result["next_action"] = (
|
||||||
|
"if the prior verdict was mistaken: gitea_authorize_review_correction "
|
||||||
|
f"with prior_review_id={last.get('review_id')}, "
|
||||||
|
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
|
||||||
|
"operator_authorized=true, then re-mark once; "
|
||||||
|
"if the prior verdict is correct: stop and hand off (do not duplicate)"
|
||||||
|
)
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal {last_action} already recorded for PR #{target_pr_number} "
|
||||||
|
f"at this head; same-head duplicate blocked (#332/#620)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_precise_mark_final_failure(
|
||||||
|
classification: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
|
||||||
|
cls = classification.get("classification")
|
||||||
|
next_action = classification.get("next_action") or (
|
||||||
|
"stop and create a durable Gitea issue with lock evidence"
|
||||||
|
)
|
||||||
|
reasons = list(classification.get("reasons") or [])
|
||||||
|
reasons.append(f"exact_next_action: {next_action}")
|
||||||
|
handoff = {
|
||||||
|
"required": cls
|
||||||
|
in (
|
||||||
|
CLASS_AMBIGUOUS,
|
||||||
|
CLASS_SESSION_OR_PROFILE_MISMATCH,
|
||||||
|
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
|
||||||
|
)
|
||||||
|
and not classification.get("mark_final_allowed"),
|
||||||
|
"title_hint": (
|
||||||
|
"Review-decision-lock recovery failed during formal review"
|
||||||
|
),
|
||||||
|
"classification": cls,
|
||||||
|
"exact_next_action": next_action,
|
||||||
|
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||||
|
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||||
|
"last_terminal_action": classification.get("last_terminal_action"),
|
||||||
|
"locked_head_sha": classification.get("locked_head_sha"),
|
||||||
|
"target_pr_number": classification.get("target_pr_number"),
|
||||||
|
"target_head_sha": classification.get("target_head_sha"),
|
||||||
|
"evidence": classification.get("evidence") or {},
|
||||||
|
"instruction": (
|
||||||
|
"If recovery cannot complete with the exact_next_action above, "
|
||||||
|
"create or update a durable Gitea issue with this payload and stop; "
|
||||||
|
"do not delete session-state files; do not misuse correction as unlock."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"reasons": reasons,
|
||||||
|
"classification": cls,
|
||||||
|
"exact_next_action": next_action,
|
||||||
|
"durable_issue_handoff": handoff,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_correction_audit_record(
|
||||||
|
*,
|
||||||
|
prior_review_id: int | None,
|
||||||
|
prior_review_state: str | None,
|
||||||
|
target_pr_number: int | None,
|
||||||
|
target_head_sha: str | None,
|
||||||
|
reason: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
authorized: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured durable audit for review-correction authorization (#693)."""
|
||||||
|
return {
|
||||||
|
"event": "review_correction_authorization",
|
||||||
|
"issue_ref": "#693",
|
||||||
|
"authorized": bool(authorized),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"prior_review_id": prior_review_id,
|
||||||
|
"prior_review_state": prior_review_state,
|
||||||
|
"target_pr_number": target_pr_number,
|
||||||
|
"target_head_sha": normalize_head_sha(target_head_sha),
|
||||||
|
"reason": reason,
|
||||||
|
"scope": "same_pr_head_only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for a thread-visible correction authorization audit."""
|
||||||
|
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
|
||||||
|
lines = [
|
||||||
|
"## Review correction authorization audit (#693)",
|
||||||
|
"",
|
||||||
|
f"Status: **{status}**",
|
||||||
|
"",
|
||||||
|
f"- actor: `{audit.get('actor_username')}`",
|
||||||
|
f"- profile: `{audit.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{audit.get('timestamp')}`",
|
||||||
|
f"- prior_review_id: `{audit.get('prior_review_id')}`",
|
||||||
|
f"- prior_review_state: `{audit.get('prior_review_state')}`",
|
||||||
|
f"- target_pr: `#{audit.get('target_pr_number')}`",
|
||||||
|
f"- target_head_sha: `{audit.get('target_head_sha')}`",
|
||||||
|
f"- reason: {audit.get('reason')}",
|
||||||
|
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
|
||||||
|
"",
|
||||||
|
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
|
||||||
|
"uses isolation (#693) or moot cleanup (#594), never correction.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_open_pr_decision_lock_recovery(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
target_pr_number: int,
|
||||||
|
target_head_sha: str | None,
|
||||||
|
last_terminal_pr_live: dict | None = None,
|
||||||
|
pr_lookup_error: str | None = None,
|
||||||
|
active_profile_identity: str | None = None,
|
||||||
|
session_blockers: list[str] | None = None,
|
||||||
|
controller_recovery_authorized: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
|
||||||
|
|
||||||
|
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
|
||||||
|
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
|
||||||
|
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
|
||||||
|
"""
|
||||||
|
classification = classify_review_decision_lock(
|
||||||
|
lock,
|
||||||
|
target_pr_number=target_pr_number,
|
||||||
|
target_head_sha=target_head_sha,
|
||||||
|
pr_live=last_terminal_pr_live,
|
||||||
|
pr_lookup_error=pr_lookup_error,
|
||||||
|
active_profile_identity=active_profile_identity,
|
||||||
|
session_blockers=session_blockers,
|
||||||
|
)
|
||||||
|
cls = classification["classification"]
|
||||||
|
recovery_allowed = False
|
||||||
|
recovery_mode = None
|
||||||
|
if cls == CLASS_FOREIGN_PR_TERMINAL:
|
||||||
|
recovery_allowed = True
|
||||||
|
recovery_mode = "cross_pr_isolation"
|
||||||
|
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
|
||||||
|
recovery_allowed = True
|
||||||
|
recovery_mode = "same_pr_new_head"
|
||||||
|
elif cls == CLASS_MOOT_MERGED_CLOSED:
|
||||||
|
recovery_allowed = True
|
||||||
|
recovery_mode = "moot_cleanup"
|
||||||
|
elif cls == CLASS_READY_FOR_TARGET:
|
||||||
|
recovery_allowed = True
|
||||||
|
recovery_mode = "none_required"
|
||||||
|
elif cls == CLASS_CORRECTION_ACTIVE:
|
||||||
|
recovery_allowed = True
|
||||||
|
recovery_mode = "scoped_correction"
|
||||||
|
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
|
||||||
|
recovery_allowed = False
|
||||||
|
recovery_mode = "correction_only_if_mistake"
|
||||||
|
else:
|
||||||
|
recovery_allowed = False
|
||||||
|
recovery_mode = "fail_closed"
|
||||||
|
|
||||||
|
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
|
||||||
|
# Cleanup still needs apply + capability; assess reports path.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
**classification,
|
||||||
|
"recovery_allowed": recovery_allowed,
|
||||||
|
"recovery_mode": recovery_mode,
|
||||||
|
"controller_recovery_authorized": bool(controller_recovery_authorized),
|
||||||
|
"manual_file_delete_forbidden": True,
|
||||||
|
"correction_as_generic_unlock_forbidden": True,
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||||
|
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||||
|
"cleanup_obsolete_reviewer_comment_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_obsolete_reviewer_comment_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",
|
||||||
@@ -107,6 +117,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #693: read-only classification of durable decision locks (incl. open PRs).
|
||||||
|
"diagnose_review_decision_lock": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_diagnose_review_decision_lock": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"authorize_review_correction": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_authorize_review_correction": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
+10
-1
@@ -26,7 +26,16 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
so durable load/save never touches host state even after env clears.
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
for env_key in [
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_RECONCILER_WORKTREE",
|
||||||
|
]:
|
||||||
|
monkeypatch.delenv(env_key, raising=False)
|
||||||
|
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,23 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
||||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
# path is the stable control checkout (not under branches/), mutation must fail.
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue(title="Test issue", body="body text")
|
res = srv.gitea_create_issue(title="Test issue", body="body text")
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn("stable control checkout", str(exc))
|
||||||
|
else:
|
||||||
|
# #683: production guards return typed blockers at entrypoints
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertFalse(res.get("performed"))
|
||||||
|
blob = " ".join(res.get("reasons") or []) + " " + str(
|
||||||
|
res.get("blocker_kind") or ""
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
"stable control checkout" in blob
|
||||||
|
or "missing_issue_worktree" in blob
|
||||||
|
or "control checkout" in blob.lower()
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("exact_next_action"))
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
@@ -105,11 +119,17 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue(
|
res = srv.gitea_create_issue(
|
||||||
title="Test issue", body="body", worktree_path=missing_path
|
title="Test issue", body="body", worktree_path=missing_path
|
||||||
)
|
)
|
||||||
self.assertIn("does not exist (fail closed)", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn("does not exist", str(exc))
|
||||||
|
else:
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertIn("does not exist", blob)
|
||||||
|
self.assertTrue(res.get("exact_next_action") or res.get("reasons"))
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
@@ -142,11 +162,20 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue(
|
res = srv.gitea_create_issue(
|
||||||
title="Test issue", body="body", worktree_path=wrong_repo_path
|
title="Test issue",
|
||||||
|
body="body",
|
||||||
|
worktree_path=wrong_repo_path,
|
||||||
)
|
)
|
||||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn(
|
||||||
|
"does not belong to the target repository", str(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertIn("does not belong to the target repository", blob)
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
|
|||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||||
|
mutations = list(mutations or [])
|
||||||
|
corr_pr = None
|
||||||
|
if correction and mutations:
|
||||||
|
corr_pr = mutations[-1].get("pr_number")
|
||||||
return {
|
return {
|
||||||
"task": "review_pr",
|
"task": "review_pr",
|
||||||
"remote": "prgs",
|
"remote": "prgs",
|
||||||
@@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
|
|||||||
"ready_remote": "prgs" if ready_pr else None,
|
"ready_remote": "prgs" if ready_pr else None,
|
||||||
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||||
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||||
"live_mutations": list(mutations or []),
|
"live_mutations": mutations,
|
||||||
"correction_authorized": correction,
|
"correction_authorized": correction,
|
||||||
"correction_reason": None,
|
"correction_reason": "test" if correction else None,
|
||||||
|
"correction_pr_number": corr_pr,
|
||||||
|
"correction_head_sha": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase):
|
|||||||
self.assertTrue(reasons)
|
self.assertTrue(reasons)
|
||||||
self.assertIn("#332", reasons[0])
|
self.assertIn("#332", reasons[0])
|
||||||
|
|
||||||
def test_rc_head_a_blocks_other_pr(self):
|
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
|
||||||
|
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
|
||||||
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
700, "mark_ready", expected_head_sha=HEAD_B
|
700, "mark_ready", expected_head_sha=HEAD_B
|
||||||
)
|
)
|
||||||
self.assertTrue(reasons)
|
self.assertEqual(reasons, [])
|
||||||
|
|
||||||
def test_legacy_619_ledger_allows_new_head(self):
|
def test_legacy_619_ledger_allows_new_head(self):
|
||||||
"""PR #619-style durable lock without mutation head_sha."""
|
"""PR #619-style durable lock without mutation head_sha."""
|
||||||
|
|||||||
@@ -267,10 +267,19 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, {}, clear=False):
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||||
with self.assertRaises(RuntimeError):
|
try:
|
||||||
srv.gitea_create_issue_comment(
|
res = srv.gitea_create_issue_comment(
|
||||||
515, "author note", remote="prgs"
|
515, "author note", remote="prgs"
|
||||||
)
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # legacy raise path
|
||||||
|
else:
|
||||||
|
# #683: typed blocker at mutation entrypoint
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertFalse(res.get("performed"))
|
||||||
|
self.assertTrue(
|
||||||
|
res.get("blocker_kind") or res.get("reasons")
|
||||||
|
)
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
"""#683: block unattributed root WIP; pytest cannot disable production guards.
|
||||||
|
|
||||||
|
Regression coverage required by issue #683:
|
||||||
|
|
||||||
|
1. Session locked to issue A blocks unrelated target issue B until B is selected.
|
||||||
|
2. Diagnostic source edit on the root checkout is blocked.
|
||||||
|
3. Same legitimate edit succeeds after issue ownership + isolated worktree bind.
|
||||||
|
4. Running under pytest does not deactivate production guards when force-on.
|
||||||
|
5. Dirty tracked Python files remain visible to porcelain consumers.
|
||||||
|
6. Monkeypatching one helper cannot silently turn the full guard path into a no-op.
|
||||||
|
7. Real mutation entrypoint proves production guards run before side effects.
|
||||||
|
8. Same-issue edits in a valid isolated worktree remain unaffected.
|
||||||
|
9. Blocker includes stable reason + exact recovery action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server # noqa: E402
|
||||||
|
import issue_lock_worktree # noqa: E402
|
||||||
|
import workflow_scope_guard as wsg # noqa: E402
|
||||||
|
|
||||||
|
CONTROL_ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if "branches" in Path(__file__).resolve().parts:
|
||||||
|
# Running from a worktree under branches/ — parent of branches is control.
|
||||||
|
parts = Path(__file__).resolve().parts
|
||||||
|
idx = parts.index("branches")
|
||||||
|
CONTROL_ROOT = str(Path(*parts[:idx])) if idx > 0 else CONTROL_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionGuardsForceOn(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
for key in (
|
||||||
|
wsg.FORCE_PRODUCTION_GUARDS_ENV,
|
||||||
|
"GITEA_TEST_FORCE_DIRTY",
|
||||||
|
"GITEA_TEST_PORCELAIN",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
):
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
wsg.clear_workflow_failure_ledger()
|
||||||
|
|
||||||
|
def test_force_on_under_pytest_keeps_production_active(self):
|
||||||
|
self.assertTrue(wsg.production_guards_active(in_test_mode=False))
|
||||||
|
self.assertFalse(wsg.production_guards_active(in_test_mode=True))
|
||||||
|
os.environ[wsg.FORCE_PRODUCTION_GUARDS_ENV] = "1"
|
||||||
|
self.assertTrue(wsg.production_guards_active(in_test_mode=True))
|
||||||
|
self.assertTrue(wsg.production_guards_forced())
|
||||||
|
|
||||||
|
def test_no_early_return_in_verify_role_mutation_workspace_source(self):
|
||||||
|
src = Path(mcp_server.__file__).read_text(encoding="utf-8")
|
||||||
|
# Rejected 300a4ca pattern must not exist.
|
||||||
|
self.assertNotIn(
|
||||||
|
"if _preflight_in_test_mode():\n return _resolve_preflight_workspace_path",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
# Docstring contract for #683.
|
||||||
|
self.assertIn("#683", src)
|
||||||
|
self.assertIn("must NOT early-return solely because pytest", src)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPorcelainIntegrity(unittest.TestCase):
|
||||||
|
def test_read_worktree_git_state_surfaces_dirty_py(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
# Use a real git repo so porcelain is truthful.
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.email", "[email protected]"],
|
||||||
|
cwd=tmp,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "config", "user.name", "t"],
|
||||||
|
cwd=tmp,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
py_path = Path(tmp) / "sample_mod.py"
|
||||||
|
py_path.write_text("x = 1\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "add", "sample_mod.py"], cwd=tmp, check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "commit", "-m", "init"],
|
||||||
|
cwd=tmp,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
py_path.write_text("x = 2\n", encoding="utf-8")
|
||||||
|
state = issue_lock_worktree.read_worktree_git_state(tmp)
|
||||||
|
porcelain = state.get("porcelain_status") or ""
|
||||||
|
self.assertIn("sample_mod.py", porcelain)
|
||||||
|
self.assertTrue(any(line.strip().endswith(".py") for line in porcelain.splitlines()))
|
||||||
|
|
||||||
|
def test_production_reader_source_rejects_pytest_py_filter(self):
|
||||||
|
src = Path(issue_lock_worktree.__file__).read_text(encoding="utf-8")
|
||||||
|
findings = wsg.assert_no_pytest_porcelain_filter(src)
|
||||||
|
self.assertEqual(findings, [])
|
||||||
|
# Negative: the rejected 300a4ca pattern is detected.
|
||||||
|
rejected = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
porcelain = status_res.stdout or ""
|
||||||
|
import sys
|
||||||
|
if "pytest" in sys.modules or "unittest" in sys.modules:
|
||||||
|
porcelain = "\\n".join(
|
||||||
|
line for line in porcelain.splitlines()
|
||||||
|
if not line.strip().endswith(".py")
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self.assertTrue(wsg.assert_no_pytest_porcelain_filter(rejected))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueScopeOwnership(unittest.TestCase):
|
||||||
|
def test_out_of_scope_issue_blocked_until_selected(self):
|
||||||
|
result = wsg.assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=100,
|
||||||
|
target_issue_number=200,
|
||||||
|
branch_name="fix/issue-100-example",
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["blocker_kind"], wsg.BLOCKER_OUT_OF_SCOPE_ISSUE)
|
||||||
|
self.assertIn("exact_next_action", result)
|
||||||
|
self.assertIn("owning issue", result["exact_next_action"].lower())
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
def test_same_issue_scope_allowed(self):
|
||||||
|
result = wsg.assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=100,
|
||||||
|
target_issue_number=100,
|
||||||
|
branch_name="fix/issue-100-example",
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["exact_next_action"], "proceed")
|
||||||
|
|
||||||
|
def test_missing_lock_when_required(self):
|
||||||
|
result = wsg.assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=None,
|
||||||
|
target_issue_number=None,
|
||||||
|
role_kind="author",
|
||||||
|
require_lock_for_author=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["blocker_kind"], wsg.BLOCKER_MISSING_ISSUE_SCOPE)
|
||||||
|
|
||||||
|
def test_branch_issue_mismatch(self):
|
||||||
|
result = wsg.assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=50,
|
||||||
|
branch_name="fix/issue-99-other",
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["blocker_kind"], wsg.BLOCKER_OUT_OF_SCOPE_ISSUE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootDiagnosticEdit(unittest.TestCase):
|
||||||
|
def test_dirty_root_source_blocked(self):
|
||||||
|
result = wsg.assess_root_source_mutation(
|
||||||
|
workspace_path=CONTROL_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M gitea_mcp_server.py\n M tests/test_x.py\n",
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["blocker_kind"], wsg.BLOCKER_ROOT_DIAGNOSTIC_EDIT)
|
||||||
|
self.assertIn("gitea_mcp_server.py", result["dirty_source_files"])
|
||||||
|
self.assertIn("exact_next_action", result)
|
||||||
|
self.assertIn("branches/", result["exact_next_action"])
|
||||||
|
|
||||||
|
def test_isolated_worktree_same_issue_unaffected(self):
|
||||||
|
wt = f"{CONTROL_ROOT}/branches/issue-100-example"
|
||||||
|
result = wsg.assess_root_source_mutation(
|
||||||
|
workspace_path=wt,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M helper.py\n",
|
||||||
|
current_branch="fix/issue-100-example",
|
||||||
|
locked_issue_number=100,
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["under_branches"])
|
||||||
|
|
||||||
|
def test_legitimate_after_ownership_and_worktree(self):
|
||||||
|
wt = f"{CONTROL_ROOT}/branches/issue-683-workflow-guard-hardening"
|
||||||
|
composed = wsg.assess_production_mutation_guards(
|
||||||
|
workspace_path=wt,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M workflow_scope_guard.py\n",
|
||||||
|
current_branch="fix/issue-683-workflow-guard-hardening",
|
||||||
|
locked_issue_number=683,
|
||||||
|
target_issue_number=683,
|
||||||
|
role_kind="author",
|
||||||
|
require_author_lock=True,
|
||||||
|
in_test_mode=True,
|
||||||
|
)
|
||||||
|
# Force-on required for production path under pytest.
|
||||||
|
os.environ[wsg.FORCE_PRODUCTION_GUARDS_ENV] = "1"
|
||||||
|
try:
|
||||||
|
composed = wsg.assess_production_mutation_guards(
|
||||||
|
workspace_path=wt,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M workflow_scope_guard.py\n",
|
||||||
|
current_branch="fix/issue-683-workflow-guard-hardening",
|
||||||
|
locked_issue_number=683,
|
||||||
|
target_issue_number=683,
|
||||||
|
role_kind="author",
|
||||||
|
require_author_lock=True,
|
||||||
|
in_test_mode=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(composed["block"])
|
||||||
|
self.assertFalse(composed.get("skipped"))
|
||||||
|
finally:
|
||||||
|
os.environ.pop(wsg.FORCE_PRODUCTION_GUARDS_ENV, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypedBlockerResponse(unittest.TestCase):
|
||||||
|
def test_block_response_has_stable_kind_and_next_action(self):
|
||||||
|
assessment = wsg.assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=1,
|
||||||
|
target_issue_number=2,
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
resp = wsg.block_response(assessment)
|
||||||
|
self.assertFalse(resp["success"])
|
||||||
|
self.assertFalse(resp["performed"])
|
||||||
|
self.assertEqual(resp["blocker_kind"], wsg.BLOCKER_OUT_OF_SCOPE_ISSUE)
|
||||||
|
self.assertIsInstance(resp["exact_next_action"], str)
|
||||||
|
self.assertTrue(resp["exact_next_action"])
|
||||||
|
self.assertTrue(resp["reasons"])
|
||||||
|
|
||||||
|
def test_production_guard_error_roundtrip(self):
|
||||||
|
err = wsg.ProductionGuardError(
|
||||||
|
"blocked",
|
||||||
|
blocker_kind=wsg.BLOCKER_ROOT_DIAGNOSTIC_EDIT,
|
||||||
|
reasons=["dirty root"],
|
||||||
|
)
|
||||||
|
resp = wsg.block_response(err, issue_number=683)
|
||||||
|
self.assertEqual(resp["blocker_kind"], wsg.BLOCKER_ROOT_DIAGNOSTIC_EDIT)
|
||||||
|
self.assertEqual(resp["issue_number"], 683)
|
||||||
|
self.assertIn("exact_next_action", resp)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDurableFailureRecording(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
wsg.clear_workflow_failure_ledger()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
wsg.clear_workflow_failure_ledger()
|
||||||
|
|
||||||
|
def test_record_before_source_mutation(self):
|
||||||
|
pending = wsg.assess_durable_failure_recorded(
|
||||||
|
require_record=True, pending_source_mutation=True
|
||||||
|
)
|
||||||
|
self.assertTrue(pending["block"])
|
||||||
|
self.assertEqual(pending["blocker_kind"], wsg.BLOCKER_UNRECORDED_FAILURE)
|
||||||
|
|
||||||
|
wsg.record_workflow_failure(
|
||||||
|
kind="transport_eof",
|
||||||
|
detail="EOF during review session (#584 cluster)",
|
||||||
|
issue_number=683,
|
||||||
|
task="comment_issue",
|
||||||
|
)
|
||||||
|
after = wsg.assess_durable_failure_recorded(
|
||||||
|
require_record=True, pending_source_mutation=True
|
||||||
|
)
|
||||||
|
self.assertFalse(after["block"])
|
||||||
|
self.assertEqual(len(wsg.workflow_failure_ledger()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMonkeypatchCannotNoopFullPath(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.pop(wsg.FORCE_PRODUCTION_GUARDS_ENV, None)
|
||||||
|
|
||||||
|
def test_patching_branches_only_still_blocks_dirty_root_scope(self):
|
||||||
|
"""Monkeypatching branches-only must not silence root diagnostic block."""
|
||||||
|
os.environ[wsg.FORCE_PRODUCTION_GUARDS_ENV] = "1"
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "_enforce_branches_only_author_mutation", lambda *a, **k: None
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "_enforce_root_checkout_guard", lambda *a, **k: None
|
||||||
|
):
|
||||||
|
# Even if both legacy helpers are patched, issue-scope composition
|
||||||
|
# still sees dirty root source via assess_production_mutation_guards.
|
||||||
|
assessment = wsg.assess_production_mutation_guards(
|
||||||
|
workspace_path=CONTROL_ROOT,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M gitea_mcp_server.py\n",
|
||||||
|
role_kind="author",
|
||||||
|
in_test_mode=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["blocker_kind"], wsg.BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealEntrypointProductionGuard(unittest.TestCase):
|
||||||
|
"""Real mutation entrypoint: production guard before side effects (#683)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
os.environ[wsg.FORCE_PRODUCTION_GUARDS_ENV] = "1"
|
||||||
|
for key in ("GITEA_AUTHOR_WORKTREE", "GITEA_ACTIVE_WORKTREE"):
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
self._orig_whoami = mcp_server._preflight_whoami_called
|
||||||
|
self._orig_cap = mcp_server._preflight_capability_called
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_capability_called = False
|
||||||
|
mcp_server._preflight_resolved_role = None
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.pop(wsg.FORCE_PRODUCTION_GUARDS_ENV, None)
|
||||||
|
for key in ("GITEA_AUTHOR_WORKTREE", "GITEA_ACTIVE_WORKTREE"):
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
mcp_server._preflight_whoami_called = self._orig_whoami
|
||||||
|
mcp_server._preflight_capability_called = self._orig_cap
|
||||||
|
mcp_server._preflight_resolved_role = None
|
||||||
|
mcp_server._preflight_resolved_task = None
|
||||||
|
|
||||||
|
def test_comment_issue_blocks_dirty_root_before_api(self):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
with patch.object(mcp_server, "api_request", api_mock), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_actual_profile_role",
|
||||||
|
return_value="author",
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_effective_workspace_role",
|
||||||
|
return_value="author",
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
side_effect=lambda path, **kw: {
|
||||||
|
"current_branch": "master",
|
||||||
|
"porcelain_status": (
|
||||||
|
" M gitea_mcp_server.py\n"
|
||||||
|
if os.path.realpath(path) == os.path.realpath(CONTROL_ROOT)
|
||||||
|
or path == CONTROL_ROOT
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"head_sha": "a" * 40,
|
||||||
|
"base_equivalent": True,
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_resolve_namespace_mutation_context",
|
||||||
|
return_value={
|
||||||
|
"workspace_path": CONTROL_ROOT,
|
||||||
|
"canonical_repo_root": CONTROL_ROOT,
|
||||||
|
"process_project_root": CONTROL_ROOT,
|
||||||
|
"workspace_role_kind": "author",
|
||||||
|
"workspace_binding_source": "process root",
|
||||||
|
"ignored_bindings": [],
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_resolve_author_mutation_context",
|
||||||
|
return_value={
|
||||||
|
"workspace_path": CONTROL_ROOT,
|
||||||
|
"canonical_repo_root": CONTROL_ROOT,
|
||||||
|
"process_project_root": CONTROL_ROOT,
|
||||||
|
"roots_aligned": True,
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_session_locked_issue_number",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_create_issue_comment(
|
||||||
|
issue_number=683,
|
||||||
|
body="diagnostic note",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=CONTROL_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_mock.assert_not_called()
|
||||||
|
self.assertFalse(result.get("success"))
|
||||||
|
self.assertFalse(result.get("performed"))
|
||||||
|
self.assertIn(result.get("blocker_kind"), wsg.BLOCKER_KINDS)
|
||||||
|
self.assertTrue(result.get("exact_next_action"))
|
||||||
|
self.assertTrue(result.get("reasons"))
|
||||||
|
|
||||||
|
def test_comment_issue_succeeds_structure_after_worktree_bind(self):
|
||||||
|
"""Same-issue isolated worktree is not blocked by root diagnostic path."""
|
||||||
|
wt = f"{CONTROL_ROOT}/branches/issue-683-workflow-guard-hardening"
|
||||||
|
os.environ["GITEA_AUTHOR_WORKTREE"] = wt
|
||||||
|
assessment = wsg.assess_production_mutation_guards(
|
||||||
|
workspace_path=wt,
|
||||||
|
canonical_repo_root=CONTROL_ROOT,
|
||||||
|
porcelain_status=" M workflow_scope_guard.py\n",
|
||||||
|
current_branch="fix/issue-683-workflow-guard-hardening",
|
||||||
|
locked_issue_number=683,
|
||||||
|
target_issue_number=683,
|
||||||
|
role_kind="author",
|
||||||
|
require_author_lock=True,
|
||||||
|
in_test_mode=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"], assessment)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyPreflightForceOn(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.pop(wsg.FORCE_PRODUCTION_GUARDS_ENV, None)
|
||||||
|
for key in ("GITEA_AUTHOR_WORKTREE", "GITEA_ACTIVE_WORKTREE"):
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
|
def test_force_on_runs_production_guards_under_pytest(self):
|
||||||
|
os.environ[wsg.FORCE_PRODUCTION_GUARDS_ENV] = "1"
|
||||||
|
called = {"root": 0, "branches": 0, "scope": 0}
|
||||||
|
|
||||||
|
def _root(*a, **k):
|
||||||
|
called["root"] += 1
|
||||||
|
|
||||||
|
def _branches(*a, **k):
|
||||||
|
called["branches"] += 1
|
||||||
|
|
||||||
|
def _scope(*a, **k):
|
||||||
|
called["scope"] += 1
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "_enforce_root_checkout_guard", _root), patch.object(
|
||||||
|
mcp_server, "_enforce_branches_only_author_mutation", _branches
|
||||||
|
), patch.object(mcp_server, "_enforce_issue_scope_guard", _scope):
|
||||||
|
# No whoami/capability — purity-order skipped; production still runs.
|
||||||
|
mcp_server.verify_preflight_purity(task="comment_issue")
|
||||||
|
|
||||||
|
self.assertEqual(called["root"], 1)
|
||||||
|
self.assertEqual(called["branches"], 1)
|
||||||
|
self.assertEqual(called["scope"], 1)
|
||||||
|
|
||||||
|
def test_without_force_on_pytest_skips_production_only_for_unit_isolation(self):
|
||||||
|
called = {"root": 0}
|
||||||
|
|
||||||
|
def _root(*a, **k):
|
||||||
|
called["root"] += 1
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "_enforce_root_checkout_guard", _root), patch.object(
|
||||||
|
mcp_server, "_enforce_branches_only_author_mutation", lambda *a, **k: None
|
||||||
|
), patch.object(mcp_server, "_enforce_issue_scope_guard", lambda *a, **k: None):
|
||||||
|
mcp_server.verify_preflight_purity(task="comment_issue")
|
||||||
|
self.assertEqual(called["root"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
"""Guarded cleanup for obsolete comment-backed reviewer leases (#691).
|
||||||
|
|
||||||
|
Reproduces PR #688 lease comment 10749 class of defect: completed
|
||||||
|
REQUEST_CHANGES on head A, author pushes head B, foreign lease remains
|
||||||
|
pinned to A (and after expiry still has no non-owner cleanup path that
|
||||||
|
diagnosis can prescribe beyond indefinite wait).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import merger_lease_adoption as mla # noqa: E402
|
||||||
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
|
|
||||||
|
# PR #688 / comment 10749 reproduction fixtures
|
||||||
|
HEAD_A = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
|
||||||
|
HEAD_B = "4a6357800364718a27a36ebc73578d4b929ff4aa"
|
||||||
|
SESSION_FOREIGN = "25883-7d4c6e6ebd53"
|
||||||
|
COMMENT_10749 = 10749
|
||||||
|
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PR = 688
|
||||||
|
ISSUE = 687
|
||||||
|
EXPIRES_10749 = "2026-07-13T04:23:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(dt: datetime) -> datetime:
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_comment(
|
||||||
|
*,
|
||||||
|
pr_number: int = PR,
|
||||||
|
session_id: str = SESSION_FOREIGN,
|
||||||
|
phase: str = "claimed",
|
||||||
|
candidate_head: str = HEAD_A,
|
||||||
|
comment_id: int = COMMENT_10749,
|
||||||
|
last_activity: datetime | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
worktree: str = "branches/review-pr688-feat-issue-687-reconciler-branch-delete",
|
||||||
|
repo: str = REPO,
|
||||||
|
) -> dict:
|
||||||
|
now = last_activity or datetime.now(timezone.utc)
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo=repo,
|
||||||
|
pr_number=pr_number,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree=worktree,
|
||||||
|
phase=phase,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": comment_id,
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": "sysadmin"},
|
||||||
|
"author": "sysadmin",
|
||||||
|
"created_at": now.isoformat(),
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reviews_for_head(head: str, verdict: str = "REQUEST_CHANGES") -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"verdict": verdict,
|
||||||
|
"reviewed_head_sha": head,
|
||||||
|
"dismissed": False,
|
||||||
|
"reviewer": "sysadmin",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _assess(
|
||||||
|
comments,
|
||||||
|
*,
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=None,
|
||||||
|
controller=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
requesting_session="fresh-controller",
|
||||||
|
apply=False,
|
||||||
|
confirmation=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
comments,
|
||||||
|
pr_number=PR,
|
||||||
|
current_head_sha=current_head,
|
||||||
|
formal_reviews=formal_reviews if formal_reviews is not None else _reviews_for_head(HEAD_A),
|
||||||
|
repo=REPO,
|
||||||
|
expected_repo=REPO,
|
||||||
|
requesting_session_id=requesting_session,
|
||||||
|
controller_recovery_authorized=controller,
|
||||||
|
worktree_exists=worktree_exists,
|
||||||
|
worktree_clean=worktree_clean,
|
||||||
|
worktree_has_unpreserved_work=not worktree_clean if worktree_exists else False,
|
||||||
|
owner_process_alive=owner_process_alive,
|
||||||
|
owner_pid_observed=kwargs.get("owner_pid_observed"),
|
||||||
|
requesting_pid=kwargs.get("requesting_pid", 99999),
|
||||||
|
current_head_review_in_progress=kwargs.get(
|
||||||
|
"current_head_review_in_progress", False
|
||||||
|
),
|
||||||
|
expected_lease_comment_id=kwargs.get("expected_lease_comment_id"),
|
||||||
|
expected_session_id=kwargs.get("expected_session_id"),
|
||||||
|
expected_leased_head=kwargs.get("expected_leased_head"),
|
||||||
|
confirmation=confirmation
|
||||||
|
if confirmation is not None
|
||||||
|
else (leases.cleanup_confirmation_for_pr(PR) if apply else ""),
|
||||||
|
apply=apply,
|
||||||
|
now=kwargs.get("now"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue691SupersededHeadCleanup(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_1_request_changes_then_push_expired_cleanup_succeeds(self):
|
||||||
|
"""Completed REQUEST_CHANGES on A, push B, lease A expires → cleanup OK."""
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
last_activity=expires - timedelta(hours=2),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A, "REQUEST_CHANGES"),
|
||||||
|
now=now,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["cleanup_allowed"], result["reasons"])
|
||||||
|
self.assertEqual(result["classification"], "foreign_expired_superseded_head")
|
||||||
|
self.assertEqual(
|
||||||
|
result["exact_next_action"], leases.NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
)
|
||||||
|
self.assertIn("phase: released", result["release_body"])
|
||||||
|
self.assertIn("obsolete-superseded-or-expired-lease", result["release_body"])
|
||||||
|
self.assertIsNotNone(result["audit_comment_body"])
|
||||||
|
self.assertEqual(result["cleanup_tool"], leases.CLEANUP_OBSOLETE_LEASE_TOOL)
|
||||||
|
|
||||||
|
def test_2_approve_then_push_cleanup_policy(self):
|
||||||
|
"""Completed APPROVE on A, push B → cleanup eligible (completed superseded)."""
|
||||||
|
now = datetime(2026, 7, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc) # not yet expired
|
||||||
|
claim = _lease_comment(
|
||||||
|
last_activity=now - timedelta(minutes=10),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A, "APPROVED"),
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["cleanup_allowed"], result["reasons"])
|
||||||
|
self.assertEqual(result["classification"], "foreign_completed_superseded_head")
|
||||||
|
|
||||||
|
def test_3_active_current_head_cleanup_denied(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=now - timedelta(minutes=5),
|
||||||
|
expires_at=now + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_B, "REQUEST_CHANGES"),
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertEqual(result["classification"], "foreign_active_current_head")
|
||||||
|
|
||||||
|
def test_4_foreign_owner_recent_progress_denied(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=now - timedelta(minutes=2),
|
||||||
|
expires_at=now + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=[],
|
||||||
|
owner_process_alive=True,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("recent authenticated progress" in r for r in result["reasons"])
|
||||||
|
or any("current PR head" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_5_owner_absent_worktree_dirty_denied(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=expires - timedelta(hours=1),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=[],
|
||||||
|
worktree_clean=False,
|
||||||
|
owner_process_alive=False,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("dirty" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_6_owner_absent_worktree_clean_orphan_ok(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=expires - timedelta(hours=1),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=[],
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["cleanup_allowed"], result["reasons"])
|
||||||
|
self.assertEqual(result["classification"], "orphaned_owner_missing")
|
||||||
|
|
||||||
|
def test_7_pid_reuse_not_ownership(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
now=now,
|
||||||
|
owner_pid_observed=4242,
|
||||||
|
requesting_pid=4242,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("PID equality" in r or "NOT ownership" in r for r in result["reasons"])
|
||||||
|
or result["owner_session_evidence"]["pid_is_not_ownership_proof"]
|
||||||
|
)
|
||||||
|
# Still eligible via superseded+terminal+expired despite matching PID.
|
||||||
|
self.assertTrue(result["cleanup_allowed"], result["reasons"])
|
||||||
|
|
||||||
|
def test_8_comment_backed_no_db_lease_id(self):
|
||||||
|
"""Cleanup uses comment ledger only — no control-plane lease_id required."""
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
# Explicitly no lease_id field anywhere.
|
||||||
|
self.assertNotIn("lease_id", claim)
|
||||||
|
result = _assess([claim], now=now)
|
||||||
|
self.assertTrue(result["cleanup_allowed"], result["reasons"])
|
||||||
|
self.assertEqual(result["active_lease"]["comment_id"], COMMENT_10749)
|
||||||
|
self.assertIsNone(result["active_lease"].get("lease_id"))
|
||||||
|
|
||||||
|
def test_9_cleanup_posts_durable_audit_bodies(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess([claim], now=now, apply=True)
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
self.assertIn("#691", result["audit_comment_body"])
|
||||||
|
self.assertIn(str(COMMENT_10749), result["audit_comment_body"])
|
||||||
|
self.assertIn(HEAD_A, result["audit_comment_body"])
|
||||||
|
self.assertIn(HEAD_B, result["audit_comment_body"])
|
||||||
|
|
||||||
|
def test_10_fresh_acquire_after_cleanup_marker(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
expires_at=expires,
|
||||||
|
last_activity=expires - timedelta(hours=1),
|
||||||
|
comment_id=COMMENT_10749,
|
||||||
|
)
|
||||||
|
assessed = _assess([claim], now=now, apply=True)
|
||||||
|
self.assertTrue(assessed["cleanup_allowed"])
|
||||||
|
release = {
|
||||||
|
"id": 99999,
|
||||||
|
"body": assessed["release_body"],
|
||||||
|
"user": {"login": "sysadmin"},
|
||||||
|
}
|
||||||
|
# Newest terminal marker ends active lease.
|
||||||
|
active = leases.find_active_reviewer_lease(
|
||||||
|
[claim, release], pr_number=PR, now=now
|
||||||
|
)
|
||||||
|
self.assertIsNone(active)
|
||||||
|
acq = leases.assess_acquire_lease(
|
||||||
|
[claim, release],
|
||||||
|
pr_number=PR,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="fresh-reviewer-1",
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
worktree="branches/review-pr-688-fresh",
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertTrue(acq["acquire_allowed"], acq["reasons"])
|
||||||
|
|
||||||
|
def test_11_no_validation_state_transfer(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess([claim], now=now, apply=True)
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
# Release body keeps old candidate_head (no repoint).
|
||||||
|
self.assertIn(f"candidate_head: {HEAD_A}", result["release_body"])
|
||||||
|
self.assertNotIn(HEAD_B, result["release_body"].split("candidate_head:")[1].split("\n")[0])
|
||||||
|
self.assertIn("did not transfer validation", result["audit_comment_body"])
|
||||||
|
self.assertIn("did not repoint", result["audit_comment_body"])
|
||||||
|
# Session lease must remain unset by assessment (tool never seeds).
|
||||||
|
self.assertIsNone(leases.get_session_lease())
|
||||||
|
|
||||||
|
def test_12_repeated_cleanup_idempotent(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
first = _assess([claim], now=now, apply=True)
|
||||||
|
self.assertTrue(first["cleanup_allowed"])
|
||||||
|
release = {"id": 100001, "body": first["release_body"], "user": {"login": "x"}}
|
||||||
|
second = _assess([claim, release], now=now, apply=True)
|
||||||
|
self.assertFalse(second["cleanup_allowed"])
|
||||||
|
self.assertEqual(second["classification"], "no_lease")
|
||||||
|
|
||||||
|
def test_13_malformed_expiry_fails_closed(self):
|
||||||
|
claim = _lease_comment()
|
||||||
|
# Corrupt expires_at in the body.
|
||||||
|
claim["body"] = claim["body"].replace(
|
||||||
|
claim["body"].split("expires_at:")[1].split("\n")[0].strip(),
|
||||||
|
"not-a-timestamp",
|
||||||
|
)
|
||||||
|
result = _assess([claim], formal_reviews=_reviews_for_head(HEAD_A))
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertFalse(result["expires_parseable"])
|
||||||
|
self.assertTrue(any("expires_at" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_14_wrong_identity_evidence_fails_closed(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
bad_repo = _assess(
|
||||||
|
[claim],
|
||||||
|
now=now,
|
||||||
|
# force repo mismatch via expected_repo
|
||||||
|
)
|
||||||
|
# Patch via direct call with wrong expected_repo
|
||||||
|
bad = leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
[claim],
|
||||||
|
pr_number=PR,
|
||||||
|
current_head_sha=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A),
|
||||||
|
expected_repo="Other-Org/Other-Repo",
|
||||||
|
requesting_session_id="fresh",
|
||||||
|
controller_recovery_authorized=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(bad["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("repository identity" in r for r in bad["reasons"]))
|
||||||
|
|
||||||
|
bad_pr = leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
[claim],
|
||||||
|
pr_number=999,
|
||||||
|
current_head_sha=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A),
|
||||||
|
expected_repo=REPO,
|
||||||
|
requesting_session_id="fresh",
|
||||||
|
controller_recovery_authorized=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
expected_lease_comment_id=COMMENT_10749,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(bad_pr["cleanup_allowed"])
|
||||||
|
|
||||||
|
bad_head = _assess(
|
||||||
|
[claim],
|
||||||
|
now=now,
|
||||||
|
expected_leased_head="0" * 40,
|
||||||
|
)
|
||||||
|
self.assertFalse(bad_head["cleanup_allowed"])
|
||||||
|
|
||||||
|
bad_session = _assess(
|
||||||
|
[claim],
|
||||||
|
now=now,
|
||||||
|
expected_session_id="wrong-session",
|
||||||
|
)
|
||||||
|
self.assertFalse(bad_session["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_15_current_head_active_cannot_be_displaced(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=now - timedelta(minutes=1),
|
||||||
|
expires_at=now + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
current_head=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_B),
|
||||||
|
now=now,
|
||||||
|
current_head_review_in_progress=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
# Acquire also blocked by foreign active lease.
|
||||||
|
acq = leases.assess_acquire_lease(
|
||||||
|
[claim],
|
||||||
|
pr_number=PR,
|
||||||
|
reviewer_identity="other",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="thief",
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
worktree="branches/steal",
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertFalse(acq["acquire_allowed"])
|
||||||
|
|
||||||
|
def test_regression_pr688_comment_10749_after_expiry(self):
|
||||||
|
"""Exact 10749 reproduction after recorded expires_at 2026-07-13T04:23:00Z."""
|
||||||
|
now = datetime(2026, 7, 13, 4, 30, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime.fromisoformat(EXPIRES_10749.replace("Z", "+00:00"))
|
||||||
|
claim = _lease_comment(
|
||||||
|
session_id=SESSION_FOREIGN,
|
||||||
|
candidate_head=HEAD_A,
|
||||||
|
comment_id=COMMENT_10749,
|
||||||
|
last_activity=expires - timedelta(hours=2),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
# Diagnosis must not prescribe indefinite wait-only for this case.
|
||||||
|
diag = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[claim],
|
||||||
|
pr_number=PR,
|
||||||
|
current_session_id="fresh-reviewer",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
current_head_sha=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A, "REQUEST_CHANGES"),
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
instructed_session_id=SESSION_FOREIGN,
|
||||||
|
instructed_comment_id=COMMENT_10749,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertEqual(diag["classification"], "foreign_expired_superseded_head")
|
||||||
|
self.assertEqual(
|
||||||
|
diag["next_action"], leases.NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
)
|
||||||
|
self.assertEqual(diag["cleanup_tool"], leases.CLEANUP_OBSOLETE_LEASE_TOOL)
|
||||||
|
self.assertIn("CLEANUP OBSOLETE REVIEWER LEASE 688", diag["required_confirmation"])
|
||||||
|
self.assertEqual(diag["mutation_eligibility"], "cleanup_only")
|
||||||
|
self.assertFalse(diag["mutation_allowed"])
|
||||||
|
|
||||||
|
# Assessment still returns the lease as active/stale class of block for acquire
|
||||||
|
# when unexpired path... here it's expired so find_active is None; acquire free
|
||||||
|
# only after cleanup if somehow still active. With expired, find_active is None:
|
||||||
|
active = leases.find_active_reviewer_lease([claim], pr_number=PR, now=now)
|
||||||
|
self.assertIsNone(active)
|
||||||
|
|
||||||
|
# But superseded unexpired still blocks acquire and diagnosis points to cleanup:
|
||||||
|
unexpired_now = datetime(2026, 7, 13, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
claim2 = _lease_comment(
|
||||||
|
session_id=SESSION_FOREIGN,
|
||||||
|
candidate_head=HEAD_A,
|
||||||
|
comment_id=COMMENT_10749,
|
||||||
|
last_activity=unexpired_now - timedelta(minutes=45),
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
active2 = leases.find_active_reviewer_lease(
|
||||||
|
[claim2], pr_number=PR, now=unexpired_now
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(active2)
|
||||||
|
self.assertEqual(active2["freshness"], "stale_warning")
|
||||||
|
diag2 = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[claim2],
|
||||||
|
pr_number=PR,
|
||||||
|
current_session_id="fresh-reviewer",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
current_head_sha=HEAD_B,
|
||||||
|
formal_reviews=_reviews_for_head(HEAD_A, "REQUEST_CHANGES"),
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
now=unexpired_now,
|
||||||
|
)
|
||||||
|
self.assertEqual(diag2["classification"], "foreign_completed_superseded_head")
|
||||||
|
self.assertEqual(
|
||||||
|
diag2["next_action"], leases.NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
)
|
||||||
|
acq = leases.assess_acquire_lease(
|
||||||
|
[claim2],
|
||||||
|
pr_number=PR,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="fresh-reviewer",
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
worktree="branches/review-pr-688-new",
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
now=unexpired_now,
|
||||||
|
)
|
||||||
|
self.assertFalse(acq["acquire_allowed"])
|
||||||
|
|
||||||
|
def test_missing_controller_auth_denied(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess([claim], controller=False, now=now)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_wrong_confirmation_denied_on_apply(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess(
|
||||||
|
[claim], now=now, apply=True, confirmation="MERGE PR 688"
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("confirmation" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_owner_session_must_use_owner_release(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess(
|
||||||
|
[claim],
|
||||||
|
now=now,
|
||||||
|
requesting_session=SESSION_FOREIGN,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_superseded_without_terminal_review_denied(self):
|
||||||
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
|
result = _assess([claim], formal_reviews=[], now=now)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue691DiagnoseClassifications(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_foreign_active_current_head_still_wait(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
claim = _lease_comment(
|
||||||
|
candidate_head=HEAD_B,
|
||||||
|
last_activity=now - timedelta(minutes=5),
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
claim["id"] = 1
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[claim],
|
||||||
|
pr_number=PR,
|
||||||
|
current_session_id="other",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
current_head_sha=HEAD_B,
|
||||||
|
formal_reviews=[],
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], "foreign_active_current_head")
|
||||||
|
self.assertEqual(result["next_action"], leases.NEXT_ACTION_WAIT)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Review-decision-lock recovery / cross-PR isolation (#693).
|
||||||
|
|
||||||
|
Reproduces the PR #688 → PR #692 formal-review sequence:
|
||||||
|
|
||||||
|
* durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425)
|
||||||
|
* reviewer leases PR #692 @ head B (6d86db…)
|
||||||
|
* mark_final for #692 must succeed without correction unlock
|
||||||
|
* cleanup of open #688 terminal remains forbidden (#594)
|
||||||
|
* authorize_review_correction for #688 cannot unlock #692
|
||||||
|
* eventual #692 APPROVE is unique and head-bound (review_id 426)
|
||||||
|
* mark_final is idempotent for same PR/action/head
|
||||||
|
* diagnosis returns one exact next_action + durable handoff payload
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import review_workflow_load
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
|
||||||
|
# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693)
|
||||||
|
HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
|
||||||
|
HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca"
|
||||||
|
PR_A = 688
|
||||||
|
PR_B = 692
|
||||||
|
REVIEW_ID_A = 425
|
||||||
|
REVIEW_ID_B = 426
|
||||||
|
|
||||||
|
RC_688 = {
|
||||||
|
"pr_number": PR_A,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": REVIEW_ID_A,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
"head_sha": HEAD_688,
|
||||||
|
}
|
||||||
|
APPROVE_692 = {
|
||||||
|
"pr_number": PR_B,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": REVIEW_ID_B,
|
||||||
|
"review_state": "approve",
|
||||||
|
"head_sha": HEAD_692,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, **kwargs):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
base = {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": None,
|
||||||
|
"ready_action": None,
|
||||||
|
"ready_expected_head_sha": None,
|
||||||
|
"ready_remote": None,
|
||||||
|
"ready_org": None,
|
||||||
|
"ready_repo": None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": False,
|
||||||
|
"correction_reason": None,
|
||||||
|
"correction_pr_number": None,
|
||||||
|
"correction_head_sha": None,
|
||||||
|
"updated_at": now,
|
||||||
|
"recorded_at": now,
|
||||||
|
"writer_pid": os.getpid(),
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
# Ensure TTL fields stay fresh unless the caller is testing expiry.
|
||||||
|
if "recorded_at" not in kwargs:
|
||||||
|
base["recorded_at"] = now
|
||||||
|
if "updated_at" not in kwargs:
|
||||||
|
base["updated_at"] = now
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
REVIEWER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.read,gitea.pr.review,gitea.pr.approve,"
|
||||||
|
"gitea.pr.request_changes,gitea.pr.comment"
|
||||||
|
),
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
REVIEWER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.merge"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(mutations=None, **kwargs):
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
def _no_lease():
|
||||||
|
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(number, head):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"merged_at": None,
|
||||||
|
"head": {"sha": head},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossPrIsolationPure(unittest.TestCase):
|
||||||
|
def test_foreign_terminal_does_not_block_mark_boundary(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_A, expected_head_sha=HEAD_688
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_terminal_boundary_allows_fresh_decision_on_other_pr(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.terminal_boundary_allows_fresh_decision(
|
||||||
|
lock,
|
||||||
|
pr_number=PR_B,
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
operation="mark_ready",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.terminal_boundary_allows_fresh_decision(
|
||||||
|
lock,
|
||||||
|
pr_number=PR_A,
|
||||||
|
expected_head_sha=HEAD_688,
|
||||||
|
operation="mark_ready",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unscoped_correction_does_not_apply(self):
|
||||||
|
lock = _lock([RC_688], correction_authorized=True, correction_reason="x")
|
||||||
|
# Missing correction_pr_number → fail closed (#693)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scoped_correction_only_for_named_pr(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_688],
|
||||||
|
correction_authorized=True,
|
||||||
|
correction_reason="mistake",
|
||||||
|
correction_pr_number=PR_A,
|
||||||
|
correction_head_sha=HEAD_688,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassification(unittest.TestCase):
|
||||||
|
def test_foreign_pr_terminal_classification(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock,
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
target_head_sha=HEAD_692,
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
active_profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||||
|
self.assertTrue(c["mark_final_allowed"])
|
||||||
|
self.assertFalse(c["correction_eligible"])
|
||||||
|
self.assertIn("do not call gitea_authorize_review_correction", c["next_action"])
|
||||||
|
|
||||||
|
def test_same_head_terminal_classification(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock,
|
||||||
|
target_pr_number=PR_A,
|
||||||
|
target_head_sha=HEAD_688,
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
)
|
||||||
|
self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD)
|
||||||
|
self.assertFalse(c["mark_final_allowed"])
|
||||||
|
self.assertTrue(c["correction_eligible"])
|
||||||
|
|
||||||
|
def test_precise_failure_includes_durable_handoff(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock, target_pr_number=PR_A, target_head_sha=HEAD_688
|
||||||
|
)
|
||||||
|
fail = srdl.build_precise_mark_final_failure(c)
|
||||||
|
self.assertIn("exact_next_action", fail)
|
||||||
|
self.assertIn("durable_issue_handoff", fail)
|
||||||
|
self.assertTrue(fail["durable_issue_handoff"]["required"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPr692Sequence(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
|
||||||
|
self._env.start()
|
||||||
|
self._prof = patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
)
|
||||||
|
self._prof.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._prof.stop()
|
||||||
|
self._env.stop()
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_mark_final_692_succeeds_with_open_688_terminal(self):
|
||||||
|
_seed([RC_688], writer_pid=25883, session_pid=60615)
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
self.assertEqual(lock["ready_pr_number"], PR_B)
|
||||||
|
self.assertEqual(
|
||||||
|
srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692
|
||||||
|
)
|
||||||
|
# Foreign terminal history preserved (non-destructive isolation).
|
||||||
|
self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A)
|
||||||
|
|
||||||
|
def test_mark_final_idempotent_same_binding(self):
|
||||||
|
_seed(
|
||||||
|
[RC_688],
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
ready_pr_number=PR_B,
|
||||||
|
ready_action="approve",
|
||||||
|
ready_expected_head_sha=HEAD_692,
|
||||||
|
ready_remote="prgs",
|
||||||
|
ready_org="Scaled-Tech-Consulting",
|
||||||
|
ready_repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["marked_ready"])
|
||||||
|
self.assertTrue(result.get("idempotent"))
|
||||||
|
|
||||||
|
def test_cleanup_still_forbidden_while_688_open(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
assessment = srdl.assess_stale_review_decision_lock(
|
||||||
|
mcp_server._load_review_decision_lock(),
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
active_profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["cleanup_allowed"])
|
||||||
|
self.assertFalse(assessment["is_moot"])
|
||||||
|
|
||||||
|
def test_correction_cannot_unlock_different_pr(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
r = mcp_server.gitea_authorize_review_correction(
|
||||||
|
prior_review_id=REVIEW_ID_A,
|
||||||
|
prior_review_state="request_changes",
|
||||||
|
reason="try unlock 692",
|
||||||
|
operator_authorized=True,
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(r["authorized"])
|
||||||
|
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||||
|
|
||||||
|
def test_scoped_correction_for_same_pr_posts_audit(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch(
|
||||||
|
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch("mcp_server.api_request", return_value={"id": 99901}):
|
||||||
|
r = mcp_server.gitea_authorize_review_correction(
|
||||||
|
prior_review_id=REVIEW_ID_A,
|
||||||
|
prior_review_state="request_changes",
|
||||||
|
reason="mistaken RC wording",
|
||||||
|
operator_authorized=True,
|
||||||
|
target_pr_number=PR_A,
|
||||||
|
expected_head_sha=HEAD_688,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(r["authorized"], r.get("reasons"))
|
||||||
|
self.assertEqual(r.get("correction_pr_number"), PR_A)
|
||||||
|
self.assertEqual(
|
||||||
|
r.get("audit_comment_id"),
|
||||||
|
99901,
|
||||||
|
r.get("audit_comment_skipped") or r.get("audit_comment_error") or r,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_diagnose_foreign_terminal_next_action(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch(
|
||||||
|
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)):
|
||||||
|
d = mcp_server.gitea_diagnose_review_decision_lock(
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(d["success"], d.get("reasons"))
|
||||||
|
self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||||
|
self.assertTrue(d["mark_final_allowed"])
|
||||||
|
self.assertIn("mark_final", d["exact_next_action"])
|
||||||
|
self.assertTrue(d["correction_as_generic_unlock_forbidden"])
|
||||||
|
|
||||||
|
def test_approval_ledger_unique_and_bound_after_sequence(self):
|
||||||
|
"""After mark_final + record mutation, ledger binds unique approve to 692 head."""
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
marked = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(marked["marked_ready"])
|
||||||
|
mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
terminals = [
|
||||||
|
m
|
||||||
|
for m in lock["live_mutations"]
|
||||||
|
if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS
|
||||||
|
and m.get("pr_number") == PR_B
|
||||||
|
]
|
||||||
|
self.assertEqual(len(terminals), 1)
|
||||||
|
self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B)
|
||||||
|
self.assertEqual(
|
||||||
|
srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692
|
||||||
|
)
|
||||||
|
# Same-head duplicate still blocked.
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionRestartWriterPid(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_writer_pid_change_does_not_inherit_foreign_block(self):
|
||||||
|
# session_pid 60615 origin, writer later 25883 — still foreign isolation.
|
||||||
|
_seed([RC_688], session_pid=60615, writer_pid=25883)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
PR_B, "mark_ready", expected_head_sha=HEAD_692
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -108,13 +108,24 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
side_effect=self._git_state(valid_worktree),
|
side_effect=self._git_state(valid_worktree),
|
||||||
):
|
):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue_comment(
|
res = srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="evidence comment",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
)
|
)
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn("stable control checkout", str(exc))
|
||||||
|
else:
|
||||||
|
# #683 typed blocker at mutation entrypoint
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertFalse(res.get("performed"))
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
"stable control checkout" in blob
|
||||||
|
or res.get("blocker_kind")
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("exact_next_action") or res.get("reasons"))
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@@ -184,14 +195,24 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
side_effect=self._subprocess(valid_worktree, outside_worktree),
|
side_effect=self._subprocess(valid_worktree, outside_worktree),
|
||||||
):
|
):
|
||||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue_comment(
|
res = srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="evidence comment",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=outside_worktree,
|
worktree_path=outside_worktree,
|
||||||
)
|
)
|
||||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn(
|
||||||
|
"does not belong to the target repository",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
blob = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertIn(
|
||||||
|
"does not belong to the target repository", blob
|
||||||
|
)
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
|
|||||||
@@ -2540,15 +2540,18 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
lock = _load_review_decision_lock() or {}
|
lock = _load_review_decision_lock() or {}
|
||||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||||
_save_review_decision_lock(lock)
|
_save_review_decision_lock(lock)
|
||||||
r = gitea_authorize_review_correction(
|
with patch("mcp_server.api_request", return_value={"id": 9001}):
|
||||||
prior_review_id=42,
|
r = gitea_authorize_review_correction(
|
||||||
prior_review_state="approve",
|
prior_review_id=42,
|
||||||
reason="typo",
|
prior_review_state="approve",
|
||||||
operator_authorized=True,
|
reason="typo",
|
||||||
)
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
|
)
|
||||||
self.assertTrue(r["authorized"])
|
self.assertTrue(r["authorized"])
|
||||||
lock = _load_review_decision_lock()
|
lock = _load_review_decision_lock()
|
||||||
self.assertTrue(lock["correction_authorized"])
|
self.assertTrue(lock["correction_authorized"])
|
||||||
|
self.assertEqual(lock["correction_pr_number"], 8)
|
||||||
|
|
||||||
def test_authorize_review_correction_mismatch(self):
|
def test_authorize_review_correction_mismatch(self):
|
||||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||||
@@ -2562,6 +2565,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
||||||
@@ -2572,10 +2576,22 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="request_changes",
|
prior_review_state="request_changes",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
||||||
|
|
||||||
|
# Cross-PR unlock refused (#693)
|
||||||
|
r = gitea_authorize_review_correction(
|
||||||
|
prior_review_id=42,
|
||||||
|
prior_review_state="approve",
|
||||||
|
reason="unlock other pr",
|
||||||
|
operator_authorized=True,
|
||||||
|
target_pr_number=692,
|
||||||
|
)
|
||||||
|
self.assertFalse(r["authorized"])
|
||||||
|
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||||
|
|
||||||
def test_authorize_review_correction_requires_operator_auth(self):
|
def test_authorize_review_correction_requires_operator_auth(self):
|
||||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||||
lock = _load_review_decision_lock() or {}
|
lock = _load_review_decision_lock() or {}
|
||||||
@@ -2588,6 +2604,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=False,
|
operator_authorized=False,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
||||||
@@ -2645,6 +2662,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="operator approved correcting mistaken approve",
|
reason="operator approved correcting mistaken approve",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
_mark_request_changes_ready(remote="prgs")
|
_mark_request_changes_ready(remote="prgs")
|
||||||
second = gitea_submit_pr_review(
|
second = gitea_submit_pr_review(
|
||||||
|
|||||||
@@ -76,13 +76,17 @@ class TestPreflightReadSurvival(unittest.TestCase):
|
|||||||
self.assertIn("task mismatch", str(ctx.exception))
|
self.assertIn("task mismatch", str(ctx.exception))
|
||||||
|
|
||||||
def test_capability_consumed_after_mutation_gate(self):
|
def test_capability_consumed_after_mutation_gate(self):
|
||||||
|
# Use reconciler/close_pr so this purity-order test does not require a
|
||||||
|
# branches/ worktree (author create_issue would hit #274/#683 guards).
|
||||||
|
# Test isolation stays explicit; production author guards remain live
|
||||||
|
# under force-on (see tests/test_issue_683_workflow_scope_guards.py).
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check(
|
mcp_server.record_preflight_check(
|
||||||
"capability", resolved_role="author", resolved_task="create_issue"
|
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||||
)
|
)
|
||||||
mcp_server.verify_preflight_purity(task="create_issue")
|
mcp_server.verify_preflight_purity(task="close_pr")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
mcp_server.verify_preflight_purity(task="create_issue")
|
mcp_server.verify_preflight_purity(task="close_pr")
|
||||||
self.assertIn("has not been resolved", str(ctx.exception))
|
self.assertIn("has not been resolved", str(ctx.exception))
|
||||||
|
|
||||||
def test_whoami_recovery_after_violation_clears_capability(self):
|
def test_whoami_recovery_after_violation_clears_capability(self):
|
||||||
|
|||||||
@@ -86,9 +86,21 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
srv._preflight_resolved_role = "author"
|
srv._preflight_resolved_role = "author"
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
try:
|
||||||
srv.gitea_create_issue(title="Test", body="body")
|
res = srv.gitea_create_issue(title="Test", body="body")
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
except RuntimeError as exc:
|
||||||
|
self.assertIn("stable control checkout", str(exc))
|
||||||
|
else:
|
||||||
|
# #683 typed blocker at mutation entrypoint
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
blob = " ".join(res.get("reasons") or []) + str(
|
||||||
|
res.get("blocker_kind") or ""
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
"stable control checkout" in blob
|
||||||
|
or "missing_issue_worktree" in blob
|
||||||
|
or "control checkout" in blob.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -141,13 +141,21 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
|||||||
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
||||||
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("os.path.isdir", return_value=True)
|
||||||
|
@patch("os.path.exists", return_value=True)
|
||||||
|
@patch("subprocess.run")
|
||||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
||||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
||||||
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
||||||
def test_reviewer_from_branches_worktree_allowed(
|
def test_reviewer_from_branches_worktree_allowed(
|
||||||
self, mock_ctx, mock_git, _remote_sha, _porcelain,
|
self, mock_ctx, mock_git, _remote_sha, _porcelain, mock_run, _exists, _isdir,
|
||||||
):
|
):
|
||||||
|
import unittest.mock
|
||||||
|
mock_run.return_value = unittest.mock.MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||||
|
)
|
||||||
srv._preflight_capability_baseline_porcelain = ""
|
srv._preflight_capability_baseline_porcelain = ""
|
||||||
mock_ctx.return_value = {
|
mock_ctx.return_value = {
|
||||||
"workspace_path": BRANCHES_WORKTREE,
|
"workspace_path": BRANCHES_WORKTREE,
|
||||||
@@ -161,6 +169,5 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -188,20 +188,32 @@ class TestActiveHardStopPreserved(unittest.TestCase):
|
|||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
def test_open_approve_still_blocks_other_pr_mark_ready(self):
|
def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self):
|
||||||
_seed([APPROVED_OPEN])
|
_seed([APPROVED_OPEN]) # approve on PR #700
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
|
# Same PR still hard-stopped for mark_ready.
|
||||||
self.assertTrue(reasons)
|
reasons_same = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
self.assertIn("#332", reasons[0])
|
700, "mark_ready"
|
||||||
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
|
)
|
||||||
|
self.assertTrue(reasons_same)
|
||||||
|
self.assertIn("#332", reasons_same[0])
|
||||||
|
# #693: other PR may mark_ready without cleanup.
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
def test_request_changes_still_blocks_everything(self):
|
def test_request_changes_still_blocks_same_pr(self):
|
||||||
_seed([RC_OPEN])
|
_seed([RC_OPEN]) # request_changes on PR #701
|
||||||
for op in ("merge", "mark_ready", "review"):
|
for op in ("merge", "mark_ready", "review"):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
mcp_server.terminal_review_hard_stop_reasons(701, op),
|
||||||
msg=op,
|
msg=op,
|
||||||
)
|
)
|
||||||
|
# Foreign PR review path is open (#693).
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||||
|
|
||||||
Extends the single-terminal-decision machinery (#211): after a live
|
Extends the single-terminal-decision machinery (#211): after a live
|
||||||
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
|
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
|
||||||
for that same PR may continue; a different PR can never be reviewed or
|
only the merge sequence for that same PR may continue; #693 allows a
|
||||||
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
|
*different* PR to be mark_ready/review in the same durable lock (cross-PR
|
||||||
suppressed.
|
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
@@ -15,7 +15,10 @@ import mcp_server
|
|||||||
HEAD_SHA = "a" * 40
|
HEAD_SHA = "a" * 40
|
||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False):
|
def _lock(mutations=None, correction=False, correction_pr=None):
|
||||||
|
mutations = list(mutations or [])
|
||||||
|
if correction and correction_pr is None and mutations:
|
||||||
|
correction_pr = mutations[-1].get("pr_number")
|
||||||
return {
|
return {
|
||||||
"task": "review_pr",
|
"task": "review_pr",
|
||||||
"remote": "prgs",
|
"remote": "prgs",
|
||||||
@@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False):
|
|||||||
"ready_remote": None,
|
"ready_remote": None,
|
||||||
"ready_org": None,
|
"ready_org": None,
|
||||||
"ready_repo": None,
|
"ready_repo": None,
|
||||||
"live_mutations": list(mutations or []),
|
"live_mutations": mutations,
|
||||||
"correction_authorized": correction,
|
"correction_authorized": correction,
|
||||||
"correction_reason": None,
|
"correction_reason": "test" if correction else None,
|
||||||
|
"correction_pr_number": correction_pr if correction else None,
|
||||||
|
"correction_head_sha": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||||
|
|
||||||
def test_request_changes_blocks_everything(self):
|
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
|
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||||
for op in ("merge", "mark_ready", "review"):
|
for op in ("merge", "mark_ready", "review"):
|
||||||
for pr in (5, 6):
|
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
|
self.assertTrue(reasons, f"{op}/5")
|
||||||
self.assertTrue(reasons, f"{op}/{pr}")
|
self.assertIn("request_changes on PR #5", reasons[0])
|
||||||
self.assertIn("request_changes on PR #5", reasons[0])
|
self.assertIn("#332", reasons[0])
|
||||||
self.assertIn("#332", reasons[0])
|
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||||
|
# Merge of an unapproved other PR remains blocked by hard-stop path
|
||||||
|
# (last terminal is not approve of PR 6).
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||||
|
|
||||||
def test_approve_allows_same_pr_merge_only(self):
|
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||||
_seed([APPROVED_A])
|
_seed([APPROVED_A])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||||
self.assertTrue(
|
# #693 cross-PR isolation: other PR formal review path is open.
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
self.assertEqual(
|
||||||
self.assertTrue(
|
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||||
|
|
||||||
def test_correction_reopens_review_path_only(self):
|
def test_correction_reopens_review_path_only(self):
|
||||||
_seed([RC_A], correction=True)
|
_seed([RC_A], correction=True)
|
||||||
@@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
|||||||
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
||||||
# correction never opens a cross-PR merge
|
# scoped correction never opens a cross-PR merge
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||||
|
# unscoped/cross-PR correction must not lift PR 6 via correction alone
|
||||||
|
# (PR 6 is allowed by isolation, not by correction scope)
|
||||||
|
|
||||||
|
|
||||||
class TestMergeHardStopWiring(unittest.TestCase):
|
class TestMergeHardStopWiring(unittest.TestCase):
|
||||||
@@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
|||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
result = mcp_server.gitea_mark_final_review_decision(
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
pr_number=6, action="approve", remote="prgs")
|
pr_number=5, action="approve", remote="prgs",
|
||||||
|
expected_head_sha=HEAD_SHA)
|
||||||
self.assertFalse(result["marked_ready"])
|
self.assertFalse(result["marked_ready"])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("#332" in r for r in result["reasons"]), result["reasons"])
|
any("#332" in r for r in result["reasons"]), result["reasons"])
|
||||||
|
|
||||||
|
def test_mark_ready_other_pr_allowed_after_foreign_terminal(self):
|
||||||
|
"""#693: foreign open-PR terminal must not block mark_final on PR B."""
|
||||||
|
_seed([RC_A])
|
||||||
|
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
||||||
|
patch("mcp_server._pr_work_lease_reviewer_block",
|
||||||
|
return_value=no_lease), \
|
||||||
|
patch.object(mcp_server, "gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_SHA, "eligible": True}):
|
||||||
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="approve", remote="prgs",
|
||||||
|
expected_head_sha=HEAD_SHA)
|
||||||
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
|
||||||
|
|
||||||
def _feedback(blocking, stale=False, success=True):
|
def _feedback(blocking, stale=False, success=True):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -100,7 +100,23 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
|||||||
srv._preflight_in_test_mode = self._orig_in_test
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
def test_runtime_context_and_guard_share_resolved_workspace(self):
|
@mock.patch("subprocess.run")
|
||||||
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
@mock.patch("os.path.exists", return_value=True)
|
||||||
|
def test_runtime_context_and_guard_share_resolved_workspace(
|
||||||
|
self, _exists, _isdir, mock_run
|
||||||
|
):
|
||||||
|
def run_side_effect(cmd, *args, **kwargs):
|
||||||
|
res = MagicMock(returncode=0)
|
||||||
|
if "--git-common-dir" in cmd:
|
||||||
|
res.stdout = f"{CONTROL_ROOT}/.git\n"
|
||||||
|
elif "--show-toplevel" in cmd:
|
||||||
|
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
||||||
|
res.stdout = f"{cwd}\n"
|
||||||
|
else:
|
||||||
|
res.stdout = f"{CONTROL_ROOT}\n"
|
||||||
|
return res
|
||||||
|
mock_run.side_effect = run_side_effect
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
||||||
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ from merged_cleanup_reconcile import (
|
|||||||
read_issue_lock,
|
read_issue_lock,
|
||||||
read_local_worktree_state,
|
read_local_worktree_state,
|
||||||
)
|
)
|
||||||
|
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||||
_REVIEW_WORKTREE_RE = re.compile(
|
|
||||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
CLASSIFICATIONS = frozenset({
|
CLASSIFICATIONS = frozenset({
|
||||||
"active-pr",
|
"active-pr",
|
||||||
@@ -147,7 +143,7 @@ def classify_entry(
|
|||||||
if not record:
|
if not record:
|
||||||
return "orphan", "Directory exists but is not a registered git worktree"
|
return "orphan", "Directory exists but is not a registered git worktree"
|
||||||
|
|
||||||
if record.get("detached") and _REVIEW_WORKTREE_RE.search(rel_path):
|
if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
|
||||||
return "detached-review", "Detached HEAD in reviewer/simulation worktree"
|
return "detached-review", "Detached HEAD in reviewer/simulation worktree"
|
||||||
|
|
||||||
if state.get("exists") and state.get("clean"):
|
if state.get("exists") and state.get("clean"):
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""Workflow scope ownership and production-guard hardening (#683).
|
||||||
|
|
||||||
|
Implements fail-closed enforcement so sessions cannot:
|
||||||
|
|
||||||
|
* mutate source/tests on the root/control checkout (including temporary
|
||||||
|
diagnostic edits) without binding an issue-backed ``branches/`` worktree;
|
||||||
|
* continue out-of-scope source work while locked to a different issue;
|
||||||
|
* disable, skip, or conceal production root/branches/porcelain guards solely
|
||||||
|
because pytest/unittest is loaded.
|
||||||
|
|
||||||
|
This module is pure assessment + small durable ledger helpers. Callers gather
|
||||||
|
live facts (lock, branch, porcelain, worktree path) and pass them in. Existing
|
||||||
|
root_checkout_guard / author_mutation_worktree assessors remain authoritative;
|
||||||
|
this module composes typed blockers with exact recovery actions.
|
||||||
|
|
||||||
|
Do **not** reintroduce the rejected #681 / ``300a4ca`` patterns:
|
||||||
|
|
||||||
|
* early-return from workspace verification under ``_preflight_in_test_mode()``
|
||||||
|
* porcelain filtering that strips ``*.py`` lines under pytest
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import author_mutation_worktree
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
|
||||||
|
# ── force-on / test isolation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# When set, production root/branches/scope guards MUST run even under pytest.
|
||||||
|
# Unit tests that only need preflight-order isolation leave this unset and
|
||||||
|
# use GITEA_TEST_PORCELAIN / fixtures; real-entrypoint proof sets this to "1".
|
||||||
|
FORCE_PRODUCTION_GUARDS_ENV = "GITEA_TEST_FORCE_PRODUCTION_GUARDS"
|
||||||
|
|
||||||
|
# Existing force signals also mean "exercise production dirtiness paths".
|
||||||
|
_FORCE_DIRTY_ENV = "GITEA_TEST_FORCE_DIRTY"
|
||||||
|
_FORCE_PORCELAIN_ENV = "GITEA_TEST_PORCELAIN"
|
||||||
|
|
||||||
|
# ── typed blocker kinds ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BLOCKER_ROOT_DIAGNOSTIC_EDIT = "root_diagnostic_edit"
|
||||||
|
BLOCKER_MISSING_ISSUE_SCOPE = "missing_issue_scope"
|
||||||
|
BLOCKER_OUT_OF_SCOPE_ISSUE = "out_of_scope_issue"
|
||||||
|
BLOCKER_MISSING_WORKTREE = "missing_issue_worktree"
|
||||||
|
BLOCKER_UNRECORDED_FAILURE = "unrecorded_workflow_failure"
|
||||||
|
BLOCKER_PRODUCTION_GUARD = "production_guard_violation"
|
||||||
|
|
||||||
|
BLOCKER_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
BLOCKER_ROOT_DIAGNOSTIC_EDIT,
|
||||||
|
BLOCKER_MISSING_ISSUE_SCOPE,
|
||||||
|
BLOCKER_OUT_OF_SCOPE_ISSUE,
|
||||||
|
BLOCKER_MISSING_WORKTREE,
|
||||||
|
BLOCKER_UNRECORDED_FAILURE,
|
||||||
|
BLOCKER_PRODUCTION_GUARD,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_NEXT_ACTIONS: dict[str, str] = {
|
||||||
|
BLOCKER_ROOT_DIAGNOSTIC_EDIT: (
|
||||||
|
"Stop editing the control/root checkout. Preserve or discard root WIP "
|
||||||
|
"durably, restore root to clean master, lock or create the owning issue, "
|
||||||
|
"bind branches/issue-<N>-*, set GITEA_AUTHOR_WORKTREE to that worktree, "
|
||||||
|
"then re-run the mutation."
|
||||||
|
),
|
||||||
|
BLOCKER_MISSING_ISSUE_SCOPE: (
|
||||||
|
"Select or create the owning Gitea issue, claim/lock it "
|
||||||
|
"(gitea_mark_issue + gitea_lock_issue), bind branches/issue-<N>-* "
|
||||||
|
"from clean master, then re-run the mutation from that worktree."
|
||||||
|
),
|
||||||
|
BLOCKER_OUT_OF_SCOPE_ISSUE: (
|
||||||
|
"Stop. The active issue lock does not own this work. Release or finish "
|
||||||
|
"the current issue lease, then select/create and lock the correct "
|
||||||
|
"owning issue, bind its branches/issue-<N>-* worktree, and re-run."
|
||||||
|
),
|
||||||
|
BLOCKER_MISSING_WORKTREE: (
|
||||||
|
"Bind an issue-backed worktree under branches/ (scripts/worktree-start "
|
||||||
|
"or git worktree add branches/issue-<N>-*), set GITEA_AUTHOR_WORKTREE / "
|
||||||
|
"worktree_path to that path, keep the control checkout clean on master, "
|
||||||
|
"then re-run the mutation."
|
||||||
|
),
|
||||||
|
BLOCKER_UNRECORDED_FAILURE: (
|
||||||
|
"Record the workflow/tool failure durably first (issue comment or "
|
||||||
|
"workflow_scope_guard.record_workflow_failure), then continue only "
|
||||||
|
"inside the owning issue-backed worktree."
|
||||||
|
),
|
||||||
|
BLOCKER_PRODUCTION_GUARD: (
|
||||||
|
"Resolve the production guard violation: clean or isolate the control "
|
||||||
|
"checkout, bind the owning issue worktree under branches/, and re-run "
|
||||||
|
"with production guards active."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ISSUE_IN_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
# In-process durable failure ledger (also written via optional sink callback).
|
||||||
|
_ledger_lock = threading.Lock()
|
||||||
|
_failure_ledger: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionGuardError(RuntimeError):
|
||||||
|
"""Fail-closed production guard with typed blocker metadata (#683)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
blocker_kind: str,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
reasons: list[str] | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
kind = (blocker_kind or "").strip()
|
||||||
|
if kind not in BLOCKER_KINDS:
|
||||||
|
kind = BLOCKER_PRODUCTION_GUARD
|
||||||
|
self.blocker_kind = kind
|
||||||
|
self.exact_next_action = (
|
||||||
|
(exact_next_action or "").strip() or _NEXT_ACTIONS[kind]
|
||||||
|
)
|
||||||
|
self.reasons = list(reasons or [message])
|
||||||
|
self.details = dict(details or {})
|
||||||
|
|
||||||
|
|
||||||
|
def production_guards_forced() -> bool:
|
||||||
|
"""True when the explicit #683 force-on flag requests production guards."""
|
||||||
|
return (os.environ.get(FORCE_PRODUCTION_GUARDS_ENV) or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def purity_order_forced() -> bool:
|
||||||
|
"""True when tests force preflight-order dirtiness paths (legacy flags)."""
|
||||||
|
if os.environ.get(_FORCE_DIRTY_ENV):
|
||||||
|
return True
|
||||||
|
# GITEA_TEST_PORCELAIN present (even empty) means dirtiness paths are live.
|
||||||
|
if os.environ.get(_FORCE_PORCELAIN_ENV) is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def production_guards_active(*, in_test_mode: bool) -> bool:
|
||||||
|
"""Whether production root/branches/scope guards must execute.
|
||||||
|
|
||||||
|
Production (non-test) always active. Under pytest, active when either the
|
||||||
|
explicit #683 force-on flag or legacy dirty/porcelain force signals are
|
||||||
|
set — never skip production enforcement solely because tests are running
|
||||||
|
when force-on is requested.
|
||||||
|
"""
|
||||||
|
if production_guards_forced() or purity_order_forced():
|
||||||
|
return True
|
||||||
|
return not bool(in_test_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_issue_number_from_branch(branch_name: str | None) -> int | None:
|
||||||
|
"""Return the first issue-N number embedded in a branch name, if any."""
|
||||||
|
text = (branch_name or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = _ISSUE_IN_BRANCH_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(match.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_source_or_test_path(path: str) -> bool:
|
||||||
|
"""True for tracked source/test paths that must not land as root WIP."""
|
||||||
|
p = (path or "").replace("\\", "/").lstrip("./")
|
||||||
|
if not p:
|
||||||
|
return False
|
||||||
|
if p.startswith("tests/") or "/tests/" in f"/{p}":
|
||||||
|
return True
|
||||||
|
if p.endswith((".py", ".pyi", ".toml", ".cfg", ".ini", ".sh")):
|
||||||
|
return True
|
||||||
|
if p in {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def dirty_source_files(porcelain_status: str) -> list[str]:
|
||||||
|
"""Tracked dirty paths that count as source/test contamination."""
|
||||||
|
dirty = parse_dirty_tracked_files(porcelain_status or "")
|
||||||
|
return [p for p in dirty if is_source_or_test_path(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def assess_issue_scope_ownership(
|
||||||
|
*,
|
||||||
|
locked_issue_number: int | None,
|
||||||
|
target_issue_number: int | None = None,
|
||||||
|
branch_name: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
require_lock_for_author: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when the session issue lock does not own the attempted work.
|
||||||
|
|
||||||
|
* Author sessions that require a lock fail when none is held.
|
||||||
|
* When a lock exists, the target issue (tool argument) and/or the issue
|
||||||
|
number embedded in the branch must match the locked issue.
|
||||||
|
* Reviewer/merger/reconciler roles are not issue-scope owners of author
|
||||||
|
implementation work and skip the author lock requirement.
|
||||||
|
"""
|
||||||
|
role = (role_kind or "").strip().lower()
|
||||||
|
locked = locked_issue_number
|
||||||
|
if isinstance(locked, str) and locked.isdigit():
|
||||||
|
locked = int(locked)
|
||||||
|
if locked is not None:
|
||||||
|
try:
|
||||||
|
locked = int(locked)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
locked = None
|
||||||
|
|
||||||
|
target = target_issue_number
|
||||||
|
if target is not None:
|
||||||
|
try:
|
||||||
|
target = int(target)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
target = None
|
||||||
|
|
||||||
|
branch_issue = extract_issue_number_from_branch(branch_name)
|
||||||
|
reasons: list[str] = []
|
||||||
|
blocker_kind: str | None = None
|
||||||
|
|
||||||
|
# Non-author roles do not take author issue locks for implementation.
|
||||||
|
if role in {"reviewer", "merger", "reconciler"}:
|
||||||
|
return _scope_ok(locked, target, branch_issue)
|
||||||
|
|
||||||
|
if require_lock_for_author and locked is None:
|
||||||
|
reasons.append(
|
||||||
|
"no owning issue lock is bound for this author session; "
|
||||||
|
"source/test mutation requires selecting or creating an owning issue first"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_MISSING_ISSUE_SCOPE
|
||||||
|
|
||||||
|
if locked is not None and target is not None and locked != target:
|
||||||
|
reasons.append(
|
||||||
|
f"session is locked to issue #{locked} but mutation targets issue "
|
||||||
|
f"#{target}; out-of-scope until the owning issue is selected"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE
|
||||||
|
|
||||||
|
if locked is not None and branch_issue is not None and locked != branch_issue:
|
||||||
|
reasons.append(
|
||||||
|
f"session is locked to issue #{locked} but workspace branch is for "
|
||||||
|
f"issue #{branch_issue}; bind the matching issue-backed worktree"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
kind = blocker_kind or BLOCKER_MISSING_ISSUE_SCOPE
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"blocker_kind": kind,
|
||||||
|
"exact_next_action": _NEXT_ACTIONS[kind],
|
||||||
|
"reasons": reasons,
|
||||||
|
"locked_issue_number": locked,
|
||||||
|
"target_issue_number": target,
|
||||||
|
"branch_issue_number": branch_issue,
|
||||||
|
}
|
||||||
|
return _scope_ok(locked, target, branch_issue)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_root_source_mutation(
|
||||||
|
*,
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
porcelain_status: str,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
locked_issue_number: int | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
||||||
|
|
||||||
|
Allowed only when the active workspace is under ``branches/``. Dirty
|
||||||
|
tracked source/test files on the control checkout always block, including
|
||||||
|
temporary/diagnostic/test-only intent.
|
||||||
|
"""
|
||||||
|
role = (role_kind or "").strip().lower()
|
||||||
|
if role == "reconciler":
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
"dirty_source_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
|
under_branches = author_mutation_worktree.is_path_under_branches(workspace, root)
|
||||||
|
dirty_src = dirty_source_files(porcelain_status)
|
||||||
|
reasons: list[str] = []
|
||||||
|
blocker_kind: str | None = None
|
||||||
|
|
||||||
|
if not under_branches and workspace == root and dirty_src:
|
||||||
|
# Root workspace with source dirtiness is unattributed root WIP.
|
||||||
|
# (Clean-root author binding is enforced by branches-only #274.)
|
||||||
|
reasons.append(
|
||||||
|
"control/root checkout has tracked source or test edits "
|
||||||
|
f"(dirty files: {', '.join(dirty_src)}); diagnostic or temporary "
|
||||||
|
"edits on the root checkout are forbidden"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
|
||||||
|
if (
|
||||||
|
not under_branches
|
||||||
|
and workspace == root
|
||||||
|
and not dirty_src
|
||||||
|
and role == "author"
|
||||||
|
):
|
||||||
|
# Explicit missing-worktree signal for force-on author entrypoints.
|
||||||
|
reasons.append(
|
||||||
|
"author source/test mutation from the stable control checkout is "
|
||||||
|
"forbidden; bind an issue-backed worktree under branches/ first"
|
||||||
|
)
|
||||||
|
blocker_kind = BLOCKER_MISSING_WORKTREE
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"blocker_kind": kind,
|
||||||
|
"exact_next_action": _NEXT_ACTIONS[kind],
|
||||||
|
"reasons": reasons,
|
||||||
|
"dirty_source_files": dirty_src,
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"locked_issue_number": locked_issue_number,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
"dirty_source_files": dirty_src,
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"locked_issue_number": locked_issue_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_production_mutation_guards(
|
||||||
|
*,
|
||||||
|
workspace_path: str,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
porcelain_status: str,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
locked_issue_number: int | None = None,
|
||||||
|
target_issue_number: int | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
require_author_lock: bool = False,
|
||||||
|
in_test_mode: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compose root + scope production guards when they must be active (#683)."""
|
||||||
|
if not production_guards_active(in_test_mode=in_test_mode):
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"skip_reason": "production guards not active (test isolation without force-on)",
|
||||||
|
}
|
||||||
|
|
||||||
|
root_assess = assess_root_source_mutation(
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=canonical_repo_root,
|
||||||
|
porcelain_status=porcelain_status,
|
||||||
|
current_branch=current_branch,
|
||||||
|
locked_issue_number=locked_issue_number,
|
||||||
|
role_kind=role_kind,
|
||||||
|
)
|
||||||
|
if root_assess["block"]:
|
||||||
|
return {**root_assess, "skipped": False}
|
||||||
|
|
||||||
|
scope_assess = assess_issue_scope_ownership(
|
||||||
|
locked_issue_number=locked_issue_number,
|
||||||
|
target_issue_number=target_issue_number,
|
||||||
|
branch_name=current_branch,
|
||||||
|
role_kind=role_kind,
|
||||||
|
require_lock_for_author=require_author_lock,
|
||||||
|
)
|
||||||
|
if scope_assess["block"]:
|
||||||
|
return {**scope_assess, "skipped": False}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"root": root_assess,
|
||||||
|
"scope": scope_assess,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def raise_if_blocked(assessment: dict[str, Any]) -> None:
|
||||||
|
"""Raise :class:`ProductionGuardError` when *assessment* blocks."""
|
||||||
|
if not assessment or not assessment.get("block"):
|
||||||
|
return
|
||||||
|
kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
|
||||||
|
reasons = list(assessment.get("reasons") or ["production guard violation"])
|
||||||
|
message = (
|
||||||
|
f"Workflow scope guard (#683) [{kind}]: {'; '.join(reasons)}. "
|
||||||
|
f"exact_next_action: {assessment.get('exact_next_action') or _NEXT_ACTIONS.get(kind, '')}"
|
||||||
|
)
|
||||||
|
raise ProductionGuardError(
|
||||||
|
message,
|
||||||
|
blocker_kind=kind,
|
||||||
|
exact_next_action=assessment.get("exact_next_action"),
|
||||||
|
reasons=reasons,
|
||||||
|
details={
|
||||||
|
k: v
|
||||||
|
for k, v in assessment.items()
|
||||||
|
if k
|
||||||
|
not in {
|
||||||
|
"proven",
|
||||||
|
"block",
|
||||||
|
"blocker_kind",
|
||||||
|
"exact_next_action",
|
||||||
|
"reasons",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def block_response(
|
||||||
|
assessment: dict[str, Any] | ProductionGuardError | None = None,
|
||||||
|
*,
|
||||||
|
blocker_kind: str | None = None,
|
||||||
|
reasons: list[str] | None = None,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured fail-closed tool response with typed blocker fields."""
|
||||||
|
if isinstance(assessment, ProductionGuardError):
|
||||||
|
kind = assessment.blocker_kind
|
||||||
|
reason_list = list(assessment.reasons)
|
||||||
|
next_action = assessment.exact_next_action
|
||||||
|
extra = {**assessment.details, **extra}
|
||||||
|
elif isinstance(assessment, dict) and assessment.get("block"):
|
||||||
|
kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
|
||||||
|
reason_list = list(assessment.get("reasons") or [])
|
||||||
|
next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get(
|
||||||
|
kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kind = (blocker_kind or BLOCKER_PRODUCTION_GUARD).strip()
|
||||||
|
if kind not in BLOCKER_KINDS:
|
||||||
|
kind = BLOCKER_PRODUCTION_GUARD
|
||||||
|
reason_list = list(reasons or ["production guard violation"])
|
||||||
|
next_action = exact_next_action or _NEXT_ACTIONS[kind]
|
||||||
|
|
||||||
|
if kind not in BLOCKER_KINDS:
|
||||||
|
kind = BLOCKER_PRODUCTION_GUARD
|
||||||
|
if not reason_list:
|
||||||
|
reason_list = ["production guard violation"]
|
||||||
|
next_action = (next_action or "").strip() or _NEXT_ACTIONS[kind]
|
||||||
|
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"blocker_kind": kind,
|
||||||
|
"exact_next_action": next_action,
|
||||||
|
"reasons": reason_list,
|
||||||
|
}
|
||||||
|
for key, value in extra.items():
|
||||||
|
if key not in out and value is not None:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def format_production_guard_error(assessment: dict[str, Any]) -> str:
|
||||||
|
"""Single RuntimeError string carrying kind + exact next action."""
|
||||||
|
kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD
|
||||||
|
reasons = "; ".join(assessment.get("reasons") or ["production guard violation"])
|
||||||
|
next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get(
|
||||||
|
kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Workflow scope guard (#683) [{kind}]: {reasons}. "
|
||||||
|
f"exact_next_action: {next_action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── durable failure recording ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def record_workflow_failure(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
detail: str,
|
||||||
|
issue_number: int | None = None,
|
||||||
|
task: str | None = None,
|
||||||
|
sink: Any | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Record a workflow/tool failure before source edits continue (#683 AC8).
|
||||||
|
|
||||||
|
*sink* may be a callable ``sink(record)`` (e.g. tests) or omitted for the
|
||||||
|
in-process ledger only. Returns the durable record.
|
||||||
|
"""
|
||||||
|
record = {
|
||||||
|
"kind": (kind or "workflow_failure").strip() or "workflow_failure",
|
||||||
|
"detail": (detail or "").strip(),
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"task": task,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
}
|
||||||
|
with _ledger_lock:
|
||||||
|
_failure_ledger.append(dict(record))
|
||||||
|
if callable(sink):
|
||||||
|
sink(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def clear_workflow_failure_ledger() -> None:
|
||||||
|
"""Test helper: reset the in-process failure ledger."""
|
||||||
|
with _ledger_lock:
|
||||||
|
_failure_ledger.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_failure_ledger() -> list[dict[str, Any]]:
|
||||||
|
"""Copy of durable in-process failure records."""
|
||||||
|
with _ledger_lock:
|
||||||
|
return [dict(r) for r in _failure_ledger]
|
||||||
|
|
||||||
|
|
||||||
|
def assess_durable_failure_recorded(
|
||||||
|
*,
|
||||||
|
require_record: bool,
|
||||||
|
pending_source_mutation: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Block source mutation when a workflow failure was not recorded first."""
|
||||||
|
if not require_record or not pending_source_mutation:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
with _ledger_lock:
|
||||||
|
has_record = bool(_failure_ledger)
|
||||||
|
if has_record:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"blocker_kind": BLOCKER_UNRECORDED_FAILURE,
|
||||||
|
"exact_next_action": _NEXT_ACTIONS[BLOCKER_UNRECORDED_FAILURE],
|
||||||
|
"reasons": [
|
||||||
|
"workflow/tool failure triggered a need for source changes but no "
|
||||||
|
"durable failure record exists yet"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def porcelain_preserves_python_paths(porcelain_status: str) -> bool:
|
||||||
|
"""Regression helper: dirty ``*.py`` lines must remain visible (#683)."""
|
||||||
|
text = porcelain_status or ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.endswith(".py") or ".py " in stripped or stripped.endswith(".py"):
|
||||||
|
# Any py path present proves no silent strip of all *.py lines.
|
||||||
|
if " M " in f" {stripped}" or stripped[:1] in "MADRCTU" or len(line) >= 4:
|
||||||
|
return True
|
||||||
|
# Empty porcelain is fine; integrity means we did not strip when present.
|
||||||
|
return ".py" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_pytest_porcelain_filter(source_text: str) -> list[str]:
|
||||||
|
"""Static check: production reader must not strip ``*.py`` under pytest."""
|
||||||
|
findings: list[str] = []
|
||||||
|
lowered = source_text or ""
|
||||||
|
if "endswith(\".py\")" in lowered or "endswith('.py')" in lowered:
|
||||||
|
if "pytest" in lowered and "porcelain" in lowered.lower():
|
||||||
|
findings.append(
|
||||||
|
"production porcelain reader must not filter *.py under pytest "
|
||||||
|
"(rejected 300a4ca pattern)"
|
||||||
|
)
|
||||||
|
if "if \"pytest\" in sys.modules" in lowered and "porcelain" in lowered.lower():
|
||||||
|
if ".py" in lowered and ("join" in lowered or "endswith" in lowered):
|
||||||
|
findings.append(
|
||||||
|
"test-mode porcelain filtering of source files is forbidden (#683)"
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_ok(
|
||||||
|
locked: int | None,
|
||||||
|
target: int | None,
|
||||||
|
branch_issue: int | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"blocker_kind": None,
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"reasons": [],
|
||||||
|
"locked_issue_number": locked,
|
||||||
|
"target_issue_number": target,
|
||||||
|
"branch_issue_number": branch_issue,
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
||||||
from reviewer_worktree import parse_dirty_tracked_files
|
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE
|
||||||
|
|
||||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
||||||
@@ -508,10 +508,8 @@ DISPOSITIONS = frozenset({
|
|||||||
"unsafe_unknown",
|
"unsafe_unknown",
|
||||||
})
|
})
|
||||||
|
|
||||||
REVIEW_WORKTREE_RE = re.compile(
|
|
||||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: str) -> str:
|
def normalize_path(path: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user