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:
+199
-95
@@ -126,6 +126,13 @@ READBACK_AUTHENTICATION = "authentication_error"
|
|||||||
READBACK_AUTHORIZATION = "authorization_error"
|
READBACK_AUTHORIZATION = "authorization_error"
|
||||||
READBACK_TRANSPORT = "transport_error"
|
READBACK_TRANSPORT = "transport_error"
|
||||||
READBACK_UNEXPECTED = "unexpected_response"
|
READBACK_UNEXPECTED = "unexpected_response"
|
||||||
|
READBACK_AMBIGUOUS_404 = "ambiguous_not_found"
|
||||||
|
|
||||||
|
# Scope of a 404: only branch-scoped absence may set verified_absent.
|
||||||
|
NOT_FOUND_SCOPE_BRANCH = "branch"
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY = "repository"
|
||||||
|
NOT_FOUND_SCOPE_HOST = "host"
|
||||||
|
NOT_FOUND_SCOPE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
@@ -139,6 +146,7 @@ OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
|
|||||||
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
|
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
|
||||||
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
|
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
|
||||||
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
|
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
|
||||||
|
OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error"
|
||||||
|
|
||||||
_ROLE_TO_OWNERSHIP_CATEGORY = {
|
_ROLE_TO_OWNERSHIP_CATEGORY = {
|
||||||
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
@@ -162,21 +170,66 @@ def _norm_str(value: Any) -> str:
|
|||||||
return str(value or "").strip()
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_host(host: str | None) -> str:
|
||||||
|
"""Normalize a host identity for ownership matching (no credentials)."""
|
||||||
|
text = _norm_str(host).lower()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if text.startswith(prefix):
|
||||||
|
text = text[len(prefix) :]
|
||||||
|
# Drop path/query if a full URL slipped through.
|
||||||
|
text = text.split("/", 1)[0]
|
||||||
|
text = text.split("?", 1)[0]
|
||||||
|
return text.rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def ownership_category_for_role(role: str | None) -> str:
|
def ownership_category_for_role(role: str | None) -> str:
|
||||||
"""Map a role kind to a non-secret ownership category label."""
|
"""Map a role kind to a non-secret ownership category label."""
|
||||||
key = _norm_str(role).lower()
|
key = _norm_str(role).lower()
|
||||||
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
|
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
|
||||||
|
|
||||||
|
|
||||||
def classify_branch_readback_http_status(status_code: int | None) -> dict[str, Any]:
|
def classify_branch_readback_http_status(
|
||||||
"""Classify a GET-branch HTTP status into a secret-free readback result."""
|
status_code: int | None,
|
||||||
|
*,
|
||||||
|
not_found_scope: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a GET-branch HTTP status into a secret-free readback result.
|
||||||
|
|
||||||
|
R1: A bare/generic/repository/wrong-host 404 never yields
|
||||||
|
``verified_absent=True``. Only an authoritative *branch-scoped* not-found
|
||||||
|
(``not_found_scope='branch'``) may verify deletion.
|
||||||
|
"""
|
||||||
if status_code == 404:
|
if status_code == 404:
|
||||||
|
scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
if scope == NOT_FOUND_SCOPE_BRANCH:
|
||||||
|
return {
|
||||||
|
"status": READBACK_NOT_FOUND,
|
||||||
|
"error_class": None,
|
||||||
|
"verified_absent": True,
|
||||||
|
"branch_present": False,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
# repository / host / unknown / generic 404 — not verified absence
|
||||||
|
reason = {
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY: (
|
||||||
|
"post-delete readback 404 is repository-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
NOT_FOUND_SCOPE_HOST: (
|
||||||
|
"post-delete readback 404 is host-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
}.get(
|
||||||
|
scope,
|
||||||
|
"post-delete readback 404 is ambiguous (not branch-scoped); "
|
||||||
|
"cannot verify absence",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": READBACK_NOT_FOUND,
|
"status": READBACK_AMBIGUOUS_404,
|
||||||
"error_class": None,
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
"verified_absent": True,
|
"verified_absent": False,
|
||||||
"branch_present": False,
|
"branch_present": None,
|
||||||
"reasons": [],
|
"not_found_scope": scope,
|
||||||
|
"reasons": [reason],
|
||||||
}
|
}
|
||||||
if status_code in (401, 407):
|
if status_code in (401, 407):
|
||||||
return {
|
return {
|
||||||
@@ -220,70 +273,73 @@ def classify_branch_readback_http_status(status_code: int | None) -> dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def classify_branch_readback_exception(exc: BaseException) -> dict[str, Any]:
|
def _extract_http_status(exc: BaseException) -> int | None:
|
||||||
"""Classify a GET-branch exception without leaking credentials or bodies.
|
"""Extract an HTTP status code from an exception chain (secret-free)."""
|
||||||
|
|
||||||
Prefer typed HTTP status when present on the exception chain; fall back to
|
|
||||||
a narrow ``HTTP <code>`` prefix parse. Never includes response bodies,
|
|
||||||
tokens, or raw exception text in the returned payload.
|
|
||||||
"""
|
|
||||||
status_code: int | None = None
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
current: BaseException | None = exc
|
current: BaseException | None = exc
|
||||||
while current is not None and id(current) not in seen:
|
while current is not None and id(current) not in seen:
|
||||||
seen.add(id(current))
|
seen.add(id(current))
|
||||||
code = getattr(current, "code", None)
|
for attr in ("code", "status", "status_code"):
|
||||||
if isinstance(code, int):
|
value = getattr(current, attr, None)
|
||||||
status_code = code
|
if isinstance(value, int) and 100 <= value <= 599:
|
||||||
break
|
return value
|
||||||
status = getattr(current, "status", None)
|
text = str(current) if current is not None else ""
|
||||||
if isinstance(status, int):
|
|
||||||
status_code = status
|
|
||||||
break
|
|
||||||
status_code_attr = getattr(current, "status_code", None)
|
|
||||||
if isinstance(status_code_attr, int):
|
|
||||||
status_code = status_code_attr
|
|
||||||
break
|
|
||||||
current = current.__cause__ or current.__context__
|
|
||||||
|
|
||||||
if status_code is None:
|
|
||||||
# Narrow, non-secret parse of our own RuntimeError shape: "HTTP 404: ..."
|
|
||||||
text = str(exc) if exc is not None else ""
|
|
||||||
match = re.match(r"HTTP\s+(\d{3})\b", text)
|
match = re.match(r"HTTP\s+(\d{3})\b", text)
|
||||||
if match:
|
if match:
|
||||||
status_code = int(match.group(1))
|
return int(match.group(1))
|
||||||
else:
|
current = current.__cause__ or current.__context__
|
||||||
lower = text.lower()
|
return None
|
||||||
if "not found" in lower or "404" in lower:
|
|
||||||
status_code = 404
|
|
||||||
elif "unauthorized" in lower or "401" in lower:
|
def classify_branch_readback_exception(
|
||||||
status_code = 401
|
exc: BaseException,
|
||||||
elif "forbidden" in lower or "403" in lower:
|
*,
|
||||||
status_code = 403
|
not_found_scope: str | None = None,
|
||||||
elif any(
|
) -> dict[str, Any]:
|
||||||
token in lower
|
"""Classify a GET-branch exception without leaking credentials or bodies.
|
||||||
for token in (
|
|
||||||
"timed out",
|
R1: substring ``404`` / ``not found`` alone never becomes verified_absent.
|
||||||
"timeout",
|
Callers must pass ``not_found_scope='branch'`` only after authoritative
|
||||||
"connection",
|
proof that the repository/host is still reachable and the 404 is branch-level.
|
||||||
"network",
|
"""
|
||||||
"temporarily unavailable",
|
status_code = _extract_http_status(exc)
|
||||||
"name or service not known",
|
if status_code is None:
|
||||||
"nodename nor servname",
|
lower = str(exc).lower() if exc is not None else ""
|
||||||
)
|
if any(
|
||||||
):
|
token in lower
|
||||||
return {
|
for token in (
|
||||||
"status": READBACK_TRANSPORT,
|
"timed out",
|
||||||
"error_class": ERROR_CLASS_TRANSPORT,
|
"timeout",
|
||||||
"verified_absent": False,
|
"connection",
|
||||||
"branch_present": None,
|
"network",
|
||||||
"reasons": [
|
"temporarily unavailable",
|
||||||
"post-delete branch readback transport/upstream failure"
|
"name or service not known",
|
||||||
],
|
"nodename nor servname",
|
||||||
}
|
)
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"status": READBACK_TRANSPORT,
|
||||||
|
"error_class": ERROR_CLASS_TRANSPORT,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": [
|
||||||
|
"post-delete branch readback transport/upstream failure"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if "unauthorized" in lower:
|
||||||
|
status_code = 401
|
||||||
|
elif "forbidden" in lower:
|
||||||
|
status_code = 403
|
||||||
|
elif "404" in lower or "not found" in lower:
|
||||||
|
# Ambiguous: do NOT treat as branch absence without scope proof.
|
||||||
|
status_code = 404
|
||||||
|
if not_found_scope is None:
|
||||||
|
not_found_scope = NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
|
||||||
if status_code is not None:
|
if status_code is not None:
|
||||||
return classify_branch_readback_http_status(status_code)
|
return classify_branch_readback_http_status(
|
||||||
|
status_code, not_found_scope=not_found_scope
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": READBACK_UNEXPECTED,
|
"status": READBACK_UNEXPECTED,
|
||||||
@@ -297,21 +353,26 @@ def classify_branch_readback_exception(exc: BaseException) -> dict[str, Any]:
|
|||||||
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
|
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Decide whether DELETE success may be reported after branch readback.
|
"""Decide whether DELETE success may be reported after branch readback.
|
||||||
|
|
||||||
Success requires an authoritative not-found readback. DELETE HTTP success
|
Success requires authoritative branch-scoped not-found
|
||||||
alone is never enough.
|
(``verified_absent=True``). DELETE HTTP success alone is never enough.
|
||||||
|
Generic/repository/host 404 cannot verify absence (R1).
|
||||||
"""
|
"""
|
||||||
payload = dict(readback or {})
|
payload = dict(readback or {})
|
||||||
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
|
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
|
||||||
verified = bool(payload.get("verified_absent")) or status == READBACK_NOT_FOUND
|
# Only explicit verified_absent flag counts — never infer from status alone
|
||||||
if status == READBACK_NOT_FOUND and verified:
|
# when status is a bare not_found without branch scope proof.
|
||||||
|
verified = bool(payload.get("verified_absent")) is True
|
||||||
|
if verified and status == READBACK_NOT_FOUND:
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"success": True,
|
"success": True,
|
||||||
|
"verified_absent": True,
|
||||||
"readback": {
|
"readback": {
|
||||||
"status": READBACK_NOT_FOUND,
|
"status": READBACK_NOT_FOUND,
|
||||||
"verified_absent": True,
|
"verified_absent": True,
|
||||||
"branch_present": False,
|
"branch_present": False,
|
||||||
"error_class": None,
|
"error_class": None,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
},
|
},
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
@@ -326,29 +387,72 @@ def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, An
|
|||||||
reasons = ["post-delete branch readback authorization failed"]
|
reasons = ["post-delete branch readback authorization failed"]
|
||||||
elif status == READBACK_TRANSPORT:
|
elif status == READBACK_TRANSPORT:
|
||||||
reasons = ["post-delete branch readback transport/upstream failure"]
|
reasons = ["post-delete branch readback transport/upstream failure"]
|
||||||
|
elif status == READBACK_AMBIGUOUS_404:
|
||||||
|
reasons = [
|
||||||
|
"post-delete readback 404 is not branch-scoped; "
|
||||||
|
"cannot verify absence"
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
reasons = ["post-delete branch readback could not verify deletion"]
|
reasons = ["post-delete branch readback could not verify deletion"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"success": False,
|
"success": False,
|
||||||
|
"verified_absent": False,
|
||||||
"readback": {
|
"readback": {
|
||||||
"status": status,
|
"status": status,
|
||||||
"verified_absent": False,
|
"verified_absent": False,
|
||||||
"branch_present": payload.get("branch_present"),
|
"branch_present": payload.get("branch_present"),
|
||||||
"error_class": payload.get("error_class"),
|
"error_class": payload.get("error_class"),
|
||||||
|
"not_found_scope": payload.get("not_found_scope"),
|
||||||
},
|
},
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"blocker_kind": "post_delete_readback_failed",
|
"blocker_kind": "post_delete_readback_failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _repo_matches(record: dict[str, Any], *, remote: str, org: str, repo: str) -> bool:
|
def cleanup_result_envelope(
|
||||||
return (
|
*,
|
||||||
_norm_str(record.get("remote")).lower() == _norm_str(remote).lower()
|
success: bool,
|
||||||
and _norm_str(record.get("org")).lower() == _norm_str(org).lower()
|
performed: bool,
|
||||||
and _norm_str(record.get("repo")).lower() == _norm_str(repo).lower()
|
delete_acknowledged: bool,
|
||||||
)
|
verified_absent: bool,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""R2: consistent top-level cleanup result fields on every return path."""
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"success": bool(success),
|
||||||
|
"performed": bool(performed),
|
||||||
|
"delete_acknowledged": bool(delete_acknowledged),
|
||||||
|
"verified_absent": bool(verified_absent),
|
||||||
|
}
|
||||||
|
out.update(extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_matches(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Match ownership record to target remote/org/repo and normalized host."""
|
||||||
|
if (
|
||||||
|
_norm_str(record.get("remote")).lower() != _norm_str(remote).lower()
|
||||||
|
or _norm_str(record.get("org")).lower() != _norm_str(org).lower()
|
||||||
|
or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
expected_host = normalize_host(host)
|
||||||
|
record_host = normalize_host(record.get("host") or record.get("host_name"))
|
||||||
|
# When both sides declare a host, they must agree after normalization.
|
||||||
|
# A record host that disagrees with the expected host is out of scope.
|
||||||
|
# Legacy records without host still match when remote/org/repo agree.
|
||||||
|
if expected_host and record_host and expected_host != record_host:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
|
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
|
||||||
@@ -359,13 +463,21 @@ def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"""Classify one ownership record as blocking or non-blocking.
|
"""Classify one ownership record as blocking or non-blocking.
|
||||||
|
|
||||||
Distinguishes active ownership from expired/released/terminal/stale records.
|
Distinguishes active ownership from expired/released/terminal/stale records.
|
||||||
Stale/expired records block only when reclaim is not allowed (sticky foreign
|
Expired/stale records block unless reclaim_allowed is *explicitly* True
|
||||||
ownership with live owner + present worktree).
|
(O2: never treat missing/unknown reclaim as auto-allowed).
|
||||||
"""
|
"""
|
||||||
status = _norm_str(record.get("status")).lower()
|
status = _norm_str(record.get("status")).lower()
|
||||||
category = _norm_str(record.get("category")) or "unknown"
|
category = _norm_str(record.get("category")) or "unknown"
|
||||||
reclaim_allowed = record.get("reclaim_allowed")
|
reclaim_allowed = record.get("reclaim_allowed")
|
||||||
|
|
||||||
|
if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR:
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status or "unknown",
|
||||||
|
"category": category,
|
||||||
|
"reason": "ownership inventory failed closed",
|
||||||
|
}
|
||||||
|
|
||||||
if status in _TERMINAL_OWNERSHIP_STATUSES:
|
if status in _TERMINAL_OWNERSHIP_STATUSES:
|
||||||
return {
|
return {
|
||||||
"blocks": False,
|
"blocks": False,
|
||||||
@@ -381,8 +493,7 @@ def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"reason": f"active {category} ownership still uses the target branch",
|
"reason": f"active {category} ownership still uses the target branch",
|
||||||
}
|
}
|
||||||
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
|
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
|
||||||
# Canonical recovery policy (#601): reclaimable expired/stale does not
|
# O2: only explicit reclaim_allowed=True skips the block.
|
||||||
# block; sticky expired/stale (live pid + present worktree) does.
|
|
||||||
if reclaim_allowed is True:
|
if reclaim_allowed is True:
|
||||||
return {
|
return {
|
||||||
"blocks": False,
|
"blocks": False,
|
||||||
@@ -393,24 +504,13 @@ def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"does not block deletion"
|
"does not block deletion"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if reclaim_allowed is False:
|
|
||||||
return {
|
|
||||||
"blocks": True,
|
|
||||||
"status": status,
|
|
||||||
"category": category,
|
|
||||||
"reason": (
|
|
||||||
f"sticky {status} {category} ownership still protects the "
|
|
||||||
"target branch (recovery review required)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
# Unknown reclaimability → fail closed
|
|
||||||
return {
|
return {
|
||||||
"blocks": True,
|
"blocks": True,
|
||||||
"status": status,
|
"status": status,
|
||||||
"category": category,
|
"category": category,
|
||||||
"reason": (
|
"reason": (
|
||||||
f"{status} {category} ownership reclaimability unknown; "
|
f"{status} {category} ownership still protects the target "
|
||||||
"fail closed"
|
"branch (reclaim not proven; fail closed)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
# Unknown status → fail closed
|
# Unknown status → fail closed
|
||||||
@@ -431,15 +531,16 @@ def assess_active_branch_ownership(
|
|||||||
org: str,
|
org: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
branch: str,
|
branch: str,
|
||||||
|
host: str | None = None,
|
||||||
records: list[dict[str, Any]] | None = None,
|
records: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Assess whether any active ownership still uses *branch* in *repo*.
|
"""Assess whether any active ownership still uses *branch* in *repo*.
|
||||||
|
|
||||||
Only records matching the exact remote/org/repo/branch quadruple are
|
Matching requires remote/org/repo/branch and, when provided, normalized
|
||||||
considered. Other repositories or branches never produce a false block.
|
host identity. Other repositories, hosts, or branches never false-block.
|
||||||
Returned denial identifies ownership category only — never secrets.
|
|
||||||
"""
|
"""
|
||||||
target_branch = _norm_str(branch)
|
target_branch = _norm_str(branch)
|
||||||
|
expected_host = normalize_host(host)
|
||||||
considered: list[dict[str, Any]] = []
|
considered: list[dict[str, Any]] = []
|
||||||
blocking: list[dict[str, Any]] = []
|
blocking: list[dict[str, Any]] = []
|
||||||
ignored: list[dict[str, Any]] = []
|
ignored: list[dict[str, Any]] = []
|
||||||
@@ -447,11 +548,13 @@ def assess_active_branch_ownership(
|
|||||||
for raw in records or []:
|
for raw in records or []:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
continue
|
continue
|
||||||
if not _repo_matches(raw, remote=remote, org=org, repo=repo):
|
if not _repo_matches(
|
||||||
|
raw, remote=remote, org=org, repo=repo, host=expected_host or None
|
||||||
|
):
|
||||||
ignored.append(
|
ignored.append(
|
||||||
{
|
{
|
||||||
"category": _norm_str(raw.get("category")) or "unknown",
|
"category": _norm_str(raw.get("category")) or "unknown",
|
||||||
"reason": "different repository scope",
|
"reason": "different repository or host scope",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -488,6 +591,7 @@ def assess_active_branch_ownership(
|
|||||||
"remote": remote,
|
"remote": remote,
|
||||||
"org": org,
|
"org": org,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
|
"host": expected_host or None,
|
||||||
"branch": target_branch,
|
"branch": target_branch,
|
||||||
"blocking_categories": categories,
|
"blocking_categories": categories,
|
||||||
"blocking": blocking,
|
"blocking": blocking,
|
||||||
|
|||||||
+368
-172
@@ -6026,13 +6026,15 @@ def gitea_cleanup_merged_pr_branch(
|
|||||||
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
|
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
|
||||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||||
if gate_reasons:
|
if gate_reasons:
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"required_permission": "gitea.branch.delete",
|
delete_acknowledged=False,
|
||||||
"reasons": gate_reasons,
|
verified_absent=False,
|
||||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
required_permission="gitea.branch.delete",
|
||||||
}
|
reasons=gate_reasons,
|
||||||
|
permission_report=_permission_block_report("gitea.branch.delete"),
|
||||||
|
)
|
||||||
|
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
active_role = _profile_role_kind(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
|
# Author/reviewer/merger must not reach this path even if they somehow
|
||||||
# hold gitea.branch.delete.
|
# hold gitea.branch.delete.
|
||||||
if active_role != "reconciler":
|
if active_role != "reconciler":
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"required_permission": "gitea.branch.delete",
|
delete_acknowledged=False,
|
||||||
"required_role_kind": "reconciler",
|
verified_absent=False,
|
||||||
"active_role_kind": active_role,
|
required_permission="gitea.branch.delete",
|
||||||
"reasons": [
|
required_role_kind="reconciler",
|
||||||
|
active_role_kind=active_role,
|
||||||
|
reasons=[
|
||||||
f"profile role '{active_role}' is not authorized for merged "
|
f"profile role '{active_role}' is not authorized for merged "
|
||||||
"branch cleanup; required role is reconciler (fail closed)"
|
"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):
|
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"required_permission": "gitea.branch.delete",
|
delete_acknowledged=False,
|
||||||
"reasons": [
|
verified_absent=False,
|
||||||
|
required_permission="gitea.branch.delete",
|
||||||
|
reasons=[
|
||||||
"merged branch cleanup requires an explicit branches/ worktree "
|
"merged branch cleanup requires an explicit branches/ worktree "
|
||||||
"path; root checkout branch ref mutation is blocked (fail closed)"
|
"path; root checkout branch ref mutation is blocked (fail closed)"
|
||||||
],
|
],
|
||||||
}
|
)
|
||||||
|
|
||||||
verify_preflight_purity(
|
verify_preflight_purity(
|
||||||
remote,
|
remote,
|
||||||
@@ -6080,16 +6086,18 @@ def gitea_cleanup_merged_pr_branch(
|
|||||||
target_branch = (pr.get("base") or {}).get("ref") or "master"
|
target_branch = (pr.get("base") or {}).get("ref") or "master"
|
||||||
|
|
||||||
if branch and branch != pr_head.get("ref"):
|
if branch and branch != pr_head.get("ref"):
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"pr_number": pr_number,
|
delete_acknowledged=False,
|
||||||
"branch": branch,
|
verified_absent=False,
|
||||||
"reasons": [
|
pr_number=pr_number,
|
||||||
|
branch=branch,
|
||||||
|
reasons=[
|
||||||
f"requested branch '{branch}' does not match PR head "
|
f"requested branch '{branch}' does not match PR head "
|
||||||
f"'{pr_head.get('ref')}'"
|
f"'{pr_head.get('ref')}'"
|
||||||
],
|
],
|
||||||
}
|
)
|
||||||
|
|
||||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||||
open_heads = {
|
open_heads = {
|
||||||
@@ -6119,50 +6127,74 @@ def gitea_cleanup_merged_pr_branch(
|
|||||||
confirmation=confirmation,
|
confirmation=confirmation,
|
||||||
)
|
)
|
||||||
if not assessment["safe_to_delete"]:
|
if not assessment["safe_to_delete"]:
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"pr_number": pr_number,
|
delete_acknowledged=False,
|
||||||
"branch": head_branch,
|
verified_absent=False,
|
||||||
"assessment": assessment,
|
pr_number=pr_number,
|
||||||
"reasons": assessment["block_reasons"],
|
branch=head_branch,
|
||||||
}
|
assessment=assessment,
|
||||||
|
reasons=assessment["block_reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
# #687: active ownership protection (sessions/leases/worktree bindings).
|
# #687: active ownership protection (sessions/leases/worktree bindings).
|
||||||
# Do not rely only on the caller worktree living under branches/.
|
# 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,
|
remote=remote,
|
||||||
|
host=h,
|
||||||
org=o,
|
org=o,
|
||||||
repo=r,
|
repo=r,
|
||||||
branch=head_branch,
|
branch=head_branch,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
project_root=PROJECT_ROOT,
|
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(
|
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||||
remote=remote,
|
remote=remote,
|
||||||
org=o,
|
org=o,
|
||||||
repo=r,
|
repo=r,
|
||||||
branch=head_branch,
|
branch=head_branch,
|
||||||
|
host=h,
|
||||||
records=ownership_records,
|
records=ownership_records,
|
||||||
)
|
)
|
||||||
if ownership.get("block"):
|
if ownership.get("block"):
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": False,
|
performed=False,
|
||||||
"pr_number": pr_number,
|
delete_acknowledged=False,
|
||||||
"branch": head_branch,
|
verified_absent=False,
|
||||||
"assessment": assessment,
|
pr_number=pr_number,
|
||||||
"ownership": {
|
branch=head_branch,
|
||||||
|
assessment=assessment,
|
||||||
|
ownership={
|
||||||
"block": True,
|
"block": True,
|
||||||
"blocking_categories": ownership.get("blocking_categories") or [],
|
"blocking_categories": ownership.get("blocking_categories") or [],
|
||||||
"reasons": ownership.get("reasons") or [],
|
"reasons": ownership.get("reasons") or [],
|
||||||
"blocker_kind": ownership.get("blocker_kind"),
|
"blocker_kind": ownership.get("blocker_kind"),
|
||||||
|
"checked": True,
|
||||||
},
|
},
|
||||||
"reasons": ownership.get("reasons") or [
|
reasons=ownership.get("reasons")
|
||||||
"active ownership protects the target branch"
|
or ["active ownership protects the target branch"],
|
||||||
],
|
blocker_kind="active_branch_ownership",
|
||||||
"blocker_kind": "active_branch_ownership",
|
)
|
||||||
}
|
|
||||||
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -6188,19 +6220,20 @@ def gitea_cleanup_merged_pr_branch(
|
|||||||
api_request("DELETE", url, auth)
|
api_request("DELETE", url, auth)
|
||||||
|
|
||||||
# #687: authoritative post-delete readback. DELETE success alone is not
|
# #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 = _probe_remote_branch(h, o, r, auth, head_branch)
|
||||||
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
|
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
|
||||||
|
verified_absent = bool(readback_assessment.get("verified_absent"))
|
||||||
request_metadata["post_delete_readback"] = {
|
request_metadata["post_delete_readback"] = {
|
||||||
"status": (readback_assessment.get("readback") or {}).get("status"),
|
"status": (readback_assessment.get("readback") or {}).get("status"),
|
||||||
"verified_absent": bool(
|
"verified_absent": verified_absent,
|
||||||
(readback_assessment.get("readback") or {}).get("verified_absent")
|
|
||||||
),
|
|
||||||
"error_class": (readback_assessment.get("readback") or {}).get(
|
"error_class": (readback_assessment.get("readback") or {}).get(
|
||||||
"error_class"
|
"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():
|
if gitea_audit.audit_enabled():
|
||||||
_audit(
|
_audit(
|
||||||
"cleanup_merged_pr_branch_readback",
|
"cleanup_merged_pr_branch_readback",
|
||||||
@@ -6220,53 +6253,60 @@ def gitea_cleanup_merged_pr_branch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not readback_assessment.get("ok"):
|
if not readback_assessment.get("ok"):
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": False,
|
success=False,
|
||||||
"performed": True,
|
performed=True,
|
||||||
"delete_acknowledged": True,
|
delete_acknowledged=True,
|
||||||
"pr_number": pr_number,
|
verified_absent=False,
|
||||||
"branch": head_branch,
|
pr_number=pr_number,
|
||||||
"assessment": assessment,
|
branch=head_branch,
|
||||||
"ownership": {
|
assessment=assessment,
|
||||||
|
ownership={
|
||||||
"block": False,
|
"block": False,
|
||||||
"blocking_categories": [],
|
"blocking_categories": [],
|
||||||
"checked": True,
|
"checked": True,
|
||||||
},
|
},
|
||||||
"readback": readback_assessment.get("readback"),
|
readback=readback_assessment.get("readback"),
|
||||||
"reasons": readback_assessment.get("reasons") or [
|
reasons=readback_assessment.get("reasons")
|
||||||
"post-delete branch readback could not verify deletion"
|
or ["post-delete branch readback could not verify deletion"],
|
||||||
],
|
blocker_kind=readback_assessment.get("blocker_kind")
|
||||||
"blocker_kind": readback_assessment.get("blocker_kind")
|
|
||||||
or "post_delete_readback_failed",
|
or "post_delete_readback_failed",
|
||||||
"message": (
|
message=(
|
||||||
f"DELETE accepted for '{head_branch}' but post-delete readback "
|
f"DELETE accepted for '{head_branch}' but post-delete readback "
|
||||||
"did not verify absence"
|
"did not verify absence"
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
return {
|
return branch_cleanup_guard.cleanup_result_envelope(
|
||||||
"success": True,
|
success=True,
|
||||||
"performed": True,
|
performed=True,
|
||||||
"pr_number": pr_number,
|
delete_acknowledged=True,
|
||||||
"branch": head_branch,
|
verified_absent=True,
|
||||||
"message": (
|
pr_number=pr_number,
|
||||||
|
branch=head_branch,
|
||||||
|
message=(
|
||||||
f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
|
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,
|
assessment=assessment,
|
||||||
"ownership": {
|
ownership={
|
||||||
"block": False,
|
"block": False,
|
||||||
"blocking_categories": [],
|
"blocking_categories": [],
|
||||||
"checked": True,
|
"checked": True,
|
||||||
},
|
},
|
||||||
"readback": readback_assessment.get("readback"),
|
readback=readback_assessment.get("readback"),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def _probe_remote_branch(
|
def _probe_remote_branch(
|
||||||
h: str, o: str, r: str, auth: str, branch: str
|
h: str, o: str, r: str, auth: str, branch: str
|
||||||
) -> dict:
|
) -> 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
|
import urllib.parse
|
||||||
|
|
||||||
encoded = urllib.parse.quote(branch, safe="")
|
encoded = urllib.parse.quote(branch, safe="")
|
||||||
@@ -6275,23 +6315,56 @@ def _probe_remote_branch(
|
|||||||
api_request("GET", url, auth)
|
api_request("GET", url, auth)
|
||||||
return branch_cleanup_guard.classify_branch_readback_http_status(200)
|
return branch_cleanup_guard.classify_branch_readback_http_status(200)
|
||||||
except Exception as exc:
|
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:
|
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
|
Authentication, authorization, transport, and ambiguous 404 failures are
|
||||||
re-raised (or returned as structured failures by callers that use
|
raised rather than treated as absence.
|
||||||
``_probe_remote_branch``) rather than treated as absence.
|
|
||||||
"""
|
"""
|
||||||
probe = _probe_remote_branch(h, o, r, auth, branch)
|
probe = _probe_remote_branch(h, o, r, auth, branch)
|
||||||
status = probe.get("status")
|
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
|
return False
|
||||||
if status == branch_cleanup_guard.READBACK_EXISTS:
|
if status == branch_cleanup_guard.READBACK_EXISTS:
|
||||||
return True
|
return True
|
||||||
# Preserve structured failure modes for callers that need them.
|
|
||||||
error_class = probe.get("error_class") or "unexpected"
|
error_class = probe.get("error_class") or "unexpected"
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"remote branch probe failed ({error_class}/{status})"
|
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(
|
def _collect_branch_ownership_records(
|
||||||
*,
|
*,
|
||||||
remote: str,
|
remote: str,
|
||||||
|
host: str,
|
||||||
org: str,
|
org: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
branch: str,
|
branch: str,
|
||||||
pr_number: int | None,
|
pr_number: int | None,
|
||||||
project_root: str,
|
project_root: str,
|
||||||
) -> list[dict]:
|
auth: str | None = None,
|
||||||
|
base_api: str | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Gather canonical ownership records for *branch* (secret-free).
|
"""Gather canonical ownership records for *branch* (secret-free).
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- author issue locks (task-session / lease files)
|
- author issue locks (task-session / lease files)
|
||||||
- control-plane leases (author/reviewer/merger/controller/reconciler)
|
- control-plane leases (author/reviewer/merger/controller/reconciler)
|
||||||
|
- comment-backed active reviewer leases (O3)
|
||||||
- local worktree bindings checked out to the branch
|
- 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] = []
|
records: list[dict] = []
|
||||||
|
inventory_error = False
|
||||||
target_branch = (branch or "").strip()
|
target_branch = (branch or "").strip()
|
||||||
if not target_branch:
|
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) ---
|
# --- Author issue locks (session + lease) ---
|
||||||
try:
|
try:
|
||||||
@@ -6331,6 +6425,12 @@ def _collect_branch_ownership_records(
|
|||||||
or str(lock.get("repo") or "") != str(repo)
|
or str(lock.get("repo") or "") != str(repo)
|
||||||
):
|
):
|
||||||
continue
|
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:
|
if str(lock.get("branch_name") or "").strip() != target_branch:
|
||||||
continue
|
continue
|
||||||
freshness = issue_lock_store.assess_lock_freshness(lock)
|
freshness = issue_lock_store.assess_lock_freshness(lock)
|
||||||
@@ -6339,58 +6439,53 @@ def _collect_branch_ownership_records(
|
|||||||
if freshness.get("live"):
|
if freshness.get("live"):
|
||||||
status = "active"
|
status = "active"
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
"status": status,
|
status=status,
|
||||||
"remote": remote,
|
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
|
||||||
"org": org,
|
role="author",
|
||||||
"repo": repo,
|
)
|
||||||
"branch": target_branch,
|
|
||||||
"reclaim_allowed": bool(reclaim.get("reclaim_allowed")),
|
|
||||||
"role": "author",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
# Session pointer binding (same record, distinct category when live)
|
|
||||||
if freshness.get("live"):
|
if freshness.get("live"):
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": (
|
category=(
|
||||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
|
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
|
||||||
),
|
),
|
||||||
"status": "active",
|
status="active",
|
||||||
"remote": remote,
|
reclaim_allowed=False,
|
||||||
"org": org,
|
role="author",
|
||||||
"repo": repo,
|
)
|
||||||
"branch": target_branch,
|
|
||||||
"reclaim_allowed": False,
|
|
||||||
"role": "author",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fail closed: if lock inventory cannot be read, invent a sticky block.
|
inventory_error = True
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
"status": "unknown",
|
status="unknown",
|
||||||
"remote": remote,
|
reclaim_allowed=False,
|
||||||
"org": org,
|
role="author",
|
||||||
"repo": repo,
|
)
|
||||||
"branch": target_branch,
|
|
||||||
"reclaim_allowed": False,
|
|
||||||
"role": "author",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Control-plane leases (role-tagged) ---
|
# --- Control-plane leases (role-tagged) ---
|
||||||
try:
|
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"):
|
if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
|
||||||
# Best-effort default path used by runtime tools.
|
|
||||||
try:
|
try:
|
||||||
db = control_plane_db.ControlPlaneDB()
|
db = control_plane_db.ControlPlaneDB()
|
||||||
except TypeError:
|
except Exception:
|
||||||
db = None
|
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(
|
listed = lease_lifecycle.list_active_leases(
|
||||||
db,
|
db,
|
||||||
remote=remote,
|
remote=remote,
|
||||||
@@ -6416,11 +6511,14 @@ def _collect_branch_ownership_records(
|
|||||||
if not (matches_branch or matches_pr):
|
if not (matches_branch or matches_pr):
|
||||||
continue
|
continue
|
||||||
if matches_pr and not lease_branch:
|
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
|
lease_branch = target_branch
|
||||||
if lease_branch != target_branch:
|
if lease_branch != target_branch:
|
||||||
continue
|
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(
|
fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
|
||||||
lease
|
lease
|
||||||
)
|
)
|
||||||
@@ -6431,11 +6529,7 @@ def _collect_branch_ownership_records(
|
|||||||
)
|
)
|
||||||
role = str(lease.get("role") or "unknown")
|
role = str(lease.get("role") or "unknown")
|
||||||
category = branch_cleanup_guard.ownership_category_for_role(role)
|
category = branch_cleanup_guard.ownership_category_for_role(role)
|
||||||
reclaim_allowed = freshness_status in {
|
# O2: expired never auto-receives reclaim_allowed=True.
|
||||||
"released",
|
|
||||||
"abandoned",
|
|
||||||
"expired",
|
|
||||||
} and freshness_status != "active"
|
|
||||||
if freshness_status == "active":
|
if freshness_status == "active":
|
||||||
status = "active"
|
status = "active"
|
||||||
reclaim_allowed = False
|
reclaim_allowed = False
|
||||||
@@ -6446,27 +6540,61 @@ def _collect_branch_ownership_records(
|
|||||||
isinstance(fr, dict) and fr.get("expired_by_time")
|
isinstance(fr, dict) and fr.get("expired_by_time")
|
||||||
):
|
):
|
||||||
status = "expired"
|
status = "expired"
|
||||||
# Sticky if session still alive with worktree — unknown here
|
reclaim_allowed = False # O2 fail closed
|
||||||
# defaults to reclaimable when status is expired by time.
|
|
||||||
reclaim_allowed = True
|
|
||||||
else:
|
else:
|
||||||
status = freshness_status
|
status = freshness_status
|
||||||
reclaim_allowed = False
|
reclaim_allowed = False
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": category,
|
category=category,
|
||||||
"status": status,
|
status=status,
|
||||||
"remote": remote,
|
reclaim_allowed=reclaim_allowed,
|
||||||
"org": org,
|
role=role,
|
||||||
"repo": repo,
|
host=lease_host or host_n or host,
|
||||||
"branch": target_branch,
|
)
|
||||||
"reclaim_allowed": reclaim_allowed,
|
|
||||||
"role": role,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Soft: control-plane may be unavailable in unit tests / offline.
|
# O1: fail closed on control-plane inventory errors.
|
||||||
pass
|
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 ---
|
# --- Worktree bindings checked out to the target branch ---
|
||||||
try:
|
try:
|
||||||
@@ -6475,34 +6603,28 @@ def _collect_branch_ownership_records(
|
|||||||
if wt_branch != target_branch:
|
if wt_branch != target_branch:
|
||||||
continue
|
continue
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": (
|
category=(
|
||||||
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
|
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
|
||||||
),
|
),
|
||||||
"status": "active",
|
status="active",
|
||||||
"remote": remote,
|
reclaim_allowed=False,
|
||||||
"org": org,
|
role="worktree",
|
||||||
"repo": repo,
|
)
|
||||||
"branch": target_branch,
|
|
||||||
"reclaim_allowed": False,
|
|
||||||
"role": "worktree",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
inventory_error = True
|
||||||
records.append(
|
records.append(
|
||||||
{
|
_base_rec(
|
||||||
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||||
"status": "unknown",
|
status="unknown",
|
||||||
"remote": remote,
|
reclaim_allowed=False,
|
||||||
"org": org,
|
role="worktree",
|
||||||
"repo": repo,
|
)
|
||||||
"branch": target_branch,
|
|
||||||
"reclaim_allowed": False,
|
|
||||||
"role": "worktree",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return records
|
return {"records": records, "inventory_error": inventory_error}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -6689,6 +6811,66 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
if remote_assessment.get("safe_to_delete_remote"):
|
if remote_assessment.get("safe_to_delete_remote"):
|
||||||
import urllib.parse
|
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="")
|
encoded = urllib.parse.quote(head_branch, safe="")
|
||||||
url = f"{base}/branches/{encoded}"
|
url = f"{base}/branches/{encoded}"
|
||||||
with _audited(
|
with _audited(
|
||||||
@@ -6698,14 +6880,28 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
org=o,
|
org=o,
|
||||||
repo=r,
|
repo=r,
|
||||||
target_branch=head_branch,
|
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)
|
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(
|
actions.append(
|
||||||
{
|
{
|
||||||
"action": "delete_remote_branch",
|
"action": "delete_remote_branch",
|
||||||
"branch": head_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 [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
# Default: no active ownership records (tests that need ownership patch this).
|
# Default: no active ownership records (tests that need ownership patch this).
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
@@ -201,6 +201,9 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
if len(get_branch_calls) <= 1:
|
if len(get_branch_calls) <= 1:
|
||||||
return {"name": branch}
|
return {"name": branch}
|
||||||
raise RuntimeError("HTTP 404: not found")
|
raise RuntimeError("HTTP 404: not found")
|
||||||
|
if method == "GET" and url.rstrip("/").endswith("/Example-Repo"):
|
||||||
|
# Repo reachability probe after branch 404 (R1 branch-scoped).
|
||||||
|
return {"full_name": "Example-Org/Example-Repo"}
|
||||||
if method == "DELETE":
|
if method == "DELETE":
|
||||||
return {}
|
return {}
|
||||||
raise AssertionError(f"unexpected {method} {url}")
|
raise AssertionError(f"unexpected {method} {url}")
|
||||||
@@ -215,6 +218,8 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertTrue(res["performed"])
|
self.assertTrue(res["performed"])
|
||||||
|
self.assertTrue(res["delete_acknowledged"])
|
||||||
|
self.assertTrue(res["verified_absent"])
|
||||||
self.assertTrue((res.get("readback") or {}).get("verified_absent"))
|
self.assertTrue((res.get("readback") or {}).get("verified_absent"))
|
||||||
delete_calls = [
|
delete_calls = [
|
||||||
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
@@ -417,12 +422,37 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestPostDeleteReadback(unittest.TestCase):
|
class TestPostDeleteReadback(unittest.TestCase):
|
||||||
def test_not_found_is_verified_success(self):
|
def test_branch_scoped_not_found_is_verified_success(self):
|
||||||
readback = guard.classify_branch_readback_http_status(404)
|
readback = guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||||
|
)
|
||||||
result = guard.assess_post_delete_readback(readback)
|
result = guard.assess_post_delete_readback(readback)
|
||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertTrue(result["verified_absent"])
|
||||||
self.assertTrue(result["readback"]["verified_absent"])
|
self.assertTrue(result["readback"]["verified_absent"])
|
||||||
|
|
||||||
|
def test_generic_404_not_verified_absent(self):
|
||||||
|
# R1: bare 404 must not verify absence
|
||||||
|
for scope in (None, guard.NOT_FOUND_SCOPE_UNKNOWN,
|
||||||
|
guard.NOT_FOUND_SCOPE_REPOSITORY,
|
||||||
|
guard.NOT_FOUND_SCOPE_HOST):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
readback = guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=scope
|
||||||
|
)
|
||||||
|
self.assertFalse(readback["verified_absent"])
|
||||||
|
result = guard.assess_post_delete_readback(readback)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertFalse(result["verified_absent"])
|
||||||
|
|
||||||
|
def test_exception_substring_404_not_verified(self):
|
||||||
|
# R1: substring/generic 404 without scope stays unverified
|
||||||
|
result = guard.classify_branch_readback_exception(
|
||||||
|
RuntimeError("HTTP 404: something not found")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["verified_absent"])
|
||||||
|
self.assertNotEqual(result.get("not_found_scope"), guard.NOT_FOUND_SCOPE_BRANCH)
|
||||||
|
|
||||||
def test_exists_is_structured_failure(self):
|
def test_exists_is_structured_failure(self):
|
||||||
readback = guard.classify_branch_readback_http_status(200)
|
readback = guard.classify_branch_readback_http_status(200)
|
||||||
result = guard.assess_post_delete_readback(readback)
|
result = guard.assess_post_delete_readback(readback)
|
||||||
@@ -465,6 +495,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"remote": "prgs",
|
"remote": "prgs",
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
"org": "Scaled-Tech-Consulting",
|
"org": "Scaled-Tech-Consulting",
|
||||||
"repo": "Gitea-Tools",
|
"repo": "Gitea-Tools",
|
||||||
"branch": "feat/target",
|
"branch": "feat/target",
|
||||||
@@ -479,6 +510,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=[self._base()],
|
records=[self._base()],
|
||||||
)
|
)
|
||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
@@ -490,6 +522,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=[
|
records=[
|
||||||
self._base(
|
self._base(
|
||||||
category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||||
@@ -513,6 +546,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=[self._base(category=cat, status="active")],
|
records=[self._base(category=cat, status="active")],
|
||||||
)
|
)
|
||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
@@ -532,6 +566,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=records,
|
records=records,
|
||||||
)
|
)
|
||||||
self.assertFalse(result["block"])
|
self.assertFalse(result["block"])
|
||||||
@@ -542,26 +577,47 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=[self._base(status="stale", reclaim_allowed=False)],
|
records=[self._base(status="stale", reclaim_allowed=False)],
|
||||||
)
|
)
|
||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
self.assertIn("sticky", " ".join(result["reasons"]))
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
token in " ".join(result["reasons"])
|
||||||
|
for token in ("sticky", "reclaim not proven", "fail closed")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_other_repo_or_branch_no_false_block(self):
|
def test_other_repo_or_branch_no_false_block(self):
|
||||||
records = [
|
records = [
|
||||||
self._base(repo="Other-Repo", status="active"),
|
self._base(repo="Other-Repo", status="active"),
|
||||||
self._base(branch="feat/other", status="active"),
|
self._base(branch="feat/other", status="active"),
|
||||||
self._base(remote="dadeschools", status="active"),
|
self._base(remote="dadeschools", status="active"),
|
||||||
|
self._base(host="gitea.other.host", status="active"),
|
||||||
]
|
]
|
||||||
result = guard.assess_active_branch_ownership(
|
result = guard.assess_active_branch_ownership(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=records,
|
records=records,
|
||||||
)
|
)
|
||||||
self.assertFalse(result["block"])
|
self.assertFalse(result["block"])
|
||||||
self.assertEqual(len(result["ignored_out_of_scope"]), 3)
|
self.assertEqual(len(result["ignored_out_of_scope"]), 4)
|
||||||
|
|
||||||
|
def test_normalized_host_matching(self):
|
||||||
|
# Host identity is normalized (scheme/path stripped)
|
||||||
|
records = [self._base(host="https://gitea.prgs.cc/")]
|
||||||
|
result = guard.assess_active_branch_ownership(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
def test_denial_has_no_secrets(self):
|
def test_denial_has_no_secrets(self):
|
||||||
result = guard.assess_active_branch_ownership(
|
result = guard.assess_active_branch_ownership(
|
||||||
@@ -569,6 +625,7 @@ class TestActiveBranchOwnership(unittest.TestCase):
|
|||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
branch="feat/target",
|
branch="feat/target",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
records=[
|
records=[
|
||||||
self._base(
|
self._base(
|
||||||
status="active",
|
status="active",
|
||||||
@@ -626,7 +683,7 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "feat/still-there"
|
branch = "feat/still-there"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _api(method, url, *a, **k):
|
def _api(method, url, *a, **k):
|
||||||
@@ -649,13 +706,14 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
self.assertTrue(res.get("performed"))
|
self.assertTrue(res.get("performed"))
|
||||||
self.assertFalse(res.get("success"))
|
self.assertFalse(res.get("success"))
|
||||||
self.assertTrue(res.get("delete_acknowledged"))
|
self.assertTrue(res.get("delete_acknowledged"))
|
||||||
|
self.assertFalse(res.get("verified_absent"))
|
||||||
self.assertIn("still present", " ".join(res.get("reasons") or []))
|
self.assertIn("still present", " ".join(res.get("reasons") or []))
|
||||||
|
|
||||||
def test_readback_authentication_failure(self):
|
def test_readback_authentication_failure(self):
|
||||||
branch = "feat/auth-fail-readback"
|
branch = "feat/auth-fail-readback"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
state = {"branch_gets": 0}
|
state = {"branch_gets": 0}
|
||||||
|
|
||||||
@@ -687,7 +745,7 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "feat/transport-fail-readback"
|
branch = "feat/transport-fail-readback"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
state = {"branch_gets": 0}
|
state = {"branch_gets": 0}
|
||||||
|
|
||||||
@@ -718,17 +776,21 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "feat/owned"
|
branch = "feat/owned"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[
|
return_value={
|
||||||
{
|
"records": [
|
||||||
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
{
|
||||||
"status": "active",
|
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
"remote": "prgs",
|
"status": "active",
|
||||||
"org": "Example-Org",
|
"remote": "prgs",
|
||||||
"repo": "Example-Repo",
|
"host": "gitea.example.com",
|
||||||
"branch": branch,
|
"org": "Example-Org",
|
||||||
"reclaim_allowed": False,
|
"repo": "Example-Repo",
|
||||||
}
|
"branch": branch,
|
||||||
],
|
"reclaim_allowed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inventory_error": False,
|
||||||
|
},
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _api(method, url, *a, **k):
|
def _api(method, url, *a, **k):
|
||||||
@@ -762,7 +824,7 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "feat/open-pr-head"
|
branch = "feat/open-pr-head"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
patch(
|
patch(
|
||||||
"mcp_server.api_get_all",
|
"mcp_server.api_get_all",
|
||||||
@@ -791,7 +853,7 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "feat/not-ancestor"
|
branch = "feat/not-ancestor"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
patch(
|
patch(
|
||||||
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||||
@@ -820,7 +882,7 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
branch = "master"
|
branch = "master"
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._collect_branch_ownership_records",
|
"mcp_server._collect_branch_ownership_records",
|
||||||
return_value=[],
|
return_value={"records": [], "inventory_error": False},
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _api(method, url, *a, **k):
|
def _api(method, url, *a, **k):
|
||||||
@@ -843,5 +905,362 @@ class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecondRemediationR1R2(unittest.TestCase):
|
||||||
|
"""R1/R2 second-remediation: branch-scoped 404 and top-level fields."""
|
||||||
|
|
||||||
|
def test_cleanup_envelope_always_has_top_level_fields(self):
|
||||||
|
env = guard.cleanup_result_envelope(
|
||||||
|
success=False,
|
||||||
|
performed=False,
|
||||||
|
delete_acknowledged=False,
|
||||||
|
verified_absent=False,
|
||||||
|
reasons=["x"],
|
||||||
|
)
|
||||||
|
for key in ("success", "performed", "delete_acknowledged", "verified_absent"):
|
||||||
|
self.assertIn(key, env)
|
||||||
|
self.assertIsInstance(env[key], bool)
|
||||||
|
|
||||||
|
def test_repo_scoped_404_never_verified(self):
|
||||||
|
rb = guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_REPOSITORY
|
||||||
|
)
|
||||||
|
self.assertFalse(rb["verified_absent"])
|
||||||
|
assessed = guard.assess_post_delete_readback(rb)
|
||||||
|
self.assertFalse(assessed["ok"])
|
||||||
|
self.assertFalse(assessed["verified_absent"])
|
||||||
|
|
||||||
|
def test_wrong_host_404_never_verified(self):
|
||||||
|
rb = guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_HOST
|
||||||
|
)
|
||||||
|
self.assertFalse(rb["verified_absent"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecondRemediationOwnership(unittest.TestCase):
|
||||||
|
"""O1/O2/O3 ownership second-remediation."""
|
||||||
|
|
||||||
|
def test_inventory_error_category_blocks(self):
|
||||||
|
result = guard.assess_active_branch_ownership(
|
||||||
|
remote="prgs",
|
||||||
|
org="Org",
|
||||||
|
repo="Repo",
|
||||||
|
branch="feat/x",
|
||||||
|
host="gitea.example.com",
|
||||||
|
records=[
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||||
|
"status": "unknown",
|
||||||
|
"remote": "prgs",
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Org",
|
||||||
|
"repo": "Repo",
|
||||||
|
"branch": "feat/x",
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn(
|
||||||
|
guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
|
||||||
|
result["blocking_categories"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_without_explicit_reclaim_blocks(self):
|
||||||
|
# O2: expired must not auto-allow
|
||||||
|
result = guard.assess_active_branch_ownership(
|
||||||
|
remote="prgs",
|
||||||
|
org="Org",
|
||||||
|
repo="Repo",
|
||||||
|
branch="feat/x",
|
||||||
|
host="gitea.example.com",
|
||||||
|
records=[
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_MERGER_LEASE,
|
||||||
|
"status": "expired",
|
||||||
|
"remote": "prgs",
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Org",
|
||||||
|
"repo": "Repo",
|
||||||
|
"branch": "feat/x",
|
||||||
|
# reclaim_allowed omitted / False
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_expired_with_explicit_reclaim_allowed_does_not_block(self):
|
||||||
|
result = guard.assess_active_branch_ownership(
|
||||||
|
remote="prgs",
|
||||||
|
org="Org",
|
||||||
|
repo="Repo",
|
||||||
|
branch="feat/x",
|
||||||
|
host="gitea.example.com",
|
||||||
|
records=[
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
|
"status": "expired",
|
||||||
|
"remote": "prgs",
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Org",
|
||||||
|
"repo": "Repo",
|
||||||
|
"branch": "feat/x",
|
||||||
|
"reclaim_allowed": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_active_reviewer_comment_lease_blocks(self):
|
||||||
|
result = guard.assess_active_branch_ownership(
|
||||||
|
remote="prgs",
|
||||||
|
org="Org",
|
||||||
|
repo="Repo",
|
||||||
|
branch="feat/x",
|
||||||
|
host="gitea.example.com",
|
||||||
|
records=[
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||||
|
"status": "active",
|
||||||
|
"remote": "prgs",
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Org",
|
||||||
|
"repo": "Repo",
|
||||||
|
"branch": "feat/x",
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
"role": "reviewer",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertIn(
|
||||||
|
guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||||
|
result["blocking_categories"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecondRemediationIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||||
|
return_value=True,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value=dict(RECONCILER_WITH_DELETE),
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def _pr(self, branch, number=487):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-08T01:00:00Z",
|
||||||
|
"head": {"ref": branch, "sha": "a" * 40},
|
||||||
|
"base": {"ref": "master"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_r1_repo_404_after_delete_not_verified(self):
|
||||||
|
"""After DELETE, branch 404 + repo 404 must not verify absence."""
|
||||||
|
branch = "feat/r1-repo-404"
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
return_value={"records": [], "inventory_error": False},
|
||||||
|
).start()
|
||||||
|
state = {"branch_gets": 0}
|
||||||
|
|
||||||
|
def _api(method, url, *a, **k):
|
||||||
|
if method == "GET" and "/pulls/" in url:
|
||||||
|
return self._pr(branch)
|
||||||
|
if method == "GET" and "/branches/" in url:
|
||||||
|
state["branch_gets"] += 1
|
||||||
|
if state["branch_gets"] == 1:
|
||||||
|
return {"name": branch}
|
||||||
|
raise RuntimeError("HTTP 404: not found")
|
||||||
|
if method == "GET" and url.rstrip("/").endswith("/Example-Repo"):
|
||||||
|
raise RuntimeError("HTTP 404: repository not found")
|
||||||
|
if method == "DELETE":
|
||||||
|
return {}
|
||||||
|
raise AssertionError(method + " " + url)
|
||||||
|
|
||||||
|
self.mock_api.side_effect = _api
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["performed"])
|
||||||
|
self.assertTrue(res["delete_acknowledged"])
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["verified_absent"])
|
||||||
|
|
||||||
|
def test_o1_inventory_error_blocks_before_delete(self):
|
||||||
|
branch = "feat/o1-inv"
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
return_value={"records": [], "inventory_error": True},
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _api(method, url, *a, **k):
|
||||||
|
if method == "GET" and "/pulls/" in url:
|
||||||
|
return self._pr(branch)
|
||||||
|
if method == "GET" and "/branches/" in url:
|
||||||
|
return {"name": branch}
|
||||||
|
raise AssertionError(f"no mutation expected {method}")
|
||||||
|
|
||||||
|
self.mock_api.side_effect = _api
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertFalse(res["delete_acknowledged"])
|
||||||
|
self.assertFalse(res["verified_absent"])
|
||||||
|
self.assertEqual(res.get("blocker_kind"), "active_branch_ownership")
|
||||||
|
|
||||||
|
def test_o3_comment_reviewer_lease_in_collector(self):
|
||||||
|
"""Collector includes active comment-backed reviewer leases."""
|
||||||
|
active_lease = {
|
||||||
|
"pr_number": 10,
|
||||||
|
"phase": "claimed",
|
||||||
|
"session_id": "s1",
|
||||||
|
"expires_at": "2099-01-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"mcp_server.api_get_all", return_value=[{"id": 1, "body": "x"}]
|
||||||
|
), patch(
|
||||||
|
"mcp_server.reviewer_pr_lease.find_active_reviewer_lease",
|
||||||
|
return_value=active_lease,
|
||||||
|
), patch(
|
||||||
|
"mcp_server.issue_lock_store.iter_lock_files", return_value=[]
|
||||||
|
), patch(
|
||||||
|
"mcp_server.worktree_cleanup_audit.list_worktrees", return_value=[]
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.control_plane_db,
|
||||||
|
"ControlPlaneDB",
|
||||||
|
side_effect=RuntimeError("no cp"),
|
||||||
|
):
|
||||||
|
bundle = mcp_server._collect_branch_ownership_records(
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.example.com",
|
||||||
|
org="Example-Org",
|
||||||
|
repo="Example-Repo",
|
||||||
|
branch="feat/x",
|
||||||
|
pr_number=10,
|
||||||
|
project_root="/tmp/repo",
|
||||||
|
auth=FAKE_AUTH,
|
||||||
|
base_api=(
|
||||||
|
"https://gitea.example.com/api/v1/repos/"
|
||||||
|
"Example-Org/Example-Repo"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cats = {r.get("category") for r in bundle.get("records") or []}
|
||||||
|
self.assertIn(guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, cats)
|
||||||
|
|
||||||
|
def test_o4_reconcile_merged_cleanups_runs_ownership_and_readback(self):
|
||||||
|
from mcp_server import gitea_reconcile_merged_cleanups
|
||||||
|
|
||||||
|
branch = "feat/reconcile-o4"
|
||||||
|
ownership_calls = []
|
||||||
|
|
||||||
|
def fake_collect(**kwargs):
|
||||||
|
ownership_calls.append(kwargs)
|
||||||
|
return {"records": [], "inventory_error": False}
|
||||||
|
|
||||||
|
probe_calls = []
|
||||||
|
|
||||||
|
def fake_probe(h, o, r, auth, br):
|
||||||
|
probe_calls.append(br)
|
||||||
|
return guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"pr_number": 1,
|
||||||
|
"head_branch": branch,
|
||||||
|
"remote_branch": {"safe_to_delete_remote": True},
|
||||||
|
"local_worktree": {"safe_to_remove_worktree": False},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reviewer_scratch_entries": [],
|
||||||
|
}
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.pr.close",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||||
|
return_value=report,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||||
|
return_value=[],
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||||
|
return_value=(True, []),
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
side_effect=fake_collect,
|
||||||
|
).start()
|
||||||
|
patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start()
|
||||||
|
self.mock_api.side_effect = lambda *a, **k: {}
|
||||||
|
|
||||||
|
res = gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=False,
|
||||||
|
execute_confirmed=True,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("performed") or res.get("executed"))
|
||||||
|
self.assertTrue(ownership_calls, "ownership must run before delete")
|
||||||
|
self.assertTrue(probe_calls, "post-delete readback must run")
|
||||||
|
actions = res.get("actions") or []
|
||||||
|
delete_actions = [
|
||||||
|
a for a in actions if a.get("action") == "delete_remote_branch"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(delete_actions), 1)
|
||||||
|
self.assertIn("verified_absent", delete_actions[0])
|
||||||
|
self.assertIn("delete_acknowledged", delete_actions[0])
|
||||||
|
self.assertTrue(delete_actions[0].get("verified_absent"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user