"""Fail-closed validation for workflow-changing Gitea comments (#496).""" from __future__ import annotations import re from typing import Any VALID_ROLES = frozenset({ "controller", "author", "reviewer", "merger", "reconciler", "user", }) _BASE_REQUIRED = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "WHY") _ISSUE_REQUIRED = _BASE_REQUIRED + ("BLOCKERS", "VALIDATION") _PR_REQUIRED = _BASE_REQUIRED + ( "ISSUE", "HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "BLOCKERS", "VALIDATION", ) _SUPERSESSION_REQUIRED = _BASE_REQUIRED + ( "CANONICAL_ITEM", "SUPERSEDED_ITEM", "CLOSE_OR_KEEP_OPEN", ) _MACHINE_MARKERS = ( "", "", "", ) _WORKFLOW_TRIGGERS = re.compile( r"\b(?:" r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|" r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|" r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|" r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|" r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|" r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|" r"merge\s+ready|ready\s+for\s+merge" r")\b", re.IGNORECASE, ) _CANONICAL_HEADINGS = ( "## Canonical Issue State", "## Canonical PR State", "## Canonical Discussion Summary", ) _FIELD_RE = re.compile( r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", re.MULTILINE, ) _VAGUE_NEXT_ACTIONS = frozenset({ "continue", "handle this", "fix it", "follow up", "follow-up", "see above", "see review", "tbd", "todo", "as needed", "proceed", "next steps", "will check", "investigate", }) _FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE) _SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) _PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE) _APPROVAL_PROOF_RE = re.compile( r"\b(?:approved|approval_at_current_head|APPROVE)\b", re.IGNORECASE, ) _UNBLOCK_RE = re.compile( r"\b(?:unblock|until|after|once|when|requires?|must)\b", re.IGNORECASE, ) def _parse_fields(body: str) -> dict[str, str]: """Parse KEY: value fields, including values continued on following lines.""" fields: dict[str, str] = {} current_key: str | None = None current_lines: list[str] = [] def _flush() -> None: nonlocal current_key, current_lines if current_key is not None: fields[current_key] = "\n".join(current_lines).strip() current_key = None current_lines = [] for line in (body or "").splitlines(): if line.startswith("## "): _flush() continue match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line) if match: _flush() current_key = match.group(1).strip().upper() rest = match.group(2) current_lines = [rest] if rest else [] elif current_key is not None: current_lines.append(line) _flush() return fields def _is_machine_generated(body: str) -> bool: text = body or "" return any(marker in text for marker in _MACHINE_MARKERS) def _has_canonical_heading(body: str) -> bool: return any(h in (body or "") for h in _CANONICAL_HEADINGS) def is_workflow_changing_comment(body: str) -> bool: """True when comment text implies a workflow/state transition.""" text = (body or "").strip() if not text: return False if _is_machine_generated(text): return False if _has_canonical_heading(text): return True if _WORKFLOW_TRIGGERS.search(text): return True fields = _parse_fields(text) if "STATE" in fields or "WHO_IS_NEXT" in fields: return True return False def infer_comment_context(body: str, *, explicit: str | None = None) -> str: if explicit: return explicit text = body or "" if "## Canonical Discussion Summary" in text: return "discussion_summary" if "## Canonical PR State" in text: return "pr_comment" if "## Canonical Issue State" in text: return "issue_comment" fields = _parse_fields(text) if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"): return "supersession" if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")): return "pr_comment" return "issue_comment" def _required_fields_for_context(context: str) -> tuple[str, ...]: if context in ("pr_comment", "pr_review"): return _PR_REQUIRED if context == "supersession": return _SUPERSESSION_REQUIRED if context == "discussion_summary": return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS") return _ISSUE_REQUIRED def _is_vague_next_action(value: str) -> bool: normalized = re.sub(r"\s+", " ", (value or "").strip().lower()) normalized = normalized.rstrip(".") if not normalized: return True if normalized in _VAGUE_NEXT_ACTIONS: return True if len(normalized) < 12: return True return False def _next_prompt_ok(value: str) -> bool: text = (value or "").strip() if len(text) < 40: return False if text.lower() in {"n/a", "none", "tbd", "todo"}: return False return True def _state_value(fields: dict[str, str]) -> str: return (fields.get("STATE") or "").strip().lower() def _suggested_template(context: str) -> str: if context == "pr_comment" or context == "pr_review": return ( "## Canonical PR State\n\n" "STATE:\n" "WHO_IS_NEXT:\n" "NEXT_ACTION:\n" "NEXT_PROMPT:\n" "```text\n\n```\n" "WHAT_HAPPENED:\n" "WHY:\n" "ISSUE:\n" "HEAD_SHA:\n" "REVIEW_STATUS:\n" "MERGE_READY:\n" "BLOCKERS:\n" "VALIDATION:\n" "LAST_UPDATED_BY:\n" ) if context == "supersession": return ( "## Canonical PR State\n\n" "STATE:\nsuperseded\n" "WHO_IS_NEXT:\nreconciler\n" "NEXT_ACTION:\n" "NEXT_PROMPT:\n" "WHY:\n" "CANONICAL_ITEM:\n" "SUPERSEDED_ITEM:\n" "CLOSE_OR_KEEP_OPEN:\n" ) if context == "discussion_summary": return ( "## Canonical Discussion Summary\n\n" "STATE:\n" "WHO_IS_NEXT:\n" "DECISION:\n" "WHY:\n" "SUBSTANTIVE_COMMENTS:\n" "NEXT_ACTION:\n" "NEXT_PROMPT:\n" ) return ( "## Canonical Issue State\n\n" "STATE:\n" "WHO_IS_NEXT:\n" "NEXT_ACTION:\n" "NEXT_PROMPT:\n" "```text\n\n```\n" "WHAT_HAPPENED:\n" "WHY:\n" "RELATED_PRS:\n" "BLOCKERS:\n" "VALIDATION:\n" "LAST_UPDATED_BY:\n" ) def _build_correction_message( *, missing_fields: list[str], vague_fields: list[str], extra_reasons: list[str], context: str, ) -> str: parts = [ "Canonical comment validation failed (fail closed before posting).", ] if missing_fields: parts.append("Missing fields: " + ", ".join(missing_fields) + ".") if vague_fields: parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".") parts.extend(extra_reasons) parts.append("Fill the suggested template and retry.") parts.append("Suggested template:\n" + _suggested_template(context)) return "\n".join(parts) def assess_canonical_comment( body: str, *, context: str | None = None, force_workflow: bool = False, ) -> dict[str, Any]: """Validate outgoing comment text before a Gitea mutation.""" text = (body or "").strip() ctx = infer_comment_context(text, explicit=context) if not text: return { "allowed": True, "is_workflow_comment": False, "context": ctx, "missing_fields": [], "vague_fields": [], "correction_message": "", "suggested_template": "", } if _is_machine_generated(text): return { "allowed": True, "is_workflow_comment": False, "context": ctx, "missing_fields": [], "vague_fields": [], "correction_message": "", "suggested_template": "", } workflow = force_workflow or is_workflow_changing_comment(text) if not workflow: return { "allowed": True, "is_workflow_comment": False, "context": ctx, "missing_fields": [], "vague_fields": [], "correction_message": "", "suggested_template": "", } fields = _parse_fields(text) required = _required_fields_for_context(ctx) missing = [name for name in required if not (fields.get(name) or "").strip()] vague: list[str] = [] extra: list[str] = [] who = (fields.get("WHO_IS_NEXT") or "").strip().lower() if who and who not in VALID_ROLES: vague.append("WHO_IS_NEXT") extra.append( f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}." ) next_action = fields.get("NEXT_ACTION") or "" if next_action and _is_vague_next_action(next_action): vague.append("NEXT_ACTION") next_prompt = fields.get("NEXT_PROMPT") or "" if not _next_prompt_ok(next_prompt): if "NEXT_PROMPT" not in missing: vague.append("NEXT_PROMPT") state = _state_value(fields) blockers = (fields.get("BLOCKERS") or "").strip() if "blocked" in state: if not blockers or blockers.lower() in {"none", "n/a"}: missing.append("BLOCKERS (unblock condition)") elif not _UNBLOCK_RE.search(blockers): vague.append("BLOCKERS") extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.") if "superseded" in state: canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip() superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip() if not canon and "CANONICAL_ITEM" not in missing: missing.append("CANONICAL_ITEM") if not superseded and "SUPERSEDED_ITEM" not in missing: missing.append("SUPERSEDED_ITEM") if "ready-to-merge" in state or "ready to merge" in state: head_sha = fields.get("HEAD_SHA") or "" merge_ready = fields.get("MERGE_READY") or "" review_status = fields.get("REVIEW_STATUS") or "" validation = fields.get("VALIDATION") or "" proof_blob = " ".join((head_sha, merge_ready, review_status, validation)) has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob)) has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob)) if not has_sha or not has_approval: extra.append( "ready-to-merge STATE requires approval proof and HEAD_SHA in " "REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION." ) if ctx in ("pr_comment", "pr_review"): head_sha = (fields.get("HEAD_SHA") or "").strip() if not head_sha or not _SHORT_SHA_RE.search(head_sha): if "HEAD_SHA" not in missing: missing.append("HEAD_SHA") if ctx == "issue_comment" and _PR_REF_RE.search(text): related = (fields.get("RELATED_PRS") or "").strip() if not related or related.lower() in {"none", "n/a", "-"}: missing.append("RELATED_PRS") allowed = not missing and not vague and not extra correction = "" if not allowed: correction = _build_correction_message( missing_fields=missing, vague_fields=vague, extra_reasons=extra, context=ctx, ) return { "allowed": allowed, "is_workflow_comment": True, "context": ctx, "missing_fields": missing, "vague_fields": vague, "extra_reasons": extra, "correction_message": correction, "suggested_template": _suggested_template(ctx) if not allowed else "", }