feat: add PR-only queue cleanup mode with per-PR dispatch gates (Closes #390)
Adds canonical pr-queue-cleanup workflow, report verifier, reviewer-only task routing, and runbook guidance so operators can drain open PRs one canonical review at a time with fail-closed boundaries on batching and unauthorized merges. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""PR-only queue cleanup mode gates and report verifier (#390).
|
||||
|
||||
Cleanup mode dispatches exactly one canonical review run per PR. It forbids
|
||||
author-side mutations (issue claiming, branch creation, implementation edits,
|
||||
issue filing) and enforces the terminal-mutation chain: stop after
|
||||
``REQUEST_CHANGES``; after ``APPROVED`` continue only to same-PR merge when
|
||||
merge is explicitly authorized for that PR and merge gates pass; stop after
|
||||
merge or a merge blocker. The next PR always requires a fresh run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CLEANUP_WORKFLOW_PATH = "workflows/pr-queue-cleanup.md"
|
||||
|
||||
# Author-side resolver tasks that must never run inside cleanup mode.
|
||||
CLEANUP_FORBIDDEN_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
# Run-state outcomes for one cleanup dispatch.
|
||||
STOP_AFTER_REQUEST_CHANGES = "STOP_AFTER_REQUEST_CHANGES"
|
||||
STOP_AFTER_DECISION = "STOP_AFTER_DECISION"
|
||||
CONTINUE_TO_SAME_PR_MERGE = "CONTINUE_TO_SAME_PR_MERGE"
|
||||
STOP_APPROVED_NO_MERGE_AUTH = "STOP_APPROVED_NO_MERGE_AUTH"
|
||||
STOP_APPROVED_MERGE_GATES_FAILED = "STOP_APPROVED_MERGE_GATES_FAILED"
|
||||
STOP_AFTER_MERGE = "STOP_AFTER_MERGE"
|
||||
STOP_AFTER_MERGE_BLOCKER = "STOP_AFTER_MERGE_BLOCKER"
|
||||
STOP_GATE_NOT_PROVEN = "STOP_GATE_NOT_PROVEN"
|
||||
|
||||
_TERMINAL_DECISIONS = frozenset({"approved", "request_changes", "comment", "skip"})
|
||||
|
||||
|
||||
def resolve_cleanup_run_state(
|
||||
decision: str | None,
|
||||
*,
|
||||
merge_authorized_for_pr: bool = False,
|
||||
merge_gates_passed: bool | None = None,
|
||||
merge_completed: bool = False,
|
||||
merge_blocker: bool = False,
|
||||
) -> dict:
|
||||
"""Resolve what one cleanup run may do after its terminal review decision.
|
||||
|
||||
Fail closed: unknown decisions stop the run with no further mutation.
|
||||
"""
|
||||
normalized = (decision or "").strip().lower()
|
||||
|
||||
if normalized not in _TERMINAL_DECISIONS:
|
||||
return {
|
||||
"outcome": STOP_GATE_NOT_PROVEN,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
f"unknown terminal decision {decision!r}; cleanup run stops "
|
||||
"(fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
if normalized == "request_changes":
|
||||
return {
|
||||
"outcome": STOP_AFTER_REQUEST_CHANGES,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["REQUEST_CHANGES is terminal in cleanup mode"],
|
||||
}
|
||||
|
||||
if normalized in {"comment", "skip"}:
|
||||
return {
|
||||
"outcome": STOP_AFTER_DECISION,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [f"{normalized} decision ends the cleanup run"],
|
||||
}
|
||||
|
||||
# normalized == "approved"
|
||||
if merge_completed:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge completed; cleanup run stops"],
|
||||
}
|
||||
if merge_blocker:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE_BLOCKER,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge blocker recorded; cleanup run stops"],
|
||||
}
|
||||
if not merge_authorized_for_pr:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_NO_MERGE_AUTH,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"APPROVED without explicit per-PR merge authorization; "
|
||||
"cleanup run stops"
|
||||
],
|
||||
}
|
||||
if merge_gates_passed is not True:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"merge gates not proven passed; cleanup run stops before merge"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"outcome": CONTINUE_TO_SAME_PR_MERGE,
|
||||
"further_mutation_allowed": True,
|
||||
"reasons": [],
|
||||
"allowed_mutation": "merge same PR only",
|
||||
}
|
||||
|
||||
|
||||
def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed on any author-side mutation task inside cleanup mode."""
|
||||
normalized = (task or "").strip().lower()
|
||||
if normalized in CLEANUP_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in PR-only cleanup mode: "
|
||||
"no issue claiming, branch creation, implementation edits, or "
|
||||
"issue filing"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
_SELECTED_PR_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_NEXT_SUGGESTED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_TERMINAL_MUTATION_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:review (?:decision|verdict)|terminal (?:review )?"
|
||||
r"(?:decision|mutation))\s*:\s*"
|
||||
r"(approved|request_changes|request changes|merged|comment)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_PAGINATION_HINT_RE = re.compile(
|
||||
r"inventory_complete|final page|has_more\s*[=:]\s*false|total[_ ]count",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*(?!none\b|not attempted\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_AUTHORIZED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge authorized(?: for pr)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*issue mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BRANCH_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*branch mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
"""Validate a PR-only cleanup run report (single dispatch, fail closed)."""
|
||||
reasons: list[str] = []
|
||||
text = report_text or ""
|
||||
|
||||
if CLEANUP_WORKFLOW_PATH not in text:
|
||||
reasons.append(
|
||||
f"report must cite the canonical cleanup workflow "
|
||||
f"({CLEANUP_WORKFLOW_PATH})"
|
||||
)
|
||||
|
||||
selected = _SELECTED_PR_RE.findall(text)
|
||||
if len(selected) == 0:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
elif len(set(selected)) > 1:
|
||||
reasons.append(
|
||||
"cleanup run selected multiple PRs "
|
||||
f"({', '.join(sorted(set(selected)))}); one canonical review per "
|
||||
"PR per run"
|
||||
)
|
||||
|
||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||
if len(terminal) > 1:
|
||||
reasons.append(
|
||||
"multiple terminal review mutations reported in one cleanup run"
|
||||
)
|
||||
|
||||
if not _PAGINATION_HINT_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing PR inventory pagination proof "
|
||||
"(inventory_complete / final page / total_count)"
|
||||
)
|
||||
|
||||
if not _NEXT_SUGGESTED_RE.search(text):
|
||||
reasons.append(
|
||||
"report must include 'Next suggested PR' (without continuing to it)"
|
||||
)
|
||||
|
||||
if _MERGE_RESULT_RE.search(text) and not _MERGE_AUTHORIZED_RE.search(text):
|
||||
reasons.append(
|
||||
"merge reported without explicit per-PR merge authorization "
|
||||
"('Merge authorized: true')"
|
||||
)
|
||||
|
||||
if _ISSUE_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("issue mutations are forbidden in PR-only cleanup mode")
|
||||
if _BRANCH_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("branch mutations are forbidden in PR-only cleanup mode")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed" if proven else
|
||||
"fix the cleanup report: one PR, one terminal mutation, pagination "
|
||||
"proof, next-suggested-PR field, and no issue/branch mutations"
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user