feat: add merged PR cleanup reconciliation report (#269)

Implement gitea_reconcile_merged_cleanups with dry-run reporting for merged
PR remote branches and local branches/ worktrees, plus confirmation-gated
execute mode. Safety gates cover protected branches, open PR references,
active issue locks, dirty worktrees, and delete_branch capability.

Closes #269

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 00:16:42 -04:00
co-authored by Claude Opus 4.8
parent d6f4f936e3
commit 4cc31a42f9
5 changed files with 687 additions and 0 deletions
+136
View File
@@ -374,6 +374,7 @@ import role_namespace_gate # noqa: E402
import task_capability_map # noqa: E402
import review_proofs # noqa: E402
import issue_lock_worktree # noqa: E402
import merged_cleanup_reconcile # noqa: E402
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
@@ -2859,6 +2860,141 @@ def gitea_delete_branch(
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
import urllib.parse
encoded = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
try:
api_request("GET", url, auth)
return True
except Exception as exc:
message = str(exc).lower()
if "404" in message or "not found" in message:
return False
raise
@mcp.tool()
def gitea_reconcile_merged_cleanups(
dry_run: bool = True,
execute_confirmed: bool = False,
limit: int = 50,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Reconcile merged PRs by reporting and optionally cleaning remote branches and local worktrees.
Args:
dry_run: Defaults to True. When True, only builds the reconciliation report.
execute_confirmed: Must be True when dry_run=False.
limit: Max number of closed PRs to inspect.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with reconciliation report and any executed cleanup actions.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"performed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
if not dry_run and not execute_confirmed:
raise ValueError(
"execute_confirmed must be True when dry_run=False (fail closed)"
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
merged_closed: list[dict] = []
remote_branch_exists: dict[str, bool] = {}
head_on_master: dict[int, bool | None] = {}
master_ref = f"{remote}/master" if remote in REMOTES else "origin/master"
for pr in closed_prs:
if not (pr.get("merged") or pr.get("merged_at")):
continue
merged_closed.append(pr)
head_ref = (pr.get("head") or {}).get("ref") or ""
head_sha = (pr.get("head") or {}).get("sha")
if head_ref and head_ref not in remote_branch_exists:
remote_branch_exists[head_ref] = _remote_branch_exists(
h, o, r, auth, head_ref
)
head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref(
PROJECT_ROOT, head_sha, master_ref
)
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
report = merged_cleanup_reconcile.build_reconciliation_report(
project_root=PROJECT_ROOT,
closed_prs=merged_closed,
open_prs=open_prs,
remote_branch_exists=remote_branch_exists,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
)
if dry_run:
report["dry_run"] = True
report["executed"] = False
return {"success": True, "performed": False, **report}
verify_preflight_purity(remote)
actions: list[dict] = []
for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or ""
remote_assessment = entry.get("remote_branch") or {}
local_assessment = entry.get("local_worktree") or {}
if remote_assessment.get("safe_to_delete_remote"):
import urllib.parse
encoded = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded}"
with _audited(
"delete_branch",
host=h,
remote=remote,
org=o,
repo=r,
target_branch=head_branch,
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
):
api_request("DELETE", url, auth)
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": True,
}
)
if local_assessment.get("safe_to_remove_worktree"):
result = merged_cleanup_reconcile.remove_local_worktree(
PROJECT_ROOT, head_branch
)
actions.append({"action": "remove_local_worktree", **result})
report["dry_run"] = False
report["executed"] = True
report["actions"] = actions
return {"success": True, "performed": True, **report}
@mcp.tool()
def gitea_close_issue(
issue_number: int,