Merge pull request 'feat(profiles): support reconciler cleanup capability and migration (Closes #687)' (#688) from feat/issue-687-reconciler-branch-delete into master
This commit was merged in pull request #688.
This commit is contained in:
+654
-62
@@ -7586,6 +7586,65 @@ def gitea_delete_branch(
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
# Possessing gitea.branch.delete alone is not enough for arbitrary deletion.
|
||||
# task_capability_map maps delete_branch → author; reconciler must use the
|
||||
# guarded cleanup_merged_pr_branch path only (#687 / #514).
|
||||
profile = get_profile()
|
||||
active_role = _profile_role_kind(profile)
|
||||
required_role = task_capability_map.required_role("delete_branch")
|
||||
if active_role == "reconciler":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_role_kind": required_role,
|
||||
"active_role_kind": active_role,
|
||||
"reasons": [
|
||||
"reconciler profile cannot use raw gitea_delete_branch; "
|
||||
"use gitea_cleanup_merged_pr_branch for a fully merged PR "
|
||||
"source branch only (fail closed)"
|
||||
],
|
||||
"exact_next_action": (
|
||||
"Call gitea_cleanup_merged_pr_branch with pr_number, the "
|
||||
"exact PR head branch, and confirmation "
|
||||
"'CLEANUP MERGED PR <n> BRANCH <branch>' after capability "
|
||||
"resolve for cleanup_merged_pr_branch."
|
||||
),
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
if active_role != required_role:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_role_kind": required_role,
|
||||
"active_role_kind": active_role,
|
||||
"reasons": [
|
||||
f"Active profile role '{active_role}' cannot perform "
|
||||
f"{required_role} task 'delete_branch' even when "
|
||||
"gitea.branch.delete is present (fail closed)"
|
||||
],
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
if branch_cleanup_guard.is_preservation_or_evidence_branch(branch):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
f"branch '{branch}' is a preservation/evidence branch and "
|
||||
"cannot be deleted (fail closed)"
|
||||
],
|
||||
}
|
||||
if branch in branch_cleanup_guard.PROTECTED_BRANCHES:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [f"branch '{branch}' is protected (fail closed)"],
|
||||
}
|
||||
|
||||
audit_allowed, audit_reasons = (
|
||||
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
|
||||
)
|
||||
@@ -7628,41 +7687,49 @@ 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 = _role_kind(
|
||||
profile.get("allowed_operations", []),
|
||||
profile.get("forbidden_operations", []),
|
||||
)
|
||||
if active_role == "reviewer":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
"reviewer profile is not authorized for merged branch cleanup "
|
||||
"(fail closed)"
|
||||
active_role = _profile_role_kind(profile)
|
||||
# cleanup_merged_pr_branch is reconciler-owned (task_capability_map).
|
||||
# Author/reviewer/merger must not reach this path even if they somehow
|
||||
# hold gitea.branch.delete.
|
||||
if active_role != "reconciler":
|
||||
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,
|
||||
@@ -7680,16 +7747,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 = {
|
||||
@@ -7719,19 +7788,86 @@ 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_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 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",
|
||||
)
|
||||
|
||||
import urllib.parse
|
||||
|
||||
encoded_branch = urllib.parse.quote(head_branch, safe="")
|
||||
url = f"{base}/branches/{encoded_branch}"
|
||||
request_metadata = {
|
||||
"branch": head_branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"cleanup_path": "gitea_cleanup_merged_pr_branch",
|
||||
"ownership_checked": True,
|
||||
"ownership_blocking_categories": [],
|
||||
}
|
||||
with _audited(
|
||||
"cleanup_merged_pr_branch",
|
||||
host=h,
|
||||
@@ -7740,36 +7876,416 @@ def gitea_cleanup_merged_pr_branch(
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
target_branch=head_branch,
|
||||
request_metadata={
|
||||
"branch": head_branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"cleanup_path": "gitea_cleanup_merged_pr_branch",
|
||||
},
|
||||
request_metadata=request_metadata,
|
||||
):
|
||||
api_request("DELETE", url, auth)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.",
|
||||
"assessment": assessment,
|
||||
|
||||
# #687: authoritative post-delete readback. DELETE success alone is not
|
||||
# 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": verified_absent,
|
||||
"error_class": (readback_assessment.get("readback") or {}).get(
|
||||
"error_class"
|
||||
),
|
||||
"not_found_scope": (readback_assessment.get("readback") or {}).get(
|
||||
"not_found_scope"
|
||||
),
|
||||
}
|
||||
if gitea_audit.audit_enabled():
|
||||
_audit(
|
||||
"cleanup_merged_pr_branch_readback",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
result=(
|
||||
gitea_audit.SUCCEEDED
|
||||
if readback_assessment.get("ok")
|
||||
else gitea_audit.FAILED
|
||||
),
|
||||
reason="; ".join(readback_assessment.get("reasons") or []) or None,
|
||||
request_metadata=request_metadata,
|
||||
pr_number=pr_number,
|
||||
target_branch=head_branch,
|
||||
)
|
||||
|
||||
if not readback_assessment.get("ok"):
|
||||
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")
|
||||
or "post_delete_readback_failed",
|
||||
message=(
|
||||
f"DELETE accepted for '{head_branch}' but post-delete readback "
|
||||
"did not verify absence"
|
||||
),
|
||||
)
|
||||
|
||||
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 branch-scoped post-delete readback."
|
||||
),
|
||||
assessment=assessment,
|
||||
ownership={
|
||||
"block": False,
|
||||
"blocking_categories": [],
|
||||
"checked": True,
|
||||
},
|
||||
readback=readback_assessment.get("readback"),
|
||||
)
|
||||
|
||||
|
||||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||||
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.
|
||||
|
||||
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="")
|
||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
|
||||
try:
|
||||
api_request("GET", url, auth)
|
||||
return True
|
||||
return branch_cleanup_guard.classify_branch_readback_http_status(200)
|
||||
except Exception as exc:
|
||||
message = str(exc).lower()
|
||||
if "404" in message or "not found" in message:
|
||||
return False
|
||||
raise
|
||||
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 branch 404.
|
||||
|
||||
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
|
||||
and probe.get("verified_absent")
|
||||
):
|
||||
return False
|
||||
if status == branch_cleanup_guard.READBACK_EXISTS:
|
||||
return True
|
||||
error_class = probe.get("error_class") or "unexpected"
|
||||
raise RuntimeError(
|
||||
f"remote branch probe failed ({error_class}/{status})"
|
||||
)
|
||||
|
||||
|
||||
def _collect_branch_ownership_records(
|
||||
*,
|
||||
remote: str,
|
||||
host: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
pr_number: int | None,
|
||||
project_root: str,
|
||||
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": 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:
|
||||
for path in issue_lock_store.iter_lock_files():
|
||||
lock = issue_lock_store.read_lock_file(path)
|
||||
if not isinstance(lock, dict):
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") != str(remote)
|
||||
or str(lock.get("org") or "") != str(org)
|
||||
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)
|
||||
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||
status = str(freshness.get("status") or "unknown")
|
||||
if freshness.get("live"):
|
||||
status = "active"
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status=status,
|
||||
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
if freshness.get("live"):
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="author",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Control-plane leases (role-tagged) ---
|
||||
try:
|
||||
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"):
|
||||
try:
|
||||
db = control_plane_db.ControlPlaneDB()
|
||||
except Exception:
|
||||
db = 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,
|
||||
org=org,
|
||||
repo=repo,
|
||||
include_non_active=True,
|
||||
limit=200,
|
||||
)
|
||||
for lease in listed.get("leases") or []:
|
||||
work_kind = str(lease.get("work_kind") or "")
|
||||
work_number = lease.get("work_number")
|
||||
lease_branch = str(
|
||||
lease.get("branch")
|
||||
or lease.get("branch_name")
|
||||
or ""
|
||||
).strip()
|
||||
matches_branch = lease_branch == target_branch
|
||||
matches_pr = (
|
||||
work_kind == "pr"
|
||||
and pr_number is not None
|
||||
and int(work_number or 0) == int(pr_number)
|
||||
)
|
||||
if not (matches_branch or matches_pr):
|
||||
continue
|
||||
if matches_pr and not lease_branch:
|
||||
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
|
||||
)
|
||||
freshness_status = str(
|
||||
(fr.get("freshness") if isinstance(fr, dict) else None)
|
||||
or lease.get("status")
|
||||
or "unknown"
|
||||
)
|
||||
role = str(lease.get("role") or "unknown")
|
||||
category = branch_cleanup_guard.ownership_category_for_role(role)
|
||||
# O2: expired never auto-receives reclaim_allowed=True.
|
||||
if freshness_status == "active":
|
||||
status = "active"
|
||||
reclaim_allowed = False
|
||||
elif freshness_status in {"released", "abandoned"}:
|
||||
status = freshness_status
|
||||
reclaim_allowed = True
|
||||
elif freshness_status == "expired" or (
|
||||
isinstance(fr, dict) and fr.get("expired_by_time")
|
||||
):
|
||||
status = "expired"
|
||||
reclaim_allowed = False # O2 fail closed
|
||||
else:
|
||||
status = freshness_status
|
||||
reclaim_allowed = False
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=category,
|
||||
status=status,
|
||||
reclaim_allowed=reclaim_allowed,
|
||||
role=role,
|
||||
host=lease_host or host_n or host,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# 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:
|
||||
for entry in worktree_cleanup_audit.list_worktrees(project_root):
|
||||
wt_branch = str(entry.get("branch") or "").strip()
|
||||
if wt_branch != target_branch:
|
||||
continue
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=(
|
||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
|
||||
),
|
||||
status="active",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
inventory_error = True
|
||||
records.append(
|
||||
_base_rec(
|
||||
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
status="unknown",
|
||||
reclaim_allowed=False,
|
||||
role="worktree",
|
||||
)
|
||||
)
|
||||
|
||||
return {"records": records, "inventory_error": inventory_error}
|
||||
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -7956,6 +8472,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(
|
||||
@@ -7965,14 +8541,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 [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13202,6 +13792,8 @@ def gitea_resolve_task_capability(
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user