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.
408 lines
12 KiB
Python
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 "",
|
|
} |