Compare commits

..
Author SHA1 Message Date
sysadmin 1e76ce267c fix: return structured conflict-fix push assessment errors (Closes #519)
Resolve PR via pulls API before fetching lease comments so
gitea_assess_conflict_fix_push fails closed with actionable reasons
instead of surfacing HTTP 500 when Gitea returns not-found errors.

Add regression tests and document assessment-failure handoff in work-issue.
2026-07-08 04:31:11 -04:00
sysadmin 4d24c34548 Merge pull request 'feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)' (#415) from feat/issue-402-post-merge-cleanup-proof into master 2026-07-08 03:13:43 -05:00
sysadmin d6b1e4be3c fix: resolve conflicts for PR #415 2026-07-08 02:20:04 -04:00
sysadminandClaude Opus 4.8 44a7c62dc3 fix: resolve conflicts for PR #415
Rebase onto current prgs/master and fix cleanup-mutations regex so
"Cleanup mutations: none" does not false-positive the proof checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
sysadminandClaude Opus 4.8 d5dc9f2708 feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)
Adds post_merge_cleanup_proof validation so cleanup claims must carry the
branch deletion and worktree removal checklists, or report CLEANUP_SKIPPED
with an exact blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
11 changed files with 729 additions and 173 deletions
-19
View File
@@ -492,25 +492,6 @@ Root-level matches are listed in `.gitignore` so they never get committed.
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
hard blocks) when these artifacts are still present.
## Capability preflight lifetime (#470)
After `gitea_resolve_task_capability(task=…)` proves the mutation is allowed,
interleaved **read-only** calls preserve that proof until a gated mutation
consumes it:
- Safe reads: `gitea_whoami`, `gitea_view_pr`, `gitea_view_issue`, `gitea_list_*`,
`gitea_get_runtime_context`, `gitea_check_pr_eligibility`, and related
read-only inventory/eligibility tools (see `preflight_contract.py`).
- Each mutation consumes the proof once; call `gitea_resolve_task_capability`
again immediately before the next mutation on the same task.
- Resolving capability for a **different** task replaces the prior task binding.
- Workspace edits before resolve, profile switches, or a dirty whoami baseline
invalidate proof (fail closed).
If a mutation fails with “capability has not been resolved” or “task mismatch”,
re-run `gitea_resolve_task_capability(task="<mutation>")` immediately before
retrying — do not guess or skip the resolve step.
Implementation work and review work must use separate branch folders. For
example, an implementation branch might live under
`branches/fix-issue-123-example`, while a review branch for the resulting PR
+16
View File
@@ -12,6 +12,7 @@ import re
from typing import Any, Callable
import issue_lock_provenance
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -1042,6 +1043,20 @@ def _rule_reviewer_review_mutation(
)
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
result = assess_post_merge_cleanup_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.post_merge_cleanup_proof",
result.get("reasons") or [],
field="Cleanup status",
severity="block",
safe_next_action=result.get("safe_next_action")
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
@@ -1071,6 +1086,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_target_branch_freshness,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
_rule_reviewer_post_merge_cleanup_proof,
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
+110 -28
View File
@@ -507,17 +507,7 @@ def verify_preflight_purity(
)
if not _preflight_capability_called:
raise RuntimeError(
preflight_contract.format_missing_capability_error(task)
)
if (
task is not None
and _preflight_resolved_task is not None
and task != _preflight_resolved_task
):
raise RuntimeError(
preflight_contract.format_task_mismatch_error(
_preflight_resolved_task, task
)
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if (
task is not None
@@ -616,7 +606,6 @@ import stacked_pr_support # noqa: E402
import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import preflight_contract # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import reviewer_pr_lease # noqa: E402
@@ -2661,6 +2650,85 @@ def gitea_get_pr_review_feedback(
}
def _fetch_pr_lease_comments_safe(
pr_number: int,
*,
remote: str,
host: str | None,
org: str | None,
repo: str | None,
limit: int = 100,
require_open: bool = False,
) -> dict:
"""Fetch PR thread comments with structured fail-closed errors (#519)."""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
resolved_repo = f"{o}/{r}"
pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
try:
pr = api_request("GET", pr_url, auth)
except RuntimeError as exc:
return {
"success": False,
"comments": [],
"reasons": [
"PR lookup failed before conflict-fix push assessment "
f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): "
f"{_redact(str(exc))}"
],
"pr_lookup": "failed",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
}
pr_state = (pr.get("state") or "").strip().lower()
if require_open and pr_state != "open":
return {
"success": False,
"comments": [],
"reasons": [
f"PR #{pr_number} on {resolved_repo} is not open "
f"(state={pr_state or 'unknown'})"
],
"pr_lookup": "not_open",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
try:
comments = api_request("GET", api, auth)
except RuntimeError as exc:
return {
"success": False,
"comments": [],
"reasons": [
"PR comment fetch failed during conflict-fix push assessment "
f"(pr_number={pr_number}, issue_index={pr_number}, "
f"repo={resolved_repo}, remote={remote}): "
f"{_redact(str(exc))}"
],
"pr_lookup": "ok",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
if not isinstance(comments, list):
comments = []
return {
"success": True,
"comments": list(comments[:limit]),
"reasons": [],
"pr_lookup": "ok",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
def _list_pr_lease_comments(
pr_number: int,
*,
@@ -2671,16 +2739,15 @@ def _list_pr_lease_comments(
limit: int = 100,
) -> list[dict]:
"""Fetch PR/issue thread comments used for reviewer/conflict-fix leases."""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
comments = api_request("GET", api, auth)
# Fail safe to no lease comments when the API returns a non-list payload
# (e.g. an error object such as an HTTP 401 body): lease state can only be
# proven from real comment entries, never inferred from an error shape (#485).
if not isinstance(comments, list):
return []
return list(comments[:limit])
fetched = _fetch_pr_lease_comments_safe(
pr_number,
remote=remote,
host=host,
org=org,
repo=repo,
limit=limit,
)
return fetched["comments"]
def _pr_work_lease_reviewer_block(
@@ -6993,9 +7060,7 @@ def gitea_acquire_conflict_fix_lease(
task_capability_map.required_permission("comment_issue"))
if blocked:
return blocked
verify_preflight_purity(
remote, worktree_path=worktree_path, task="comment_issue"
)
verify_preflight_purity(remote, worktree_path=worktree_path)
comments = _list_pr_lease_comments(
pr_number,
remote=remote,
@@ -7065,22 +7130,39 @@ def gitea_assess_conflict_fix_push(
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
comments = _list_pr_lease_comments(
fetched = _fetch_pr_lease_comments_safe(
pr_number,
remote=remote,
host=host,
org=org,
repo=repo,
require_open=True,
)
return pr_work_lease.assess_conflict_fix_push(
if not fetched["success"]:
return {
"push_allowed": False,
"block": True,
"reasons": fetched["reasons"],
"pr_lookup": fetched.get("pr_lookup"),
"resolved_repo": fetched.get("resolved_repo"),
"remote": fetched.get("remote"),
"pr_number": pr_number,
"assessment_failed": True,
}
assessment = pr_work_lease.assess_conflict_fix_push(
pr_number=pr_number,
comments=comments,
comments=fetched["comments"],
branch_head_before=branch_head_before,
branch_head_after=branch_head_after,
worktree_path=worktree_path,
push_cwd=push_cwd,
is_fast_forward=is_fast_forward,
)
assessment["pr_lookup"] = fetched.get("pr_lookup")
assessment["resolved_repo"] = fetched.get("resolved_repo")
assessment["remote"] = fetched.get("remote")
assessment["live_pr_head_sha"] = fetched.get("head_sha")
return assessment
@mcp.tool()
+239
View File
@@ -0,0 +1,239 @@
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
from __future__ import annotations
import re
from typing import Any
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
_CLEANUP_SECTION_HINT = re.compile(
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
re.IGNORECASE,
)
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
_CLEANUP_BLOCKER_RE = re.compile(
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
re.IGNORECASE,
)
_REMOTE_DELETE_CLAIM_RE = re.compile(
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
r"deleted remote branch|delete_branch)",
re.IGNORECASE,
)
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
r"worktree cleanup performed)",
re.IGNORECASE,
)
_DELETE_CAPABILITY_RE = re.compile(
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
re.IGNORECASE,
)
_DELETE_TASK_RE = re.compile(
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(
r"merge result\s*:\s*(?:merged|success|performed)",
re.IGNORECASE,
)
_MERGE_COMMIT_SHA_RE = re.compile(
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_PR_HEAD_BRANCH_RE = re.compile(
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BRANCH_NOT_PROTECTED_RE = re.compile(
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
re.IGNORECASE,
)
_OPEN_PR_INVENTORY_RE = re.compile(
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
r"no other open pr references branch",
re.IGNORECASE,
)
_ACTIVE_CLAIM_LEASE_RE = re.compile(
r"(?:no active (?:heartbeat|claim|lease)|"
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
re.IGNORECASE,
)
_SESSION_OWNED_WORKTREE_RE = re.compile(
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
_CLEAN_TRACKED_RE = re.compile(
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
re.IGNORECASE,
)
_CLEAN_UNTRACKED_RE = re.compile(
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
re.IGNORECASE,
)
_WORKTREE_LIST_AFTER_RE = re.compile(
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
re.IGNORECASE,
)
_WRONG_BRANCH_RE = re.compile(
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
re.IGNORECASE,
)
def _claims_remote_delete(text: str) -> bool:
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
def _claims_worktree_remove(text: str) -> bool:
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
def _branch_safety_fields_present(text: str) -> list[str]:
missing: list[str] = []
if not _DELETE_CAPABILITY_RE.search(text):
missing.append("delete-branch capability resolved (gitea.branch.delete)")
if not _DELETE_TASK_RE.search(text):
missing.append("delete-branch task named (delete_branch or cleanup)")
if not _MERGE_RESULT_RE.search(text):
missing.append("merge result: merged")
if not _MERGE_COMMIT_SHA_RE.search(text):
missing.append("merge commit SHA")
if not _PR_HEAD_BRANCH_RE.search(text):
missing.append("merged PR head branch / deleted branch name")
if not _BRANCH_NOT_PROTECTED_RE.search(text):
missing.append("branch not protected proof")
if not _OPEN_PR_INVENTORY_RE.search(text):
missing.append("open PR inventory proof (no other PR references branch)")
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
missing.append("no active heartbeat/claim/lease proof")
return missing
def _worktree_cleanup_fields_present(text: str) -> list[str]:
missing: list[str] = []
match = _SESSION_OWNED_WORKTREE_RE.search(text)
path = match.group(1).strip() if match else ""
if not path:
missing.append("session-owned worktree path")
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
missing.append("worktree path under branches/")
if not _CLEAN_TRACKED_RE.search(text):
missing.append("pre-removal tracked state: clean")
if not _CLEAN_UNTRACKED_RE.search(text):
missing.append("pre-removal untracked state: clean")
if not _WORKTREE_LIST_AFTER_RE.search(text):
missing.append("git worktree list after removal")
return missing
def assess_post_merge_cleanup_proof(
report_text: str,
*,
cleanup_session: dict | None = None,
) -> dict[str, Any]:
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
text = report_text or ""
session = dict(cleanup_session or {})
reasons: list[str] = []
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
blocker = (session.get("blocker") or "").strip()
if not blocker:
match = _CLEANUP_BLOCKER_RE.search(text)
blocker = match.group(1).strip() if match else ""
if blocker.upper() == CLEANUP_SKIPPED:
blocker = ""
if not blocker:
reasons.append(
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
)
return {
"block": bool(reasons),
"proven": not reasons,
"outcome": CLEANUP_SKIPPED,
"remote_delete_claimed": False,
"worktree_remove_claimed": False,
"reasons": reasons,
"safe_next_action": (
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
if reasons
else "proceed"
),
}
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
return {
"block": False,
"proven": True,
"outcome": None,
"remote_delete_claimed": False,
"worktree_remove_claimed": False,
"reasons": [],
"safe_next_action": "proceed",
}
remote_delete = bool(
session.get("remote_delete_claimed") or _claims_remote_delete(text)
)
worktree_remove = bool(
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
)
if _WRONG_BRANCH_RE.search(text):
reasons.append(
"cleanup report claims deleted branch that is not the merged PR head branch"
)
if remote_delete:
reasons.extend(
f"remote branch deletion missing {field}"
for field in _branch_safety_fields_present(text)
)
if worktree_remove:
reasons.extend(
f"worktree removal missing {field}"
for field in _worktree_cleanup_fields_present(text)
)
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
pass
if not remote_delete and not worktree_remove:
cleanup_mutations = re.search(
r"cleanup mutations\s*:\s*(?!none\b)\S",
text,
re.IGNORECASE,
)
if cleanup_mutations:
reasons.append(
"cleanup mutations reported without post-merge cleanup proof checklist"
)
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
if remote_delete or worktree_remove:
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
block = bool(reasons)
return {
"block": block,
"proven": not block,
"outcome": outcome,
"remote_delete_claimed": remote_delete,
"worktree_remove_claimed": worktree_remove,
"reasons": reasons,
"safe_next_action": (
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
"checklist before claiming remote delete or worktree removal"
if reasons
else "proceed"
),
}
-66
View File
@@ -1,66 +0,0 @@
"""Capability preflight lifetime contract (#470).
Defines which read-only MCP tools preserve an existing capability proof and the
canonical sequencing operators must follow before mutations.
"""
from __future__ import annotations
# Read-only tools that must not invalidate capability proof (#470).
# gitea_whoami is read-only but re-pins identity; capability is preserved when
# identity was already verified clean (#469).
READ_ONLY_PREFLIGHT_TOOLS = frozenset({
"gitea_whoami",
"gitea_get_authenticated_user",
"gitea_get_current_user",
"gitea_view_pr",
"gitea_view_issue",
"gitea_list_prs",
"gitea_list_issues",
"gitea_list_issue_comments",
"gitea_get_runtime_context",
"gitea_resolve_task_capability",
"gitea_check_pr_eligibility",
"gitea_get_pr_review_feedback",
"gitea_assess_work_issue_duplicate",
"gitea_route_task_session",
"gitea_audit_config",
"gitea_list_labels",
"gitea_get_profile",
"gitea_list_profiles",
})
PREFLIGHT_CONTRACT_SUMMARY = (
"Capability preflight is session-scoped per MCP process, profile, and task. "
"After gitea_resolve_task_capability(task=...), interleaved read-only calls "
"(whoami, view_*, list_*, get_runtime_context, eligibility checks) preserve "
"the proof until a gated mutation consumes it. Each mutation consumes the proof "
"once; re-resolve immediately before the next mutation. A new resolve for a "
"different task replaces the prior task binding. Profile/session changes or "
"workspace edits before resolve invalidate proof."
)
def format_missing_capability_error(task: str | None = None) -> str:
base = (
"Pre-flight order violation: Task capability "
"(gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if task:
return (
f"{base}. Re-run gitea_resolve_task_capability(task=\"{task}\") "
"immediately before this mutation."
)
return (
f"{base}. Re-run gitea_resolve_task_capability for the mutation task "
"immediately before acting."
)
def format_task_mismatch_error(resolved: str, required: str) -> str:
return (
"Pre-flight task mismatch: "
f"resolved '{resolved}' but mutation requires '{required}' (fail closed). "
f"Re-run gitea_resolve_task_capability(task=\"{required}\") "
"immediately before this mutation."
)
@@ -881,6 +881,40 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
If any local artifact is created after final cleanup, run and report a new final status check.
## 28A. Post-merge cleanup proof checklist (#402)
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
### Remote branch deletion checklist
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
* Merge result: merged
* Merge commit SHA: full 40-character SHA
* Merged PR head branch / deleted branch: exact branch name (must match)
* Branch protection: none / branch is not protected
* Open PR inventory proof: no other open PR references the branch
* Active heartbeat/claim/lease: none
### Local worktree removal checklist
When removing session-owned review/simulation worktrees under `branches/`, report:
* Session-owned worktree path: exact path under `branches/`
* Pre-removal tracked state: clean
* Pre-removal untracked state: clean
* Git worktree list after removal: command output or equivalent proof
### Skipped cleanup
If any gate fails, report:
* Cleanup outcome: `CLEANUP_SKIPPED`
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
## 29. Recovery handoff rules
If blocked, produce a recovery handoff with:
@@ -626,9 +626,14 @@ When pushing to an existing PR branch to resolve merge conflicts:
* session worktree path
* push cwd
* whether the push is fast-forward
3. Do not push when a reviewer holds an active lease on the same PR.
4. Do not force-push.
5. Do not push from the main checkout or wrong cwd.
* explicit `remote`, `org`, and `repo` when not using defaults
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
and produce a recovery handoff with the structured `reasons` and
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
unsafe or safe (#519).
4. Do not push when a reviewer holds an active lease on the same PR.
5. Do not force-push.
6. Do not push from the main checkout or wrong cwd.
Conflict-fix final reports must state:
+139
View File
@@ -0,0 +1,139 @@
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_server import ( # noqa: E402
_fetch_pr_lease_comments_safe,
gitea_assess_conflict_fix_push,
)
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "prgs-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.branch.push,gitea.pr.create",
}
HEAD_BEFORE = "dad1dc8d5108ab01ed83065334116d7425a4471c"
HEAD_AFTER = "3f3d6cb35d0fe225dcb236247f2a2d0ec193fa35"
OPEN_PR = {
"number": 508,
"state": "open",
"head": {"sha": HEAD_AFTER},
}
class TestFetchPrLeaseCommentsSafe(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_pr_lookup_404_returns_structured_failure(self, _auth, mock_api):
mock_api.side_effect = RuntimeError(
'HTTP 404: {"message":"issue does not exist","index":508}'
)
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertFalse(result["success"])
self.assertEqual(result["comments"], [])
self.assertTrue(result["reasons"])
self.assertIn("PR lookup failed", result["reasons"][0])
self.assertEqual(result["pr_lookup"], "failed")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_comment_fetch_404_after_pr_lookup(self, _auth, mock_api):
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
raise RuntimeError(
'HTTP 404: {"message":"issue does not exist","index":508}'
)
mock_api.side_effect = _api
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertFalse(result["success"])
self.assertIn("comment fetch failed", result["reasons"][0].lower())
self.assertEqual(result["pr_lookup"], "ok")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_success_returns_comments_and_head_sha(self, _auth, mock_api):
comment = {"id": 1, "body": "<!-- mcp-conflict-fix-lease:v1 -->"}
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
return [comment]
mock_api.side_effect = _api
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertTrue(result["success"])
self.assertEqual(result["comments"], [comment])
self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"])
class TestAssessConflictFixPushTool(unittest.TestCase):
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_returns_structured_block_when_pr_lookup_fails(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": False,
"comments": [],
"reasons": ["PR lookup failed"],
"pr_lookup": "failed",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertFalse(result["push_allowed"])
self.assertTrue(result.get("assessment_failed"))
self.assertEqual(result["pr_lookup"], "failed")
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_valid_push_assessment_when_pr_and_comments_resolve(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": True,
"comments": [],
"reasons": [],
"pr_lookup": "ok",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
"head_sha": HEAD_AFTER,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertTrue(result["push_allowed"])
self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER)
if __name__ == "__main__":
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
"""Tests for post-merge cleanup proof enforcement (#402)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from post_merge_cleanup_proof import ( # noqa: E402
CLEANUP_SKIPPED,
assess_post_merge_cleanup_proof,
)
MERGE_SHA = "a" * 40
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
WORKTREE = "branches/review-pr374-conflicts"
def _full_remote_cleanup_report(**overrides):
fields = {
"Task": "review PR #374",
"Merge result": "merged",
"Merge commit SHA": MERGE_SHA,
"Cleanup status": "remote branch deleted",
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
"Merged PR head branch": HEAD_BRANCH,
"Deleted branch": HEAD_BRANCH,
"Branch protection": "none",
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
"Active heartbeat/claim/lease": "none",
"Cleanup mutations": "gitea_delete_branch on remote head branch",
}
fields.update(overrides)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
def _full_worktree_cleanup_report(**overrides):
fields = {
"Task": "review PR #374",
"Merge result": "merged",
"Cleanup status": "local worktree removed",
"Removed worktree path": WORKTREE,
"Pre-removal tracked state": "clean",
"Pre-removal untracked state": "clean",
"Git worktree list after removal": "only main checkout listed",
"Cleanup mutations": "git worktree remove on session-owned review worktree",
}
fields.update(overrides)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
class TestPostMergeCleanupProof(unittest.TestCase):
def test_no_cleanup_claim_passes(self):
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
result = assess_post_merge_cleanup_proof(report)
self.assertFalse(result["block"])
def test_missing_capability_proof_blocked(self):
report = _full_remote_cleanup_report(
**{"Delete-branch capability resolved": "delete succeeded"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
def test_unmerged_pr_blocked(self):
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
def test_wrong_branch_blocked(self):
report = _full_remote_cleanup_report(
**{"Deleted branch": "feat/other-branch"}
)
report += "\nDeleted branch does not match merged PR head branch"
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_protected_branch_blocked(self):
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_open_pr_reference_blocked(self):
report = _full_remote_cleanup_report(
**{"Open PR inventory proof": "PR #999 still references branch"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_active_claim_blocked(self):
report = _full_remote_cleanup_report(
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_dirty_worktree_blocked(self):
report = _full_worktree_cleanup_report(
**{"Pre-removal tracked state": "dirty"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
def test_foreign_worktree_blocked(self):
report = _full_worktree_cleanup_report(
**{"Removed worktree path": "/tmp/foreign-worktree"}
)
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
def test_cleanup_skipped_with_blocker_passes(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup outcome: CLEANUP_SKIPPED",
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
])
result = assess_post_merge_cleanup_proof(report)
self.assertFalse(result["block"])
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
def test_cleanup_skipped_without_blocker_blocked(self):
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
result = assess_post_merge_cleanup_proof(report)
self.assertTrue(result["block"])
def test_full_remote_cleanup_passes(self):
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
self.assertFalse(result["block"])
self.assertTrue(result["remote_delete_claimed"])
def test_full_worktree_cleanup_passes(self):
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
self.assertFalse(result["block"])
self.assertTrue(result["worktree_remove_claimed"])
def test_validator_integration_blocks_unproven_delete(self):
report = (
"## Controller Handoff\n"
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
)
result = assess_final_report_validator(report, task_kind="review_pr")
self.assertTrue(result["blocked"])
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
def test_validator_integration_allows_skipped(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup outcome: CLEANUP_SKIPPED",
"- Cleanup blocker: branch still referenced by open PR #414",
])
result = assess_final_report_validator(report, task_kind="review_pr")
cleanup_findings = [
f for f in result["findings"]
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
]
self.assertEqual(cleanup_findings, [])
if __name__ == "__main__":
unittest.main()
+13 -3
View File
@@ -16,13 +16,23 @@ AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
}
OPEN_PR = {"number": 12, "state": "open", "head": {"sha": "abc123"}}
def _pr_then_comments(mock_api, comments_payload):
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
return comments_payload
mock_api.side_effect = _api
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
mock_api.return_value = {"message": "Unauthorized"}
_pr_then_comments(mock_api, {"message": "Unauthorized"})
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@@ -30,7 +40,7 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
mock_api.return_value = None
_pr_then_comments(mock_api, None)
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@@ -39,7 +49,7 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
mock_api.return_value = [comment]
_pr_then_comments(mock_api, [comment])
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None, limit=5)
self.assertEqual(result, [comment])
+1 -54
View File
@@ -1,4 +1,4 @@
"""#469/#470: capability preflight lifetime across safe read-only calls."""
"""#469: capability preflight survives interleaved read-only whoami calls."""
import os
import unittest
@@ -101,59 +101,6 @@ class TestPreflightReadSurvival(unittest.TestCase):
)
mcp_server.verify_preflight_purity(task="review_pr")
def test_close_pr_sequence_with_interleaved_reads(self):
"""resolve(close_pr) → whoami/view reads → close_pr gate (#470)."""
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
# Simulate live-state revalidation between resolve and mutation.
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_fresh_capability_resolve_replaces_prior_task(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
def test_missing_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("gitea_resolve_task_capability", msg)
self.assertIn('task="close_pr"', msg)
def test_task_mismatch_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("task mismatch", msg)
self.assertIn('task="close_pr"', msg)
def test_consumed_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
mcp_server.verify_preflight_purity(task="close_pr")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn('task="close_pr"', str(ctx.exception))
if __name__ == "__main__":
unittest.main()