"""Canonical state handoff ledger for discussions, issues, and PRs (#494).""" from __future__ import annotations import re from typing import Any HANDOFF_HEADING = "Controller Handoff" ISSUE_STATE_FIELDS = ( "STATE", "NEXT_ACTOR", "NEXT_ACTION", "NEXT_PROMPT", "STATUS", "RELATED_PRS", "BRANCH", "HEAD_SHA", "VALIDATION", "BLOCKERS", "DUPLICATES_OR_SUPERSEDES", "LAST_UPDATED_BY", ) PR_STATE_FIELDS = ( "STATE", "NEXT_ACTOR", "NEXT_ACTION", "NEXT_PROMPT", "ISSUE", "BASE", "HEAD", "HEAD_SHA", "REVIEW_STATUS", "VALIDATION", "BLOCKERS", "SUPERSEDES", "SUPERSEDED_BY", "MERGE_READY", "LAST_UPDATED_BY", ) DISCUSSION_STATE_FIELDS = ( "STATE", "NEXT_ACTOR", "NEXT_ACTION", "NEXT_PROMPT", "COMMENT_COUNT", "SUBSTANTIVE_COMMENTS", "URGENCY", "BLOCKERS", "LAST_UPDATED_BY", ) DISCUSSION_SUMMARY_FIELDS = ( "DECISION", "ISSUES_TO_CREATE", "NON_GOALS", "UNRESOLVED_QUESTIONS", "NEXT_PROMPT", "LAST_UPDATED_BY", ) QUEUE_CONTROLLER_FIELDS = ( "QUEUE_STATE", "SELECTED_OBJECT", "SELECTED_OBJECT_TYPE", "NEXT_ACTOR", "NEXT_ACTION", "NEXT_PROMPT", "EVIDENCE", "LAST_UPDATED_BY", ) FINAL_REPORT_NEXT_ACTION_FIELDS = ( ("Current status", ("current status",)), ("Next actor", ("next actor", "next_actor")), ("Next action", ("next action", "next_action")), ("Next prompt", ("next prompt", "next_prompt")), ) STATE_COMMENT_KINDS = frozenset({ "issue_state", "pr_state", "discussion_state", "discussion_summary", "queue_controller", }) _FIELDS_BY_KIND = { "issue_state": ISSUE_STATE_FIELDS, "pr_state": PR_STATE_FIELDS, "discussion_state": DISCUSSION_STATE_FIELDS, "discussion_summary": DISCUSSION_SUMMARY_FIELDS, "queue_controller": QUEUE_CONTROLLER_FIELDS, } _FIELD_LINE_RE = re.compile( r"^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$", re.MULTILINE, ) _HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M) _READY_TO_MERGE_RE = re.compile( r"\b(?:ready[_ ]to[_ ]merge|merge[_ ]ready\s*:\s*(?:yes|true))\b", re.IGNORECASE, ) _APPROVAL_PROOF_RE = re.compile( r"\b(?:review decision\s*:\s*approve|approved at|approval at current head|" r"formal approval|review_status\s*:\s*approved)\b", re.IGNORECASE, ) _EXTERNAL_NONE_RE = re.compile( r"^\s*[-*]?\s*external[- ]state mutations\s*:\s*none\s*$", re.IGNORECASE | re.MULTILINE, ) _LOCK_MUTATION_RE = re.compile( r"\b(?:gitea_lock_issue|issue-locks/|lock file edited)\b", re.IGNORECASE, ) _ISSUE_DONE_RE = re.compile( r"\b(?:issue (?:done|closed)\b|current status\s*:\s*(?:done|closed)\b|" r"current status\s*:\s*issue complete\b)", re.IGNORECASE, ) _PR_MERGE_PROOF_RE = re.compile( r"\b(?:pr (?:merged|number)|merge result\s*:\s*merged|pr mutations\s*:\s*created)\b", re.IGNORECASE, ) _DISCUSSION_COMPLETE_RE = re.compile( r"\b(?:discussion complete|discussion ready for issue creation)\b", re.IGNORECASE, ) _DISCUSSION_SUMMARY_PROOF_RE = re.compile( r"\b(?:discussion summary|issues to create|issues created|linked issues)\b", re.IGNORECASE, ) MIN_DISCUSSION_COMMENTS_DEFAULT = 5 def _render_fields(fields: tuple[str, ...], values: dict[str, Any]) -> str: lines = ["```text"] for name in fields: raw = values.get(name, values.get(name.lower(), "none")) text = str(raw).strip() if raw is not None else "none" lines.append(f"{name}: {text or 'none'}") lines.append("```") return "\n".join(lines) def render_issue_state_comment(**values: Any) -> str: """Render canonical issue state comment body.""" return _render_fields(ISSUE_STATE_FIELDS, values) def render_pr_state_comment(**values: Any) -> str: """Render canonical PR state comment body.""" return _render_fields(PR_STATE_FIELDS, values) def render_discussion_state_comment(**values: Any) -> str: """Render canonical discussion state comment body.""" return _render_fields(DISCUSSION_STATE_FIELDS, values) def render_discussion_summary_comment(**values: Any) -> str: """Render discussion-to-issue conversion summary comment.""" return _render_fields(DISCUSSION_SUMMARY_FIELDS, values) def render_queue_controller_report(**values: Any) -> str: """Render queue-controller selection report.""" return _render_fields(QUEUE_CONTROLLER_FIELDS, values) def parse_state_comment(text: str) -> dict[str, str]: """Parse KEY: value lines from a canonical state comment.""" parsed: dict[str, str] = {} for match in _FIELD_LINE_RE.finditer(text or ""): parsed[match.group(1)] = match.group(2).strip() return parsed def _handoff_field_map(report_text: str) -> dict[str, str]: section_match = _HANDOFF_SECTION_RE.search(report_text or "") if not section_match: return {} section = (report_text or "")[section_match.end():] fields: dict[str, str] = {} for line in section.splitlines(): stripped = line.strip().lstrip("-*").strip() if ":" not in stripped: continue key, value = stripped.split(":", 1) fields[key.strip().lower()] = value.strip() return fields def assess_state_comment(text: str, *, kind: str) -> dict[str, Any]: """Validate a canonical Gitea state comment for *kind*.""" normalized = (kind or "").strip().lower() if normalized not in STATE_COMMENT_KINDS: return { "complete": False, "block": True, "missing_fields": [], "reasons": [f"unknown state comment kind '{kind}'"], "safe_next_action": "use a supported state comment kind", } required = _FIELDS_BY_KIND[normalized] parsed = parse_state_comment(text) missing = [name for name in required if name not in parsed or not parsed[name].strip()] reasons = [f"state comment missing field: {name}" for name in missing] if normalized == "discussion_state": urgency = parsed.get("URGENCY", "").strip().lower() count_raw = parsed.get("COMMENT_COUNT", "").strip() try: count = int(count_raw) except (TypeError, ValueError): count = -1 if ( urgency not in {"urgent", "trivial"} and count >= 0 and count < MIN_DISCUSSION_COMMENTS_DEFAULT ): reasons.append( f"discussion has {count} comments; need at least " f"{MIN_DISCUSSION_COMMENTS_DEFAULT} substantive comments " "or URGENCY: urgent|trivial" ) block = bool(missing or reasons) return { "complete": not block, "block": block, "missing_fields": missing, "parsed_fields": parsed, "reasons": reasons, "safe_next_action": ( "fill all required state comment fields before posting" if block else "proceed" ), } def assess_final_report_next_action_handoff(report_text: str) -> dict[str, Any]: """Require current status plus next actor/action/prompt in Controller Handoff.""" fields = _handoff_field_map(report_text) if not fields: return { "complete": False, "block": True, "missing_fields": [name for name, _ in FINAL_REPORT_NEXT_ACTION_FIELDS], "reasons": [f"final report has no section titled exactly '{HANDOFF_HEADING}'"], "safe_next_action": f"add a complete {HANDOFF_HEADING} section", } missing = [] for name, aliases in FINAL_REPORT_NEXT_ACTION_FIELDS: value = "" for alias in aliases: if alias in fields: value = fields[alias] break if not value or value.lower() in {"none", "n/a", "not verified in this session", ""}: missing.append(name) reasons = [f"handoff missing next-action field: {name}" for name in missing] block = bool(missing) return { "complete": not block, "block": block, "missing_fields": missing, "reasons": reasons, "safe_next_action": ( "add Current status, Next actor, Next action, and Next prompt to Controller Handoff" if block else "proceed" ), } def assess_contradictory_state_handoff(report_text: str) -> dict[str, Any]: """Fail closed on contradictory queue/state claims in final reports.""" text = report_text or "" fields = _handoff_field_map(text) reasons: list[str] = [] review_decision = fields.get("review decision", fields.get("review status", "")) has_approval = ( review_decision.lower() in {"approve", "approved"} or bool(_APPROVAL_PROOF_RE.search(text)) ) if _READY_TO_MERGE_RE.search(text) and not has_approval: reasons.append( "report claims ready to merge without approval-at-current-head proof" ) lock_val = fields.get("external-state mutations", "") mutation_lines = " ".join( fields.get(key, "") for key in ( "issue mutations", "mcp/gitea mutations", "external-state mutations", "claim/lock state", ) ) if ( (_EXTERNAL_NONE_RE.search(text) or lock_val.lower() == "none") and _LOCK_MUTATION_RE.search(mutation_lines) ): reasons.append( "report claims External-state mutations: none while lock " "activity is documented in mutation fields" ) if _ISSUE_DONE_RE.search(text) and not _PR_MERGE_PROOF_RE.search(text): reasons.append( "report claims issue done/complete without PR or merge proof" ) if _DISCUSSION_COMPLETE_RE.search(text) and not _DISCUSSION_SUMMARY_PROOF_RE.search(text): reasons.append( "report claims discussion complete without summary or issue-creation proof" ) review_status = fields.get("review status", fields.get("review decision", "")) merge_ready = fields.get("merge ready", "") if ( merge_ready.lower() in {"yes", "true", "ready"} and review_status.lower() not in {"approve", "approved"} and not _APPROVAL_PROOF_RE.search(text) ): reasons.append( "MERGE_READY or merge-ready claim without approved review status" ) block = bool(reasons) return { "complete": not block, "block": block, "reasons": reasons, "safe_next_action": ( "correct contradictory state claims or add missing proof" if block else "proceed" ), } def assess_state_handoff_ledger_report(report_text: str) -> dict[str, Any]: """Composite #494 assessment for final reports.""" next_action = assess_final_report_next_action_handoff(report_text) contradictions = assess_contradictory_state_handoff(report_text) reasons = list(next_action.get("reasons") or []) + list(contradictions.get("reasons") or []) block = bool(next_action.get("block") or contradictions.get("block")) return { "complete": not block, "block": block, "next_action": next_action, "contradictions": contradictions, "reasons": reasons, "safe_next_action": ( next_action.get("safe_next_action") if next_action.get("block") else contradictions.get("safe_next_action") if contradictions.get("block") else "proceed" ), }