"""Controller issue-acceptance gate helpers (#500). Pure validation for controller acceptance comments and final-report claims that an issue is complete. Does not post comments or close issues. """ from __future__ import annotations import re ACCEPTANCE_HEADING = "controller issue acceptance" REQUIRED_FIELDS = ( "STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "ISSUE", "MERGED_PR", "MERGE_COMMIT", "ACCEPTANCE_CRITERIA_CHECKED", "VALIDATION_REVIEWED", "CONTROLLER_DECISION", "WHY", ) ACCEPTED_STATES = frozenset({"accepted"}) REJECTION_STATES = frozenset({ "more-work-required", "more_work_required", "needs-tests", "needs_tests", "needs-docs", "needs_docs", "needs-feature-enhancement", "needs_feature_enhancement", "needs-follow-up-issue", "needs_follow_up_issue", "blocked", }) ALLOWED_NEXT_ACTORS = frozenset({ "controller", "author", "reviewer", "merger", "reconciler", "user", }) _FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$") _FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE) _ISSUE_REF_RE = re.compile(r"#\d+") _PR_REF_RE = re.compile(r"#\d+") _CHECKED_ITEM_RE = re.compile(r"\[[xX]\]") _UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]") _CLAIMS_ISSUE_COMPLETE_RE = re.compile( r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|" r"\bissue\s+acceptance\s*:\s*accepted\b|" r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b", re.IGNORECASE, ) _MERGE_ONLY_COMPLETE_RE = re.compile( r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}" r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)", re.IGNORECASE | re.DOTALL, ) _CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile( r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)", re.IGNORECASE, ) _PENDING_ACCEPTANCE_RE = re.compile( r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))", re.IGNORECASE, ) def render_controller_acceptance_template() -> str: """Return the canonical controller issue-acceptance comment template.""" return """## Controller Issue Acceptance STATE: WHO_IS_NEXT: NEXT_ACTION: NEXT_PROMPT: ISSUE: #... MERGED_PR: #... MERGE_COMMIT: <40-character SHA> ACCEPTANCE_CRITERIA_CHECKED: - [x] ... - [ ] ... VALIDATION_REVIEWED: CONTROLLER_DECISION: WHY: MISSING_WORK: FOLLOW_UP_ISSUES: BLOCKERS: LAST_UPDATED_BY: """ def extract_acceptance_fields(text: str | None) -> dict[str, str]: """Return upper-case labeled fields from a controller acceptance block.""" fields: dict[str, str] = {} current_key: str | None = None for line in (text or "").splitlines(): match = _FIELD_RE.match(line) if match: current_key = match.group(1).strip().upper().replace(" ", "_") fields[current_key] = match.group(2).strip() continue stripped = line.strip() if current_key and stripped: existing = fields.get(current_key, "") fields[current_key] = ( f"{existing}\n{stripped}" if existing else stripped ) return fields def contains_acceptance_block(text: str | None) -> bool: return ACCEPTANCE_HEADING in (text or "").lower() def _empty_or_placeholder(value: str | None) -> bool: value = (value or "").strip().lower() return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."} def _normalize_state(value: str | None) -> str: return (value or "").strip().lower().replace(" ", "_").replace("-", "_") def validate_controller_acceptance_comment(text: str | None) -> dict: """Validate a controller issue-acceptance comment.""" body = text or "" if not contains_acceptance_block(body): return { "valid": False, "fields": {}, "reasons": ["missing Controller Issue Acceptance heading"], } fields = extract_acceptance_fields(body) reasons: list[str] = [] for field in REQUIRED_FIELDS: if _empty_or_placeholder(fields.get(field)): reasons.append(f"missing required controller acceptance field: {field}") state = _normalize_state(fields.get("STATE")) if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES: reasons.append( "STATE must be accepted or a rejection path " "(more-work-required, needs-tests, needs-docs, " "needs-feature-enhancement, needs-follow-up-issue, blocked)" ) actor = (fields.get("WHO_IS_NEXT") or "").strip().lower() if actor and actor not in ALLOWED_NEXT_ACTORS: reasons.append( "WHO_IS_NEXT must be one of: " + ", ".join(sorted(ALLOWED_NEXT_ACTORS)) ) if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""): reasons.append("ISSUE must cite an issue number (#N)") if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""): reasons.append("MERGED_PR must cite a merged PR number (#N)") if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""): reasons.append("MERGE_COMMIT must include a full 40-character SHA") criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or "" if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria): reasons.append( "ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items" ) decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower() if state in ACCEPTED_STATES: if decision not in {"accepted", "accept"}: reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted") if not _CHECKED_ITEM_RE.search(criteria): reasons.append( "accepted STATE requires at least one checked acceptance criterion" ) if _empty_or_placeholder(fields.get("WHY")): reasons.append("accepted STATE requires WHY with acceptance rationale") elif state in REJECTION_STATES: if decision not in {"rejected", "reject", "more_work_required"}: reasons.append( "rejection STATE requires CONTROLLER_DECISION: rejected" ) if _empty_or_placeholder(fields.get("NEXT_PROMPT")): reasons.append( "rejection STATE requires a paste-ready NEXT_PROMPT for the next actor" ) missing = fields.get("MISSING_WORK") or "" if _empty_or_placeholder(missing): reasons.append( "rejection STATE requires MISSING_WORK describing what is still needed" ) return { "valid": not reasons, "fields": fields, "reasons": reasons, } def claims_issue_complete(text: str | None) -> bool: """Return True when text claims an issue is complete/accepted.""" return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or "")) def claims_merge_only_issue_complete(text: str | None) -> bool: """Return True when text treats PR merge as issue completion.""" return bool(_MERGE_ONLY_COMPLETE_RE.search(text or "")) def claims_controller_acceptance_update(text: str | None) -> bool: return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or "")) def notes_controller_acceptance_pending(text: str | None) -> bool: return bool(_PENDING_ACCEPTANCE_RE.search(text or "")) def validate_final_report_issue_acceptance(report_text: str | None) -> dict: """Validate issue-completion and controller-acceptance claims in final reports.""" text = report_text or "" reasons: list[str] = [] complete_claim = claims_issue_complete(text) merge_only = claims_merge_only_issue_complete(text) acceptance_claim = claims_controller_acceptance_update(text) pending_noted = notes_controller_acceptance_pending(text) has_block = contains_acceptance_block(text) if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]): reasons.append( "final report treats PR merge as issue completion without controller acceptance proof" ) if complete_claim and not pending_noted: if not has_block: reasons.append( "final report claims issue complete but includes no Controller Issue Acceptance block" ) else: result = validate_controller_acceptance_comment(text) if not result["valid"]: reasons.extend(result["reasons"]) else: state = _normalize_state(result["fields"].get("STATE")) if state not in ACCEPTED_STATES: reasons.append( "final report claims issue complete but controller STATE is not accepted" ) if acceptance_claim and has_block: result = validate_controller_acceptance_comment(text) if not result["valid"]: reasons.extend(result["reasons"]) applicable = complete_claim or merge_only or acceptance_claim or pending_noted return { "applicable": applicable, "valid": not reasons, "reasons": reasons, }