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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user