Files
Gitea-Tools/canonical_comment_validator.py
T
sysadmin a5cd617bbb feat(#496): fail-closed canonical comment validation before Gitea posts
Add canonical_comment_validator and wire it into issue comments, PR review
bodies, reconcile post_comment, and structured comment helpers. Workflow-changing
comments without complete next-action state are rejected before any API call.
Includes unit/MCP integration tests, runbook docs, and a final-report rule
blocking false "comment posted" claims when validation failed.
2026-07-08 03:13:42 -04:00

408 lines
12 KiB
Python

"""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 = (
"<!-- mcp-review-lease:v1 -->",
"<!-- mcp-conflict-fix-lease:v1 -->",
"<!-- gitea-issue-claim-heartbeat:v1 -->",
)
_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<paste-ready prompt>\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<paste-ready prompt>\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 "",
}