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