fix(workflow): retire status:pr-open on every terminal PR transition (Closes #780)

status:pr-open was applied by gitea_create_pr and never removed again. Every
terminal path finished without touching it, so a repository audit found 40
closed issues still advertising an open PR.

Add terminal_pr_label_cleanup.py as the single authoritative rule and route
every sanctioned terminal path through it, so the paths cannot drift:

- merge (gitea_merge_pr)
- close without merge (gitea_edit_pr)
- supersession/abandonment (gitea_reconcile_superseded_by_merged_pr)
- already-landed reconciliation (gitea_reconcile_already_landed_pr)
- controller closure (gitea_close_issue)
- retry/recovery (new gitea_cleanup_terminal_pr_labels)

The rule removes only status:pr-open, preserves every other label, allows an
empty resulting set, is a no-op when the label is absent (so retries are
safe), and confirms the outcome by read-after-write rather than assumption.

Controller closure runs the cleanup before the state change and fails closed
if it cannot be completed and verified; closing first would bake in the stale
label with no later step to catch it. Post-merge cleanup never blocks the
merge, which already happened, and reports failures with a safe next action.

Also:
- gitea_assess_terminal_label_hygiene: read-only terminal validation that
  reports residual status:pr-open, exempting issues with a genuinely open PR.
- _put_issue_label_names now accepts Gitea's empty response body when the
  requested set is empty, so clearing the last label works.
- test_audit's close_issue fixture keys on the request instead of call order,
  since closing now also reads labels for the cleanup and its read-back.

Docs: label-taxonomy terminal-transition section, runbook pointer, and the
review-merge / reconcile-landed final-report terminal-label requirements.

Suite: 4045 passed, 11 failed, 6 skipped. The same 11 failures reproduce on
clean master df31674 (4010 passed, 11 failed) and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-21 15:41:24 -04:00
co-authored by Claude Opus 4.8
parent df3167488c
commit ed0e8c82de
9 changed files with 1448 additions and 8 deletions
+499 -6
View File
@@ -2122,6 +2122,7 @@ def _seed_session_context(
)
import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402
import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule
import reviewer_pr_lease # noqa: E402
import post_merge_moot_lease_gate # noqa: E402 # #745 reconciler cleanup gate
import merger_lease_adoption # noqa: E402
@@ -2698,6 +2699,12 @@ def _put_issue_label_names(
auth,
{"labels": ids},
)
# Clearing every label is a legitimate full-set replacement (#780 AC4): the
# last label may be the one being retired. Gitea answers that PUT with an
# empty body, which api_request surfaces as None — treat it as the empty
# label set rather than a protocol violation.
if res is None and not names:
res = []
if not isinstance(res, list):
raise RuntimeError(
"Post-mutation label verification failed: expected a list of labels "
@@ -2824,7 +2831,191 @@ def release_in_progress_label(issue_numbers: list[int], remote: str, host: str |
return results
def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
def _clear_pr_open_label_for_issue(
*,
base: str,
auth: str,
issue_number: int,
terminal_reason: str,
resolve_label_ids,
audit_kwargs: dict,
) -> dict:
"""Apply the authoritative #780 cleanup rule to exactly one issue.
Plans through :mod:`terminal_pr_label_cleanup`, mutates via the verified
full-set replacement, then re-reads the issue so the caller reports a
read-after-write proof rather than an assumption.
"""
try:
current = _issue_label_names(base, auth, issue_number)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"issue_number": issue_number,
"status": "error",
"performed": False,
"verified": False,
"reasons": [f"could not read current labels: {_redact(str(exc))}"],
}
plan = terminal_pr_label_cleanup.plan_pr_open_cleanup(
current, terminal_reason=terminal_reason
)
if not plan["cleanup_required"]:
# Idempotent by construction: nothing to remove, nothing to verify
# beyond the read we already did (#780 AC5).
return {
"issue_number": issue_number,
"status": "not present",
"performed": False,
"verified": True,
"idempotent_noop": True,
"labels_after": plan["labels_after"],
"reasons": [],
}
try:
# Resolved only once a mutation is actually needed, so the no-op path
# stays free of extra API traffic.
label_ids_by_name = resolve_label_ids()
with _audited(
"set_issue_labels",
issue_number=issue_number,
request_metadata={
"source": f"terminal_pr_label_cleanup:{plan['terminal_reason']}",
"removed": plan["removed"],
"preserved": plan["preserved"],
},
**audit_kwargs,
):
_put_issue_label_names(
base=base,
auth=auth,
issue_number=issue_number,
names=plan["labels_after"],
label_ids_by_name=label_ids_by_name,
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"issue_number": issue_number,
"status": "error",
"performed": False,
"verified": False,
"labels_before": plan["labels_before"],
"reasons": [f"label replacement failed: {_redact(str(exc))}"],
"safe_next_action": (
"Re-run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason='{terminal_pr_label_cleanup.RETRY_RECOVERY}' "
f"for issue #{issue_number}."
),
}
try:
observed = _issue_label_names(base, auth, issue_number)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"issue_number": issue_number,
"status": "unverified",
"performed": True,
"verified": False,
"labels_before": plan["labels_before"],
"reasons": [f"read-after-write check failed: {_redact(str(exc))}"],
"safe_next_action": (
"Re-run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason='{terminal_pr_label_cleanup.RETRY_RECOVERY}' "
f"for issue #{issue_number} to confirm the final label set."
),
}
verification = terminal_pr_label_cleanup.verify_pr_open_cleanup(
observed, plan=plan
)
return {
"issue_number": issue_number,
"status": "removed" if verification["verified"] else "verification_failed",
"performed": True,
"verified": verification["verified"],
"labels_before": plan["labels_before"],
"labels_after": verification["observed_labels"],
"empty_label_set": verification["empty_label_set"],
"verification": verification,
"reasons": verification["reasons"],
"safe_next_action": verification["safe_next_action"],
}
def clear_pr_open_label(
issue_numbers: list[int],
remote: str,
host: str | None,
org: str | None,
repo: str | None,
*,
terminal_reason: str,
) -> dict:
"""Authoritative ``status:pr-open`` cleanup for terminal PR states (#780).
Every sanctioned terminal path routes here merge, close-without-merge,
supersession/abandonment, already-landed reconciliation, controller
closure, and retry/recovery so the rule cannot drift between them. Only
``status:pr-open`` is ever removed; all other labels are preserved, and an
empty resulting set is a valid outcome.
"""
reason = terminal_pr_label_cleanup.canonical_terminal_reason(terminal_reason)
numbers: list[int] = []
for raw in issue_numbers or []:
try:
num = int(raw)
except (TypeError, ValueError):
continue
if num not in numbers:
numbers.append(num)
if not numbers:
return terminal_pr_label_cleanup.summarize_cleanup_results(
[], terminal_reason=reason
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
audit_kwargs = {"host": h, "remote": remote, "org": o, "repo": r}
# Memoized, lazily resolved label inventory: an issue that no longer
# carries the label needs no ids at all, so a clean repository costs one
# read per issue and nothing else.
cached: dict[str, dict[str, int]] = {}
def resolve_label_ids() -> dict[str, int]:
if "map" not in cached:
cached["map"] = _repo_label_id_map(base, auth)
return cached["map"]
results = [
_clear_pr_open_label_for_issue(
base=base,
auth=auth,
issue_number=num,
terminal_reason=reason,
resolve_label_ids=resolve_label_ids,
audit_kwargs=audit_kwargs,
)
for num in numbers
]
return terminal_pr_label_cleanup.summarize_cleanup_results(
results, terminal_reason=reason
)
def cleanup_in_progress_for_pr(
pr_payload: dict,
remote: str,
host: str | None,
org: str | None,
repo: str | None,
*,
terminal_reason: str = terminal_pr_label_cleanup.MERGED,
) -> dict:
body = pr_payload.get("body") or ""
title = pr_payload.get("title") or ""
branch = pr_payload.get("head", {}).get("ref") or ""
@@ -2833,10 +3024,38 @@ def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None,
issues = extract_linked_issue_numbers(text, branch)
if not issues:
return {"cleanup_status": "no linked issue found"}
return {
"cleanup_status": "no linked issue found",
"pr_open_label_cleanup": terminal_pr_label_cleanup.summarize_cleanup_results(
[], terminal_reason=terminal_reason
),
}
results = release_in_progress_label(issues, remote, host, org, repo)
return {"cleanup_status": results}
# #780: the PR has reached a terminal state, so status:pr-open must go too.
# Never raises — a failed cleanup is reported, never silently dropped, and
# never undoes the terminal transition that already happened.
try:
pr_open_cleanup = clear_pr_open_label(
issues, remote, host, org, repo, terminal_reason=terminal_reason
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
pr_open_cleanup = {
"label": terminal_pr_label_cleanup.PR_OPEN_LABEL,
"clean": False,
"checked": issues,
"removed": [],
"already_absent": [],
"failed": issues,
"results": [],
"reasons": [f"terminal label cleanup failed: {_redact(str(exc))}"],
"safe_next_action": (
"Re-run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason='{terminal_pr_label_cleanup.RETRY_RECOVERY}' "
"for the linked issues."
),
}
return {"cleanup_status": results, "pr_open_label_cleanup": pr_open_cleanup}
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -8357,9 +8576,18 @@ def gitea_edit_pr(
data = api_request("PATCH", url, auth, payload)
cleanup_status = None
pr_open_label_cleanup = None
if state == "closed":
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
cleanup = cleanup_in_progress_for_pr(
data,
remote,
host,
org,
repo,
terminal_reason=terminal_pr_label_cleanup.CLOSED_WITHOUT_MERGE,
)
cleanup_status = cleanup.get("cleanup_status")
pr_open_label_cleanup = cleanup.get("pr_open_label_cleanup")
if isinstance(cleanup_status, dict):
for issue_num, st in cleanup_status.items():
if st == "released":
@@ -8376,6 +8604,7 @@ def gitea_edit_pr(
"body": data.get("body", ""),
"state": data["state"],
"cleanup_status": cleanup_status,
"pr_open_label_cleanup": pr_open_label_cleanup,
}
return _with_optional_url(result, data.get("html_url"))
@@ -9111,12 +9340,33 @@ def gitea_merge_pr(
result["cleanup_status"] = "skipped (merge read-back failed)"
else:
try:
cleanup = cleanup_in_progress_for_pr(merged, remote, host, org, repo)
cleanup = cleanup_in_progress_for_pr(
merged,
remote,
host,
org,
repo,
terminal_reason=terminal_pr_label_cleanup.MERGED,
)
result["cleanup_status"] = cleanup.get("cleanup_status")
result["pr_open_label_cleanup"] = cleanup.get("pr_open_label_cleanup")
except Exception as cleanup_exc: # noqa: BLE001 — redact before surfacing
result["cleanup_status"] = (
f"skipped (cleanup error: {_redact(str(cleanup_exc))})"
)
result["pr_open_label_cleanup"] = {
"label": terminal_pr_label_cleanup.PR_OPEN_LABEL,
"clean": False,
"reasons": [
f"cleanup error: {_redact(str(cleanup_exc))}"
],
"safe_next_action": (
"Re-run gitea_cleanup_terminal_pr_labels with "
f"terminal_reason="
f"'{terminal_pr_label_cleanup.RETRY_RECOVERY}' for the "
"linked issues."
),
}
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(f"merge failed: {_redact(str(exc))}")
return result
@@ -11104,6 +11354,20 @@ def gitea_reconcile_already_landed_pr(
result["issue_closed"] = True
result["performed"] = True
# #780: once the PR is closed as already-landed, the linked issue is no
# longer awaiting an open PR — retire status:pr-open even when the issue
# itself stays open. Reported, never fatal: the reconciliation already
# happened and must not be reversed by a label failure.
if linked_issue and (result.get("pr_closed") or result.get("issue_closed")):
result["pr_open_label_cleanup"] = clear_pr_open_label(
[linked_issue],
remote,
host,
org,
repo,
terminal_reason=terminal_pr_label_cleanup.ALREADY_LANDED,
)
return result
@@ -11359,6 +11623,20 @@ def gitea_reconcile_superseded_by_merged_pr(
result["issue_closed"] = True
result["performed"] = True
# #780: supersession/abandonment is terminal for the target PR, so the
# target issue must not keep advertising an open PR.
if target_issue_number and (
result.get("pr_closed") or result.get("issue_closed")
):
result["pr_open_label_cleanup"] = clear_pr_open_label(
[target_issue_number],
remote,
host,
org,
repo,
terminal_reason=terminal_pr_label_cleanup.SUPERSEDED,
)
return result
@mcp.tool()
@@ -11417,6 +11695,33 @@ def gitea_close_issue(
"reasons": closure_gate["reasons"],
"safe_next_action": closure_gate["safe_next_action"],
}
# #780: controller closure is a terminal transition, so status:pr-open must
# be retired. Run it *before* the state change and fail closed if it cannot
# be completed — closing first would leave the exact stale-label state this
# gate exists to prevent, with no remaining step to catch it.
pr_open_cleanup = clear_pr_open_label(
[issue_number],
remote,
host,
org,
repo,
terminal_reason=terminal_pr_label_cleanup.CONTROLLER_CLOSURE,
)
if not pr_open_cleanup["clean"]:
return {
"success": False,
"blocked": True,
"performed": False,
"message": (
f"Issue #{issue_number} close blocked (#780): the terminal "
f"'{terminal_pr_label_cleanup.PR_OPEN_LABEL}' cleanup could not "
"be completed and verified."
),
"pr_open_label_cleanup": pr_open_cleanup,
"reasons": pr_open_cleanup["reasons"],
"safe_next_action": pr_open_cleanup["safe_next_action"],
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
@@ -11426,13 +11731,201 @@ def gitea_close_issue(
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
# Terminal validation (#780): re-read the closed issue and report any
# residual status:pr-open rather than assuming the earlier cleanup held.
try:
closed_labels = _issue_label_names(
repo_api_url(h, o, r), auth, issue_number
)
terminal_validation = terminal_pr_label_cleanup.detect_residual_pr_open(
[{"number": issue_number, "state": "closed", "labels": closed_labels}]
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
terminal_validation = {
"label": terminal_pr_label_cleanup.PR_OPEN_LABEL,
"clean": False,
"checked_count": 0,
"residual_count": None,
"residual_issues": [],
"reasons": [
f"terminal label validation read-back failed: {_redact(str(exc))}"
],
"safe_next_action": (
"Re-check the issue labels with gitea_assess_terminal_label_hygiene."
),
}
return {
"success": True,
"message": f"Issue #{issue_number} closed.",
"cleanup_status": cleanup_result
"cleanup_status": cleanup_result,
"pr_open_label_cleanup": pr_open_cleanup,
"terminal_label_validation": terminal_validation,
}
@mcp.tool()
def gitea_cleanup_terminal_pr_labels(
issue_numbers: list[int],
terminal_reason: str = terminal_pr_label_cleanup.RETRY_RECOVERY,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Retire ``status:pr-open`` after a terminal PR transition (#780).
This is the sanctioned recovery path when a terminal transition completed
but its label cleanup did not for example a merge whose read-back failed,
or a workflow interrupted between closing a PR and updating its issue.
The rule is the same one every terminal path uses: remove only
``status:pr-open``, preserve every other label, allow an empty resulting
set, and verify by read-after-write. Calling it on an issue that no longer
carries the label is a harmless no-op, so retries are always safe.
Args:
issue_numbers: Issues whose terminal label state should be repaired.
terminal_reason: Why the PR is terminal one of merged,
closed_without_merge, superseded, already_landed,
controller_closure, abandoned, retry_recovery.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Optional branches worktree to verify before mutation.
Returns:
dict with 'clean', per-issue 'results' carrying read-after-write proof,
'removed', 'already_absent', 'failed', and 'safe_next_action'.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("cleanup_terminal_pr_labels"),
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(
remote,
worktree_path=worktree_path,
task="cleanup_terminal_pr_labels",
org=org,
repo=repo,
)
try:
reason = terminal_pr_label_cleanup.canonical_terminal_reason(terminal_reason)
except ValueError as exc:
return {
"success": False,
"clean": False,
"performed": False,
"reasons": [str(exc)],
"safe_next_action": (
"Re-call with terminal_reason set to one of: "
+ ", ".join(terminal_pr_label_cleanup.TERMINAL_REASONS)
),
}
summary = clear_pr_open_label(
issue_numbers, remote, host, org, repo, terminal_reason=reason
)
summary["success"] = summary["clean"]
summary["performed"] = bool(summary["removed"])
return summary
@mcp.tool()
def gitea_assess_terminal_label_hygiene(
state: str = "all",
limit: int = 500,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Read-only terminal validation for residual ``status:pr-open`` (#780).
Enumerates issues and the live open pull requests, then reports any issue
still carrying ``status:pr-open`` without an open PR to justify it. Use it
before declaring a terminal transition or a cleanup batch complete.
Args:
state: Issue state filter 'open', 'closed', or 'all' (default).
limit: Max issues to enumerate across all pages.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'clean', 'residual_count', 'residual_issues',
'open_pr_count', and 'safe_next_action'.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"clean": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
try:
issues = api_get_all(
f"{base}/issues?state={state}&type=issues", auth, limit=limit
) or []
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"success": False,
"clean": False,
"reasons": [f"could not enumerate issues: {_redact(str(exc))}"],
"safe_next_action": (
"Retry once the Gitea API is reachable; an incomplete "
"enumeration cannot prove terminal label hygiene."
),
}
try:
open_pulls = _list_open_pulls(h, o, r, auth)
except Exception as exc: # noqa: BLE001 — redact before surfacing
return {
"success": False,
"clean": False,
"reasons": [f"could not enumerate open pull requests: {_redact(str(exc))}"],
"safe_next_action": (
"Retry once the Gitea API is reachable; without the open-PR "
"inventory a legitimate status:pr-open cannot be distinguished "
"from a stale one."
),
}
open_pr_issues: list[int] = []
for pull in open_pulls:
head_obj = pull.get("head") or {}
branch = head_obj.get("ref") if isinstance(head_obj, dict) else None
text = f"{pull.get('title') or ''}\n{pull.get('body') or ''}"
open_pr_issues.extend(extract_linked_issue_numbers(text, branch))
assessment = terminal_pr_label_cleanup.detect_residual_pr_open(
issues, open_pr_issue_numbers=open_pr_issues
)
assessment["success"] = True
assessment["issue_state_filter"] = state
assessment["open_pr_count"] = len(open_pulls)
return assessment
@mcp.tool()
def gitea_list_issues(
state: str = "open",