fix(cleanup): harden readback scope and ownership fail-closed (#687)
Require branch-scoped post-delete not-found (not generic/repo/host 404), emit consistent top-level cleanup fields, fail closed on ownership inventory errors, never auto-reclaim expired control-plane leases, include active comment-backed reviewer leases, apply the same gates to reconcile_merged_cleanups, and match ownership with normalized host identity.
This commit is contained in:
+368
-172
@@ -6026,13 +6026,15 @@ def gitea_cleanup_merged_pr_branch(
|
||||
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
|
||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||
if gate_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": gate_reasons,
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
reasons=gate_reasons,
|
||||
permission_report=_permission_block_report("gitea.branch.delete"),
|
||||
)
|
||||
|
||||
profile = get_profile()
|
||||
active_role = _profile_role_kind(profile)
|
||||
@@ -6040,29 +6042,33 @@ def gitea_cleanup_merged_pr_branch(
|
||||
# Author/reviewer/merger must not reach this path even if they somehow
|
||||
# hold gitea.branch.delete.
|
||||
if active_role != "reconciler":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_role_kind": "reconciler",
|
||||
"active_role_kind": active_role,
|
||||
"reasons": [
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
required_role_kind="reconciler",
|
||||
active_role_kind=active_role,
|
||||
reasons=[
|
||||
f"profile role '{active_role}' is not authorized for merged "
|
||||
"branch cleanup; required role is reconciler (fail closed)"
|
||||
],
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
permission_report=_permission_block_report("gitea.branch.delete"),
|
||||
)
|
||||
|
||||
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
required_permission="gitea.branch.delete",
|
||||
reasons=[
|
||||
"merged branch cleanup requires an explicit branches/ worktree "
|
||||
"path; root checkout branch ref mutation is blocked (fail closed)"
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
@@ -6080,16 +6086,18 @@ def gitea_cleanup_merged_pr_branch(
|
||||
target_branch = (pr.get("base") or {}).get("ref") or "master"
|
||||
|
||||
if branch and branch != pr_head.get("ref"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": branch,
|
||||
"reasons": [
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=branch,
|
||||
reasons=[
|
||||
f"requested branch '{branch}' does not match PR head "
|
||||
f"'{pr_head.get('ref')}'"
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
open_heads = {
|
||||
@@ -6119,50 +6127,74 @@ def gitea_cleanup_merged_pr_branch(
|
||||
confirmation=confirmation,
|
||||
)
|
||||
if not assessment["safe_to_delete"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"assessment": assessment,
|
||||
"reasons": assessment["block_reasons"],
|
||||
}
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
reasons=assessment["block_reasons"],
|
||||
)
|
||||
|
||||
# #687: active ownership protection (sessions/leases/worktree bindings).
|
||||
# Do not rely only on the caller worktree living under branches/.
|
||||
ownership_records = _collect_branch_ownership_records(
|
||||
ownership_bundle = _collect_branch_ownership_records(
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_number,
|
||||
project_root=PROJECT_ROOT,
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
ownership_records = ownership_bundle.get("records") or []
|
||||
if ownership_bundle.get("inventory_error"):
|
||||
# O1: control-plane / ownership inventory failure fails closed.
|
||||
ownership_records = list(ownership_records) + [
|
||||
{
|
||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"host": h,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"branch": head_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "inventory",
|
||||
}
|
||||
]
|
||||
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
host=h,
|
||||
records=ownership_records,
|
||||
)
|
||||
if ownership.get("block"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"assessment": assessment,
|
||||
"ownership": {
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=False,
|
||||
delete_acknowledged=False,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": True,
|
||||
"blocking_categories": ownership.get("blocking_categories") or [],
|
||||
"reasons": ownership.get("reasons") or [],
|
||||
"blocker_kind": ownership.get("blocker_kind"),
|
||||
"checked": True,
|
||||
},
|
||||
"reasons": ownership.get("reasons") or [
|
||||
"active ownership protects the target branch"
|
||||
],
|
||||
"blocker_kind": "active_branch_ownership",
|
||||
}
|
||||
reasons=ownership.get("reasons")
|
||||
or ["active ownership protects the target branch"],
|
||||
blocker_kind="active_branch_ownership",
|
||||
)
|
||||
|
||||
import urllib.parse
|
||||
|
||||
@@ -6188,19 +6220,20 @@ def gitea_cleanup_merged_pr_branch(
|
||||
api_request("DELETE", url, auth)
|
||||
|
||||
# #687: authoritative post-delete readback. DELETE success alone is not
|
||||
# enough — only an authoritative not-found proves deletion.
|
||||
# enough — only branch-scoped not-found proves deletion (R1).
|
||||
readback = _probe_remote_branch(h, o, r, auth, head_branch)
|
||||
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
|
||||
verified_absent = bool(readback_assessment.get("verified_absent"))
|
||||
request_metadata["post_delete_readback"] = {
|
||||
"status": (readback_assessment.get("readback") or {}).get("status"),
|
||||
"verified_absent": bool(
|
||||
(readback_assessment.get("readback") or {}).get("verified_absent")
|
||||
),
|
||||
"verified_absent": verified_absent,
|
||||
"error_class": (readback_assessment.get("readback") or {}).get(
|
||||
"error_class"
|
||||
),
|
||||
"not_found_scope": (readback_assessment.get("readback") or {}).get(
|
||||
"not_found_scope"
|
||||
),
|
||||
}
|
||||
# Emit a second audit row capturing readback evidence (no secrets).
|
||||
if gitea_audit.audit_enabled():
|
||||
_audit(
|
||||
"cleanup_merged_pr_branch_readback",
|
||||
@@ -6220,53 +6253,60 @@ def gitea_cleanup_merged_pr_branch(
|
||||
)
|
||||
|
||||
if not readback_assessment.get("ok"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": True,
|
||||
"delete_acknowledged": True,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"assessment": assessment,
|
||||
"ownership": {
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=False,
|
||||
performed=True,
|
||||
delete_acknowledged=True,
|
||||
verified_absent=False,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": False,
|
||||
"blocking_categories": [],
|
||||
"checked": True,
|
||||
},
|
||||
"readback": readback_assessment.get("readback"),
|
||||
"reasons": readback_assessment.get("reasons") or [
|
||||
"post-delete branch readback could not verify deletion"
|
||||
],
|
||||
"blocker_kind": readback_assessment.get("blocker_kind")
|
||||
readback=readback_assessment.get("readback"),
|
||||
reasons=readback_assessment.get("reasons")
|
||||
or ["post-delete branch readback could not verify deletion"],
|
||||
blocker_kind=readback_assessment.get("blocker_kind")
|
||||
or "post_delete_readback_failed",
|
||||
"message": (
|
||||
message=(
|
||||
f"DELETE accepted for '{head_branch}' but post-delete readback "
|
||||
"did not verify absence"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"message": (
|
||||
return branch_cleanup_guard.cleanup_result_envelope(
|
||||
success=True,
|
||||
performed=True,
|
||||
delete_acknowledged=True,
|
||||
verified_absent=True,
|
||||
pr_number=pr_number,
|
||||
branch=head_branch,
|
||||
message=(
|
||||
f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
|
||||
"and verified absent via post-delete readback."
|
||||
"and verified absent via branch-scoped post-delete readback."
|
||||
),
|
||||
"assessment": assessment,
|
||||
"ownership": {
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": False,
|
||||
"blocking_categories": [],
|
||||
"checked": True,
|
||||
},
|
||||
"readback": readback_assessment.get("readback"),
|
||||
}
|
||||
readback=readback_assessment.get("readback"),
|
||||
)
|
||||
|
||||
|
||||
def _probe_remote_branch(
|
||||
h: str, o: str, r: str, auth: str, branch: str
|
||||
) -> dict:
|
||||
"""GET a remote branch and return a secret-free structured readback."""
|
||||
"""GET a remote branch and return a secret-free structured readback.
|
||||
|
||||
R1: On HTTP 404, re-probe the repository endpoint. Only when the repo is
|
||||
still reachable is the 404 treated as branch-scoped absence. Generic,
|
||||
repository-scoped, or host-level 404 never sets verified_absent.
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
encoded = urllib.parse.quote(branch, safe="")
|
||||
@@ -6275,23 +6315,56 @@ def _probe_remote_branch(
|
||||
api_request("GET", url, auth)
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(200)
|
||||
except Exception as exc:
|
||||
return branch_cleanup_guard.classify_branch_readback_exception(exc)
|
||||
status = branch_cleanup_guard._extract_http_status(exc)
|
||||
if status != 404:
|
||||
return branch_cleanup_guard.classify_branch_readback_exception(exc)
|
||||
|
||||
# Distinguish branch vs repository/host 404 via repo reachability.
|
||||
repo_url = repo_api_url(h, o, r)
|
||||
try:
|
||||
api_request("GET", repo_url, auth)
|
||||
# Repo reachable → branch-scoped not-found.
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_BRANCH,
|
||||
)
|
||||
except Exception as repo_exc:
|
||||
repo_status = branch_cleanup_guard._extract_http_status(repo_exc)
|
||||
if repo_status == 404:
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_REPOSITORY,
|
||||
)
|
||||
if repo_status in (401, 407):
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
401
|
||||
)
|
||||
if repo_status == 403:
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
403
|
||||
)
|
||||
# Host/transport/unknown: not branch-verified.
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(
|
||||
404,
|
||||
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||||
"""True when the remote branch exists; False on authoritative 404.
|
||||
"""True when the remote branch exists; False on authoritative branch 404.
|
||||
|
||||
Authentication, authorization, transport, and unexpected failures are
|
||||
re-raised (or returned as structured failures by callers that use
|
||||
``_probe_remote_branch``) rather than treated as absence.
|
||||
Authentication, authorization, transport, and ambiguous 404 failures are
|
||||
raised rather than treated as absence.
|
||||
"""
|
||||
probe = _probe_remote_branch(h, o, r, auth, branch)
|
||||
status = probe.get("status")
|
||||
if status == branch_cleanup_guard.READBACK_NOT_FOUND:
|
||||
if (
|
||||
status == branch_cleanup_guard.READBACK_NOT_FOUND
|
||||
and probe.get("verified_absent")
|
||||
):
|
||||
return False
|
||||
if status == branch_cleanup_guard.READBACK_EXISTS:
|
||||
return True
|
||||
# Preserve structured failure modes for callers that need them.
|
||||
error_class = probe.get("error_class") or "unexpected"
|
||||
raise RuntimeError(
|
||||
f"remote branch probe failed ({error_class}/{status})"
|
||||
@@ -6301,23 +6374,44 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo
|
||||
def _collect_branch_ownership_records(
|
||||
*,
|
||||
remote: str,
|
||||
host: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
pr_number: int | None,
|
||||
project_root: str,
|
||||
) -> list[dict]:
|
||||
auth: str | None = None,
|
||||
base_api: str | None = None,
|
||||
) -> dict:
|
||||
"""Gather canonical ownership records for *branch* (secret-free).
|
||||
|
||||
Sources:
|
||||
- author issue locks (task-session / lease files)
|
||||
- control-plane leases (author/reviewer/merger/controller/reconciler)
|
||||
- comment-backed active reviewer leases (O3)
|
||||
- local worktree bindings checked out to the branch
|
||||
|
||||
Returns ``{"records": [...], "inventory_error": bool}``. Control-plane
|
||||
inventory errors set inventory_error=True so callers fail closed (O1).
|
||||
"""
|
||||
records: list[dict] = []
|
||||
inventory_error = False
|
||||
target_branch = (branch or "").strip()
|
||||
if not target_branch:
|
||||
return records
|
||||
return {"records": records, "inventory_error": False}
|
||||
|
||||
host_n = branch_cleanup_guard.normalize_host(host)
|
||||
|
||||
def _base_rec(**kwargs):
|
||||
rec = {
|
||||
"remote": remote,
|
||||
"host": host_n or host,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
}
|
||||
rec.update(kwargs)
|
||||
return rec
|
||||
|
||||
# --- Author issue locks (session + lease) ---
|
||||
try:
|
||||
@@ -6331,6 +6425,12 @@ def _collect_branch_ownership_records(
|
||||
or str(lock.get("repo") or "") != str(repo)
|
||||
):
|
||||
continue
|
||||
# Host identity when present on the lock
|
||||
lock_host = branch_cleanup_guard.normalize_host(
|
||||
lock.get("host") or lock.get("host_name")
|
||||
)
|
||||
if host_n and lock_host and host_n != lock_host:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target_branch:
|
||||
continue
|
||||
freshness = issue_lock_store.assess_lock_freshness(lock)
|
||||
@@ -6339,58 +6439,53 @@ def _collect_branch_ownership_records(
|
||||
if freshness.get("live"):
|
||||
status = "active"
|
||||
records.append(
|
||||
{
|
||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"status": status,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": bool(reclaim.get("reclaim_allowed")),
|
||||
"role": "author",
|
||||
}
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status=status,
|
||||
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
# Session pointer binding (same record, distinct category when live)
|
||||
if freshness.get("live"):
|
||||
records.append(
|
||||
{
|
||||
"category": (
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
|
||||
),
|
||||
"status": "active",
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "author",
|
||||
}
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Fail closed: if lock inventory cannot be read, invent a sticky block.
|
||||
inventory_error = True
|
||||
records.append(
|
||||
{
|
||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "author",
|
||||
}
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Control-plane leases (role-tagged) ---
|
||||
try:
|
||||
db = control_plane_db.get_db() if hasattr(control_plane_db, "get_db") else None
|
||||
db = None
|
||||
if hasattr(control_plane_db, "get_db"):
|
||||
try:
|
||||
db = control_plane_db.get_db()
|
||||
except Exception:
|
||||
db = None
|
||||
if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
|
||||
# Best-effort default path used by runtime tools.
|
||||
try:
|
||||
db = control_plane_db.ControlPlaneDB()
|
||||
except TypeError:
|
||||
except Exception:
|
||||
db = None
|
||||
if db is not None:
|
||||
inventory_error = True
|
||||
if db is None:
|
||||
# O1: unavailable control-plane inventory fails closed.
|
||||
inventory_error = True
|
||||
else:
|
||||
listed = lease_lifecycle.list_active_leases(
|
||||
db,
|
||||
remote=remote,
|
||||
@@ -6416,11 +6511,14 @@ def _collect_branch_ownership_records(
|
||||
if not (matches_branch or matches_pr):
|
||||
continue
|
||||
if matches_pr and not lease_branch:
|
||||
# PR-scoped lease without explicit branch still protects
|
||||
# the merged PR source branch being cleaned.
|
||||
lease_branch = target_branch
|
||||
if lease_branch != target_branch:
|
||||
continue
|
||||
lease_host = branch_cleanup_guard.normalize_host(
|
||||
lease.get("host") or lease.get("host_name")
|
||||
)
|
||||
if host_n and lease_host and host_n != lease_host:
|
||||
continue
|
||||
fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
|
||||
lease
|
||||
)
|
||||
@@ -6431,11 +6529,7 @@ def _collect_branch_ownership_records(
|
||||
)
|
||||
role = str(lease.get("role") or "unknown")
|
||||
category = branch_cleanup_guard.ownership_category_for_role(role)
|
||||
reclaim_allowed = freshness_status in {
|
||||
"released",
|
||||
"abandoned",
|
||||
"expired",
|
||||
} and freshness_status != "active"
|
||||
# O2: expired never auto-receives reclaim_allowed=True.
|
||||
if freshness_status == "active":
|
||||
status = "active"
|
||||
reclaim_allowed = False
|
||||
@@ -6446,27 +6540,61 @@ def _collect_branch_ownership_records(
|
||||
isinstance(fr, dict) and fr.get("expired_by_time")
|
||||
):
|
||||
status = "expired"
|
||||
# Sticky if session still alive with worktree — unknown here
|
||||
# defaults to reclaimable when status is expired by time.
|
||||
reclaim_allowed = True
|
||||
reclaim_allowed = False # O2 fail closed
|
||||
else:
|
||||
status = freshness_status
|
||||
reclaim_allowed = False
|
||||
records.append(
|
||||
{
|
||||
"category": category,
|
||||
"status": status,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": reclaim_allowed,
|
||||
"role": role,
|
||||
}
|
||||
_base_rec(
|
||||
category=category,
|
||||
status=status,
|
||||
reclaim_allowed=reclaim_allowed,
|
||||
role=role,
|
||||
host=lease_host or host_n or host,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Soft: control-plane may be unavailable in unit tests / offline.
|
||||
pass
|
||||
# O1: fail closed on control-plane inventory errors.
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="control_plane",
|
||||
)
|
||||
)
|
||||
|
||||
# --- O3: comment-backed active reviewer leases ---
|
||||
if pr_number is not None and auth and base_api:
|
||||
try:
|
||||
comments = api_get_all(
|
||||
f"{base_api}/issues/{int(pr_number)}/comments", auth
|
||||
)
|
||||
active = reviewer_pr_lease.find_active_reviewer_lease(
|
||||
comments, pr_number=int(pr_number)
|
||||
)
|
||||
if active:
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="reviewer",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="reviewer_comment_lease",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Worktree bindings checked out to the target branch ---
|
||||
try:
|
||||
@@ -6475,34 +6603,28 @@ def _collect_branch_ownership_records(
|
||||
if wt_branch != target_branch:
|
||||
continue
|
||||
records.append(
|
||||
{
|
||||
"category": (
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
|
||||
),
|
||||
"status": "active",
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "worktree",
|
||||
}
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
{
|
||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"branch": target_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "worktree",
|
||||
}
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
return {"records": records, "inventory_error": inventory_error}
|
||||
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -6689,6 +6811,66 @@ def gitea_reconcile_merged_cleanups(
|
||||
if remote_assessment.get("safe_to_delete_remote"):
|
||||
import urllib.parse
|
||||
|
||||
pr_num = entry.get("pr_number")
|
||||
try:
|
||||
pr_num_int = int(pr_num) if pr_num is not None else None
|
||||
except (TypeError, ValueError):
|
||||
pr_num_int = None
|
||||
ownership_bundle = _collect_branch_ownership_records(
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
pr_number=pr_num_int,
|
||||
project_root=PROJECT_ROOT,
|
||||
auth=auth,
|
||||
base_api=base,
|
||||
)
|
||||
ownership_records = list(ownership_bundle.get("records") or [])
|
||||
if ownership_bundle.get("inventory_error"):
|
||||
ownership_records.append(
|
||||
{
|
||||
"category": (
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR
|
||||
),
|
||||
"status": "unknown",
|
||||
"remote": remote,
|
||||
"host": h,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"branch": head_branch,
|
||||
"reclaim_allowed": False,
|
||||
"role": "inventory",
|
||||
}
|
||||
)
|
||||
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
branch=head_branch,
|
||||
host=h,
|
||||
records=ownership_records,
|
||||
)
|
||||
if ownership.get("block"):
|
||||
actions.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"branch": head_branch,
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"delete_acknowledged": False,
|
||||
"verified_absent": False,
|
||||
"blocker_kind": "active_branch_ownership",
|
||||
"reasons": ownership.get("reasons") or [],
|
||||
"blocking_categories": ownership.get(
|
||||
"blocking_categories"
|
||||
)
|
||||
or [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
encoded = urllib.parse.quote(head_branch, safe="")
|
||||
url = f"{base}/branches/{encoded}"
|
||||
with _audited(
|
||||
@@ -6698,14 +6880,28 @@ def gitea_reconcile_merged_cleanups(
|
||||
org=o,
|
||||
repo=r,
|
||||
target_branch=head_branch,
|
||||
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
|
||||
request_metadata={
|
||||
"branch": head_branch,
|
||||
"source": "reconcile_merged_cleanups",
|
||||
"ownership_checked": True,
|
||||
},
|
||||
):
|
||||
api_request("DELETE", url, auth)
|
||||
readback = _probe_remote_branch(h, o, r, auth, head_branch)
|
||||
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(
|
||||
readback
|
||||
)
|
||||
verified = bool(readback_assessment.get("verified_absent"))
|
||||
actions.append(
|
||||
{
|
||||
"action": "delete_remote_branch",
|
||||
"branch": head_branch,
|
||||
"success": True,
|
||||
"success": bool(readback_assessment.get("ok")),
|
||||
"performed": True,
|
||||
"delete_acknowledged": True,
|
||||
"verified_absent": verified,
|
||||
"readback": readback_assessment.get("readback"),
|
||||
"reasons": readback_assessment.get("reasons") or [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user