diff --git a/README.md b/README.md index e099658..5ebd8ac 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) | | `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) | | `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR | +| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree | | `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) | | `gitea_mark_issue` | Claim/release an issue (start/done) | | `gitea_list_labels` | List all available labels in a repository | diff --git a/canonical_comment_validator.py b/canonical_comment_validator.py new file mode 100644 index 0000000..a2d05a0 --- /dev/null +++ b/canonical_comment_validator.py @@ -0,0 +1,408 @@ +"""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 "", + } \ No newline at end of file diff --git a/canonical_state_comments.py b/canonical_state_comments.py new file mode 100644 index 0000000..cc819ac --- /dev/null +++ b/canonical_state_comments.py @@ -0,0 +1,171 @@ +"""Canonical state comment validation helpers (#495). + +These helpers validate durable issue/PR/discussion state handoff comments. +They are intentionally pure and do not post comments; MCP mutation hooks are +owned by #496. +""" + +from __future__ import annotations + +import re + +CANONICAL_HEADINGS = ( + "canonical issue state", + "canonical pr state", + "canonical discussion summary", +) + +REQUIRED_FIELDS = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT") +ALLOWED_NEXT_ACTORS = { + "controller", + "author", + "reviewer", + "merger", + "reconciler", + "user", +} + +VAGUE_NEXT_ACTIONS = { + "continue", + "handle this", + "do it", + "fix it", + "proceed", + "follow up", + "next", + "tbd", + "todo", + "n/a", + "none", +} + +_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) +_CLAIMS_STATE_UPDATE_RE = re.compile( + r"canonical\s+(?:issue|pr|discussion)?\s*state|" + r"state\s+comment\s+(?:posted|created|updated)|" + r"next[- ]action\s+comment\s+(?:posted|created|updated)", + re.IGNORECASE, +) + + +def extract_state_fields(text: str | None) -> dict[str, str]: + """Return upper-case labeled fields from a canonical state 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 and not stripped.startswith("#"): + existing = fields.get(current_key, "") + fields[current_key] = ( + f"{existing}\n{stripped}" if existing else stripped + ) + return fields + + +def contains_canonical_state_block(text: str | None) -> bool: + lower = (text or "").lower() + return any(heading in lower for heading in CANONICAL_HEADINGS) + + +def claims_canonical_state_update(text: str | None) -> bool: + """Return True when text claims a canonical state/next-action update.""" + return bool(_CLAIMS_STATE_UPDATE_RE.search(text or "")) + + +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 _vague_next_action(value: str | None) -> bool: + normalized = re.sub(r"\s+", " ", (value or "").strip().lower()) + return normalized in VAGUE_NEXT_ACTIONS + + +def validate_canonical_state_comment(text: str | None) -> dict: + """Validate a canonical state comment or embedded final-report block. + + Returns a dict with ``valid`` and ``reasons``. The validator focuses on + fields that make continuation possible: current state, next actor, next + action, and paste-ready next prompt, plus a few contradiction checks. + """ + fields = extract_state_fields(text) + reasons: list[str] = [] + + for field in REQUIRED_FIELDS: + if _empty_or_placeholder(fields.get(field)): + reasons.append(f"missing required canonical state field: {field}") + + 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 _vague_next_action(fields.get("NEXT_ACTION")): + reasons.append("NEXT_ACTION is too vague for durable continuation") + + state = (fields.get("STATE") or "").strip().lower().replace("-", "_") + if "ready_to_merge" in state or ( + state == "approved" and "pr" in (text or "").lower() + ): + review_status = (fields.get("REVIEW_STATUS") or "").lower() + head_sha = fields.get("HEAD_SHA") or "" + merge_ready = (fields.get("MERGE_READY") or "").lower() + if "approved" not in review_status: + reasons.append("ready-to-merge state requires approved REVIEW_STATUS") + if not _FULL_SHA_RE.search(head_sha): + reasons.append("ready-to-merge state requires full HEAD_SHA proof") + if merge_ready and not merge_ready.startswith(("yes", "true")): + reasons.append("ready-to-merge state contradicts MERGE_READY") + + if "superseded" in state: + canonical = fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") + if _empty_or_placeholder(canonical): + reasons.append("superseded state requires canonical item proof") + + if "blocked" in state: + blockers = fields.get("BLOCKERS") or "" + if _empty_or_placeholder(blockers): + reasons.append("blocked state requires BLOCKERS/unblock condition") + + return { + "valid": not reasons, + "fields": fields, + "reasons": reasons, + } + + +def validate_final_report_state_update(report_text: str | None) -> dict: + """Validate canonical state-update claims inside a final report.""" + text = report_text or "" + if not claims_canonical_state_update(text) and not contains_canonical_state_block(text): + return { + "applicable": False, + "valid": True, + "reasons": [], + } + + if not contains_canonical_state_block(text): + return { + "applicable": True, + "valid": False, + "reasons": [ + "final report claims a canonical state update but includes no canonical state block" + ], + } + + result = validate_canonical_state_comment(text) + return { + "applicable": True, + "valid": result["valid"], + "fields": result["fields"], + "reasons": result["reasons"], + } diff --git a/canonical_thread_handoff.py b/canonical_thread_handoff.py new file mode 100644 index 0000000..aa0a85b --- /dev/null +++ b/canonical_thread_handoff.py @@ -0,0 +1,213 @@ +"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505). + +A CTH comment is the authoritative workflow handoff in a Gitea issue or PR +thread. It records current state, decisions, blockers, proof, and the exact +next prompt/action for the next LLM or person. +""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any + +MARKER = "" + +CTH_TERM = "Canonical Thread Handoff" +CTH_ABBREV = "CTH" + +CTH_TYPES = frozenset({ + "State Handoff", + "Controller Decision", + "Author Handoff", + "Reviewer Handoff", + "Merger Handoff", + "Supersession Notice", + "Blocker", +}) + +_REQUIRED_BASE_FIELDS = ( + "status", + "next owner", + "current blocker", + "decision", + "proof", + "next action", + "ready-to-paste prompt", +) + +_HEADING_RE = re.compile( + r"^##\s+CTH:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_FIELD_RE = re.compile( + r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$", + re.MULTILINE, +) + + +def format_cth_body( + *, + cth_type: str, + status: str, + next_owner: str, + current_blocker: str = "none", + decision: str = "none", + proof: str = "none", + next_action: str = "none", + ready_to_paste_prompt: str = "none", + extra_fields: dict[str, str] | None = None, +) -> str: + """Render a canonical CTH comment body.""" + normalized_type = (cth_type or "").strip() + if normalized_type not in CTH_TYPES: + raise ValueError( + f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" + ) + lines = [ + MARKER, + f"## CTH: {normalized_type}", + "", + f"Status: {(status or 'unknown').strip() or 'unknown'}", + f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}", + f"Current blocker: {(current_blocker or 'none').strip() or 'none'}", + f"Decision: {(decision or 'none').strip() or 'none'}", + f"Proof: {(proof or 'none').strip() or 'none'}", + f"Next action: {(next_action or 'none').strip() or 'none'}", + f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}", + ] + for key, value in (extra_fields or {}).items(): + label = (key or "").strip() + if not label: + continue + lines.append(f"{label}: {(value or 'none').strip() or 'none'}") + return "\n".join(lines) + + +def parse_cth_comment(body: str) -> dict[str, Any] | None: + """Parse one CTH comment body, or None when not a CTH comment.""" + text = body or "" + if MARKER not in text and not _HEADING_RE.search(text): + return None + heading = _HEADING_RE.search(text) + if not heading: + return None + cth_type = heading.group(1).strip() + fields: dict[str, str] = {} + for match in _FIELD_RE.finditer(text): + key = match.group(1).strip().lower() + if key.startswith("cth"): + continue + fields[key] = match.group(2).strip() + return { + "cth_type": cth_type, + "fields": fields, + "raw_body": text, + } + + +def assess_cth_comment(body: str) -> dict[str, Any]: + """Validate a CTH comment has required base fields and a known type.""" + parsed = parse_cth_comment(body) + reasons: list[str] = [] + if not parsed: + return { + "valid": False, + "block": True, + "reasons": ["comment is not a Canonical Thread Handoff (CTH)"], + "parsed": None, + } + + cth_type = parsed.get("cth_type") or "" + if cth_type not in CTH_TYPES: + reasons.append( + f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" + ) + + fields = parsed.get("fields") or {} + missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()] + if missing: + reasons.append( + "CTH missing required fields: " + ", ".join(missing) + ) + + vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower() + if vague_prompt in {"", "none", "tbd", "n/a", "todo"}: + reasons.append("ready-to-paste prompt must be concrete and paste-ready") + + return { + "valid": not reasons, + "block": bool(reasons), + "reasons": reasons, + "parsed": parsed, + "cth_type": cth_type, + } + + +def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None: + """Return the newest valid CTH comment from a Gitea comment list.""" + latest: dict[str, Any] | None = None + latest_ts: datetime | None = None + for comment in comments or []: + body = comment.get("body") or "" + parsed = parse_cth_comment(body) + if not parsed: + continue + created = _parse_timestamp(comment.get("created_at")) + if latest is None or (created and (latest_ts is None or created >= latest_ts)): + latest = { + "comment_id": comment.get("id"), + "created_at": comment.get("created_at"), + "author": (comment.get("user") or {}).get("login"), + **parsed, + } + latest_ts = created + return latest + + +def assess_cth_supersedes_non_cth( + *, + latest_cth: dict[str, Any] | None, + narrative_claim: str, +) -> dict[str, Any]: + """Fail closed when narrative relies on stale non-CTH state despite newer CTH.""" + if not latest_cth: + return {"block": False, "reasons": []} + text = (narrative_claim or "").strip() + if not text: + return {"block": False, "reasons": []} + if parse_cth_comment(text): + return {"block": False, "reasons": []} + return { + "block": True, + "reasons": [ + "workflow narrative must treat the latest CTH comment as authoritative; " + f"found newer CTH type '{latest_cth.get('cth_type')}' " + f"(comment #{latest_cth.get('comment_id')})" + ], + } + + +def _parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +EXAMPLE_SCENARIOS = ( + "pr_approved_ready_for_merger", + "pr_request_changes_to_author", + "duplicate_superseded_pr_closure", + "blocked_dirty_root_worktree", + "stale_head_requires_fresh_review", + "issue_implementation_handoff", +) \ No newline at end of file diff --git a/conflict_fix_classification.py b/conflict_fix_classification.py new file mode 100644 index 0000000..86815b5 --- /dev/null +++ b/conflict_fix_classification.py @@ -0,0 +1,266 @@ +"""Live PR head re-pin before conflict-fix classification (#522). + +Open PR inventory fields (mergeable, head_sha) can be stale. Author sessions +must re-fetch live PR state and pin the live head before classifying a PR as +conflicted or creating a conflict-fix worktree. +""" + +from __future__ import annotations + +import re +from typing import Any + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +_INVENTORY_HEAD_RE = re.compile( + r"(?:inventory head sha|stale inventory head sha)\s*:\s*([0-9a-f]{40})", + re.IGNORECASE, +) +_LIVE_HEAD_RE = re.compile( + r"(?:live head sha|pinned head sha|live pr head sha)\s*:\s*([0-9a-f]{40})", + re.IGNORECASE, +) +_REPINS_TOOL_RE = re.compile( + r"gitea_assess_conflict_fix_classification", + re.IGNORECASE, +) +_CLASSIFICATION_RE = re.compile( + r"conflict[- ]fix classification\s*:\s*(\S+)", + re.IGNORECASE, +) + +CLASSIFICATION_STALE_INVENTORY_SKIP = "stale_inventory_skip" +CLASSIFICATION_CONFLICT_FIX_NEEDED = "conflict_fix_needed" +CLASSIFICATION_LIVE_MERGEABLE = "live_mergeable_skip" +CLASSIFICATION_INCOMPLETE = "incomplete_live_repin" + +_VALID_CLASSIFICATIONS = frozenset({ + CLASSIFICATION_STALE_INVENTORY_SKIP, + CLASSIFICATION_CONFLICT_FIX_NEEDED, + CLASSIFICATION_LIVE_MERGEABLE, + CLASSIFICATION_INCOMPLETE, +}) + + +def _normalize_sha(value: str | None) -> str | None: + text = (value or "").strip().lower() + if not text: + return None + return text if _FULL_SHA.match(text) else None + + +def assess_conflict_fix_classification( + *, + pr_number: int, + inventory_head_sha: str | None = None, + inventory_mergeable: bool | None = None, + live_head_sha: str | None = None, + live_mergeable: bool | None = None, +) -> dict[str, Any]: + """Classify whether conflict-fix work is justified from live PR state. + + Inventory fields are advisory only. Live head + live mergeable are required + before any conflict-fix worktree may be created. + """ + inv_head = _normalize_sha(inventory_head_sha) + live_head = _normalize_sha(live_head_sha) + reasons: list[str] = [] + + if not isinstance(pr_number, int) or pr_number <= 0: + return { + "classification": CLASSIFICATION_INCOMPLETE, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": False, + "inventory_stale_mergeable": False, + "pinned_head_sha": None, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": ["pr_number must be a positive integer (fail closed)"], + } + + if live_head is None: + reasons.append( + "live PR head SHA missing or not a full 40-char hex SHA; " + "re-fetch the PR before conflict-fix classification (fail closed)" + ) + if live_mergeable is None: + reasons.append( + "live PR mergeable value missing; re-fetch the PR before " + "conflict-fix classification (fail closed)" + ) + + if reasons: + return { + "classification": CLASSIFICATION_INCOMPLETE, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": bool( + inv_head and live_head and inv_head != live_head + ), + "inventory_stale_mergeable": ( + inventory_mergeable is not None + and live_mergeable is not None + and bool(inventory_mergeable) != bool(live_mergeable) + ), + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": reasons, + } + + inventory_stale_head = bool(inv_head and inv_head != live_head) + inventory_stale_mergeable = ( + inventory_mergeable is not None + and bool(inventory_mergeable) != bool(live_mergeable) + ) + + # Live mergeable wins: do not start conflict-fix work. + if live_mergeable is True: + classification = ( + CLASSIFICATION_STALE_INVENTORY_SKIP + if ( + inventory_mergeable is False + or inventory_stale_head + or inventory_stale_mergeable + ) + else CLASSIFICATION_LIVE_MERGEABLE + ) + note = [] + if inventory_stale_head: + note.append( + f"inventory head {inv_head} differs from live head {live_head}; " + "use live head only" + ) + if inventory_mergeable is False: + note.append( + "inventory reported mergeable:false but live PR is mergeable:true; " + "skip author conflict-fix mutation" + ) + return { + "classification": classification, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": inventory_stale_head, + "inventory_stale_mergeable": inventory_stale_mergeable, + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": note, + } + + # live_mergeable is False → conflict-fix may proceed on the pinned live head. + note = [] + if inventory_stale_head: + note.append( + f"inventory head {inv_head} differs from live head {live_head}; " + "pin and use live head only for conflict-fix work" + ) + if inventory_mergeable is True: + note.append( + "inventory reported mergeable:true but live PR is mergeable:false; " + "trust live mergeable and proceed only with live head pin" + ) + note.append( + f"live PR #{pr_number} is mergeable:false at pinned head {live_head}; " + "conflict-fix worktree allowed for that head only" + ) + return { + "classification": CLASSIFICATION_CONFLICT_FIX_NEEDED, + "worktree_allowed": True, + "skip_author_mutation": False, + "inventory_stale_head": inventory_stale_head, + "inventory_stale_mergeable": inventory_stale_mergeable, + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": note, + } + + +def assess_conflict_fix_classification_final_report( + report_text: str, + **_kwargs: Any, +) -> dict[str, Any]: + """Require live-head re-pin proof when a report claims conflict-fix work (#522).""" + text = report_text or "" + lower = text.lower() + mentions_conflict_fix = ( + "conflict-fix" in lower + or "conflict fix" in lower + or "conflict_fix" in lower + ) + if not mentions_conflict_fix: + return {"proven": True, "reasons": [], "applicable": False} + + reasons: list[str] = [] + if not _REPINS_TOOL_RE.search(text): + reasons.append( + "conflict-fix report must cite gitea_assess_conflict_fix_classification " + "(live head re-pin tool)" + ) + + live_match = _LIVE_HEAD_RE.search(text) + if not live_match: + reasons.append( + "conflict-fix report must state Live head SHA: <40-char hex> " + "(or Pinned head SHA / Live PR head SHA)" + ) + else: + live_sha = _normalize_sha(live_match.group(1)) + if live_sha is None: + reasons.append("live head SHA is not a full 40-char hex digest") + + inv_match = _INVENTORY_HEAD_RE.search(text) + # Inventory head is optional but recommended when classification is stale skip. + class_match = _CLASSIFICATION_RE.search(text) + if not class_match: + reasons.append( + "conflict-fix report must state Conflict-fix classification: " + f"<{'|'.join(sorted(_VALID_CLASSIFICATIONS))}>" + ) + else: + classification = class_match.group(1).strip().lower().replace(" ", "_") + # allow hyphenated forms + classification = classification.replace("-", "_") + if classification not in _VALID_CLASSIFICATIONS: + reasons.append( + f"unknown conflict-fix classification {class_match.group(1)!r}; " + f"expected one of {sorted(_VALID_CLASSIFICATIONS)}" + ) + + if inv_match and live_match: + inv_sha = _normalize_sha(inv_match.group(1)) + live_sha = _normalize_sha(live_match.group(1)) + if inv_sha and live_sha and inv_sha != live_sha: + # Require explicit note that live head was used. + if "use live head" not in lower and "live head only" not in lower: + reasons.append( + "inventory head differs from live head; report must state that " + "the live head was used exclusively" + ) + + return { + "proven": not reasons, + "reasons": reasons, + "applicable": True, + "inventory_head_sha": _normalize_sha(inv_match.group(1)) if inv_match else None, + "live_head_sha": _normalize_sha(live_match.group(1)) if live_match else None, + "classification": ( + class_match.group(1).strip().lower().replace("-", "_").replace(" ", "_") + if class_match + else None + ), + } diff --git a/create_issue.py b/create_issue.py index 6ec1b4b..5a93532 100644 --- a/create_issue.py +++ b/create_issue.py @@ -29,6 +29,7 @@ from gitea_auth import ( get_credentials, resolve_remote, add_remote_args, api_request, repo_api_url, ) +import issue_workflow_labels def main(argv=None): @@ -38,6 +39,16 @@ def main(argv=None): parser.add_argument("--body", default="", help="Issue body text.") parser.add_argument("--body-file", help="Read issue body from this file ('-' for stdin).") + parser.add_argument("--label", action="append", default=[], + help="Existing label name to apply; may be repeated.") + parser.add_argument("--type-label", + help="Issue type, e.g. feature or type:feature.") + parser.add_argument("--status-label", + help="Initial status, e.g. ready or status:ready.") + parser.add_argument("--discussion", action="store_true", + help="Apply type:discussion.") + parser.add_argument("--require-workflow-labels", action="store_true", + help="Fail unless one type:* and one status:* label are supplied.") args = parser.parse_args(argv) host, org, repo = resolve_remote(args) @@ -50,6 +61,24 @@ def main(argv=None): with open(args.body_file, "r", encoding="utf-8") as fh: body = fh.read() + requested_labels = issue_workflow_labels.labels_for_new_issue( + issue_type=args.type_label, + initial_status=args.status_label, + extra_labels=args.label, + discussion=args.discussion, + ) + label_assessment = issue_workflow_labels.assess_issue_labels( + requested_labels, + discussion=args.discussion, + ) + if args.require_workflow_labels and not label_assessment["valid"]: + print( + "Workflow label validation failed: " + + "; ".join(label_assessment["errors"]), + file=sys.stderr, + ) + return 1 + user, password = get_credentials(host) if not user or not password: print(f"Could not get credentials for {host} " @@ -59,11 +88,34 @@ def main(argv=None): import base64 auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}" - url = f"{repo_api_url(host, org, repo)}/issues" + base = repo_api_url(host, org, repo) + url = f"{base}/issues" try: + label_ids = [] + if requested_labels: + existing = api_request("GET", f"{base}/labels", auth) + by_name = {lb["name"]: lb["id"] for lb in existing} + missing = [name for name in requested_labels if name not in by_name] + if missing: + print(f"Missing labels: {missing}", file=sys.stderr) + return 1 + label_ids = [by_name[name] for name in requested_labels] data = api_request("POST", url, auth, {"title": args.title, "body": body}) + if label_ids: + api_request( + "PUT", + f"{base}/issues/{data.get('number')}/labels", + auth, + {"labels": label_ids}, + ) print(f"Issue #{data.get('number')}: {data.get('html_url')}") + if not label_assessment["valid"]: + print( + "Workflow label recommendation: " + + "; ".join(label_assessment["errors"]), + file=sys.stderr, + ) return 0 except RuntimeError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/docs/canonical-state-comments.md b/docs/canonical-state-comments.md new file mode 100644 index 0000000..555a91b --- /dev/null +++ b/docs/canonical-state-comments.md @@ -0,0 +1,183 @@ +# Canonical State Comments + +Gitea is the durable system of record for workflow continuation. When a +comment changes issue, PR, or discussion state, it should leave enough +information for the next role to continue without private chat history. + +Canonical comments answer: + +- what state the object is in +- who acts next +- what the next actor should do +- the exact prompt the next actor should run +- which proof, blocker, or dependency matters + +Non-workflow discussion comments do not need this template. + +## Issue State + +Use this when an issue becomes ready, blocked, in progress, PR-open, +superseded, merged, or otherwise changes workflow direction. + +```text +## Canonical Issue State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +RELATED_DISCUSSION: + + +RELATED_PRS: +- #... + +BRANCH: + + +HEAD_SHA: +<40-character SHA or none> + +VALIDATION: + + +BLOCKERS: + + +LAST_UPDATED_BY: + +``` + +## PR State + +Use this when a PR needs review, receives changes requested, is approved, +is stale, is superseded, or becomes ready for merge. + +```text +## Canonical PR State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +ISSUE: +#... + +BASE: + + +HEAD: + + +HEAD_SHA: +<40-character SHA> + +REVIEW_STATUS: + + +VALIDATION: + + +BLOCKERS: + + +SUPERSEDES: + + +SUPERSEDED_BY: + + +MERGE_READY: + + +LAST_UPDATED_BY: + +``` + +## Discussion Summary + +Discussions should normally have at least five substantive comments before +conversion into issues. A controller may waive that only for tiny mechanical, +urgent, or explicitly trivial work. + +```text +## Canonical Discussion Summary + +STATE: + + +WHO_IS_NEXT: + + +DECISION: + + +WHY: + + +SUBSTANTIVE_COMMENTS: + + +ISSUES_TO_CREATE_OR_CREATED: +- #... + +DEPENDENCY_ORDER: + + +NON_GOALS: + + +OPEN_QUESTIONS: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +LAST_UPDATED_BY: + +``` + +## Validation Rules + +The final-report validator rejects canonical state update claims when the +report omits the canonical block or when the block lacks: + +- `STATE` +- `WHO_IS_NEXT` +- `NEXT_ACTION` +- `NEXT_PROMPT` + +It also rejects vague next actions such as `continue`, ready-to-merge states +without approval/head-SHA proof, superseded states without canonical item +proof, and blocked states without an unblock condition. diff --git a/docs/canonical-thread-handoff.md b/docs/canonical-thread-handoff.md new file mode 100644 index 0000000..0e45000 --- /dev/null +++ b/docs/canonical-thread-handoff.md @@ -0,0 +1,142 @@ +# Canonical Thread Handoff (CTH) + +**CTH** = **Canonical Thread Handoff** + +A CTH comment is the authoritative workflow handoff in a Gitea issue or PR +thread. It records current state, decisions, blockers, proof, and the exact +next prompt/action for the next LLM or person. + +CTH comments complement — but do not replace — formal Gitea review state. +A CTH may summarize an APPROVE or REQUEST_CHANGES decision, yet merge gates +still require the live Gitea review verdict. + +## CTH comment types + +- `CTH: State Handoff` +- `CTH: Controller Decision` +- `CTH: Author Handoff` +- `CTH: Reviewer Handoff` +- `CTH: Merger Handoff` +- `CTH: Supersession Notice` +- `CTH: Blocker` + +## Required base template + +```md +## CTH: + +Status: +Next owner: +Current blocker: +Decision: +Proof: +Next action: +Ready-to-paste prompt: +``` + +Extended fields may map to canonical issue/PR state templates from #495 when +that layer is available. Until then, keep the base fields complete. + +## Discovery and posting rules + +Before acting in an issue or PR thread: + +1. **Find the latest CTH comment** before doing work. +2. Treat the **latest valid CTH** as the current handoff state. +3. **Post a new CTH** when you finish, block, skip, supersede, request + changes, approve, or hand off. +4. Do **not** rely on stale non-CTH comments when a newer CTH exists. +5. Casual discussion comments do not need to be CTH comments. + +## Examples + +### PR approved and ready for merger + +```md +## CTH: Reviewer Handoff + +Status: approved_at_current_head +Next owner: merger +Current blocker: none +Decision: APPROVE recorded at head abc123... +Proof: gitea_submit_pr_review performed; visible verdict APPROVE +Next action: eligible merger merges PR #N with pinned expected_head_sha +Ready-to-paste prompt: Merge PR #N for issue #M if live head still abc123... and merge gates pass. +``` + +### PR request-changes back to author + +```md +## CTH: Reviewer Handoff + +Status: request_changes_at_current_head +Next owner: author +Current blocker: unresolved findings in validation report +Decision: REQUEST_CHANGES at head def456... +Proof: gitea_submit_pr_review performed; blocking review visible +Next action: author fixes findings and pushes; reviewer re-validates fresh head +Ready-to-paste prompt: Fix PR #N review findings, push to feat/issue-M-..., post Author Handoff CTH. +``` + +### Duplicate / superseded PR closure + +```md +## CTH: Supersession Notice + +Status: superseded +Next owner: controller +Current blocker: duplicate branch/PR work +Decision: close PR #N; continue on PR #M +Proof: duplicate gate linked open PR #M for issue #K +Next action: controller closes superseded PR and records canonical state +Ready-to-paste prompt: Close superseded PR #N; confirm PR #M remains canonical for issue #K. +``` + +### Blocked workflow due to dirty root/worktree + +```md +## CTH: Blocker + +Status: blocked_preflight +Next owner: operator +Current blocker: dirty control checkout / branches worktree mismatch +Decision: stop before mutation +Proof: verify_preflight_purity failed; workspace diagnostics attached +Next action: repair worktree or relaunch MCP from branches/ worktree +Ready-to-paste prompt: Repair dirty workspace, relaunch MCP from branches/review-prN, retry review. +``` + +### Stale head requiring fresh review + +```md +## CTH: Reviewer Handoff + +Status: stale_head +Next owner: reviewer +Current blocker: live head differs from pinned review head +Decision: prior approval not valid for current head +Proof: expected_head_sha mismatch at merge gate +Next action: re-run validation and post fresh review decision +Ready-to-paste prompt: Re-validate PR #N at live head, dry-run review gates, submit fresh verdict. +``` + +### Issue implementation handoff + +```md +## CTH: Author Handoff + +Status: implementation_complete_pending_review +Next owner: reviewer +Current blocker: none +Decision: PR #N ready for review at head fedcba... +Proof: tests passed in branches/issue-M-...; PR opened with Closes #M +Next action: reviewer acquires lease and validates PR #N +Ready-to-paste prompt: Review PR #N for issue #M; pin head fedcba... before mutations. +``` + +## Related + +- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) +- [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md) +- #495 — canonical next-action comment fields +- #496 — fail-closed validation for workflow-changing comments \ No newline at end of file diff --git a/docs/examples/two-comment-workflow-examples.md b/docs/examples/two-comment-workflow-examples.md new file mode 100644 index 0000000..84e1d58 --- /dev/null +++ b/docs/examples/two-comment-workflow-examples.md @@ -0,0 +1,43 @@ +# Two-comment workflow examples (#507) + +Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads. +See `thread_state_ledger_examples.py` for machine-checked fixtures. + +## Approved review posted + +**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with +`gitea_submit_pr_review → APPROVED review posted to Gitea`. + +**Ledger** (concise): + +```markdown +[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea + +What is true now: +- PR state: open +- Server-side decision state: APPROVED review posted to Gitea +- Local verdict/state: APPROVE verdict prepared locally + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: merger +- Required action: merge on explicit operator command +- Do not do: re-post APPROVE +``` + +## Approve validated locally but blocked before posting + +Ledger must show `no server-side state changed` under server-side decision state and +`APPROVE verdict prepared locally` under local verdict/state. + +## Environment / tooling blocker + +Ledger blocker classification: `environment/tooling blocker`. Mutation ledger: +`none — no server-side state changed`. + +## Stale head blocker + +Ledger: `approval_at_current_head is false`; classification `stale head`. +Do not do: merge with stale approval. \ No newline at end of file diff --git a/docs/gitea-dual-namespace-deployment.md b/docs/gitea-dual-namespace-deployment.md index 6ee7e8e..f25f09f 100644 --- a/docs/gitea-dual-namespace-deployment.md +++ b/docs/gitea-dual-namespace-deployment.md @@ -22,9 +22,12 @@ launched with exactly one static execution profile: | Namespace (MCP server name) | Profile (role) | Typical use | |-----------------------------|----------------|-------------| | `gitea-author` | an author profile | implement issues, push branches, open PRs, comment | -| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge | +| `gitea-reviewer` | a reviewer profile | review, approve/request changes | +| `gitea-merger` | a merger profile | merge PRs after approval and verification | | `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) | +Review and merge are separate workflow roles. A reviewer approval is not merge authorization. + Properties: - **One process, one credential.** Each namespace authenticates as exactly @@ -106,6 +109,14 @@ syntax to the client): "GITEA_MCP_CONFIG": "", "GITEA_MCP_PROFILE": "" } + }, + "gitea-merger": { + "command": "/venv/bin/python3", + "args": ["/mcp_server.py"], + "env": { + "GITEA_MCP_CONFIG": "", + "GITEA_MCP_PROFILE": "" + } } } } diff --git a/docs/gitea-execution-profiles.md b/docs/gitea-execution-profiles.md index 3d9958f..970f5d4 100644 --- a/docs/gitea-execution-profiles.md +++ b/docs/gitea-execution-profiles.md @@ -311,7 +311,8 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo ### 2. Dual MCP Namespaces Recommendation For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles: - `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges. -- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges. +- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization. +- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges. This layout maintains physical separation of credentials and prevents privilege escalation within a single session. This is the model accepted in #139; deployment details, rationale, and client setup live in diff --git a/docs/label-taxonomy.md b/docs/label-taxonomy.md index e3cf98b..d1c400d 100644 --- a/docs/label-taxonomy.md +++ b/docs/label-taxonomy.md @@ -1,34 +1,98 @@ # Label Taxonomy -This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability). +This document defines the canonical issue labels used by MCP workflows. -> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document. +Every issue should carry: -## Existing Labels +- one `type:*` label +- one `status:*` label -* **`jenkins`** - * Description: Jenkins integration - * Color: `d93f0b` - * Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures. +Discussion-only issues must carry `type:discussion`. -* **`glitchtip`** - * Description: GlitchTip integration - * Color: `b60205` - * Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration. +## Issue Type Labels -## Proposed / Missing Labels +| Label | Use | +| --- | --- | +| `type:bug` | Bug or defect | +| `type:feature` | Feature or enhancement | +| `type:process` | Process or policy work | +| `type:workflow` | Workflow automation or guidance | +| `type:guardrail` | Safety gate or guardrail | +| `type:docs` | Documentation work | +| `type:test` | Tests or test infrastructure | +| `type:discussion` | Discussion-only issue | +| `type:umbrella` | Umbrella or tracker issue | +| `type:cleanup` | Cleanup or hygiene work | -* **`observability`** - * Proposed Description: Observability, metrics, and monitoring tasks - * Proposed Color: `5319e7` - * Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements. +## Workflow Status Labels -* **`source:glitchtip`** - * Proposed Description: Issue filed automatically by GlitchTip orchestration - * Proposed Color: `b60205` - * Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue. +Only one `status:*` label should be active on an issue at a time. When an issue +moves forward, tooling must remove the old `status:*` label and apply the new +one. -* **`status:triage`** - * Proposed Description: Issue needs human or orchestrator triage - * Proposed Color: `fbca04` - * Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution. +| Label | Use | +| --- | --- | +| `status:triage` | Issue needs triage | +| `status:ready` | Issue is ready for work | +| `status:claimed` | Issue is claimed | +| `status:in-progress` | Issue is being worked on | +| `status:blocked` | Issue is blocked | +| `status:needs-review` | Issue work needs review | +| `status:pr-open` | A linked PR is open | +| `status:approved` | Linked PR is approved | +| `status:merged` | Linked PR is merged | +| `status:reconcile` | Issue needs reconciliation | +| `status:done` | Issue workflow is complete | +| `status:duplicate` | Issue is a duplicate | +| `status:wontfix` | Issue will not be fixed | + +## Transition Rules + +Suggested lifecycle: + +1. New issue created: `status:triage` or `status:ready` +2. Issue selected by an author: `status:claimed` +3. Author starts work: `status:in-progress` +4. Work is blocked: `status:blocked` +5. PR opened: `status:pr-open` +6. PR approved: `status:approved` +7. PR merged but issue still needs closure/reconciliation: `status:reconcile` +8. Issue fully complete: `status:done` +9. Duplicate issue: `status:duplicate` +10. Won't-fix issue: `status:wontfix` + +The helper module `issue_workflow_labels.py` is the source of truth for the +canonical label specs and status transition replacement behavior. + +## Discussion Issues + +Discussion issues must be labeled `type:discussion`. + +A discussion issue should not be treated as implementation-ready unless it also +has a clear implementation status and next action. + +If a discussion produces implementation work, either: + +1. convert the discussion issue into an implementation issue by changing labels + and adding acceptance criteria, or +2. create child implementation issues and leave the discussion issue as + `type:discussion`. + +## Tooling + +- `manage_labels.py --create-labels` creates the canonical `type:*` and + `status:*` labels. +- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing + and can apply supplied label names. +- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with + `status:in-progress`. +- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot + be applied to the locked issue, then applies it after the PR is created. +- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author + sessions can satisfy the branches-only mutation guard while changing labels. + +## Existing Non-Workflow Labels + +Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`, +`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical +labels. They do not replace the required `type:*` and `status:*` labels. diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index ce42f39..ea2f64e 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -7,6 +7,11 @@ package of the MCP Control Plane: creating issues, implementing them, opening and reviewing pull requests, merging, and closing out — safely and reproducibly. +Canonical state comments for durable issue/PR/discussion continuation are +documented in [`canonical-state-comments.md`](canonical-state-comments.md). +Use them when a workflow-changing comment needs to leave the next actor, next +action, and paste-ready prompt in Gitea. + > For the **project-agnostic** version of these operating rules (issue-first, > isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed) > that can be copied into any repository, see the reusable skill @@ -28,6 +33,8 @@ audit logging). See [Related documents](#related-documents). > to discover the available project workflows and `mcp_get_skill_guide()` > for step-by-step instructions. This replaces long pasted operator prompts for > the standard rules; operator prompts still control task-specific scope. +> +> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop. > See issue #129 for the skill registry design. Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP @@ -408,6 +415,31 @@ The guard blocks when the **control checkout** (repository root) is: `branches/...` directories are disposable role worktrees; the root checkout is the stable orchestration surface only. +## Canonical workflow skill names (#551) + +Controller prompts and sessions must load the **same** workflow skill wall +regardless of runtime (Claude, Codex, Gemini): + +| Name | Role | +|------|------| +| `gitea-workflow` | Primary controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package | +| `git-pr-workflows` | Legacy alias | + +- Inventory: `mcp_list_project_skills` lists all three. +- Preflight: `mcp_check_workflow_skill_preflight` before mutations. +- Codex install: `scripts/install-codex-workflow-skill.sh` +- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md) + +If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate. + +## No direct-import mutation path (#558) + +Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` / +keychain fill from a raw shell to bypass MCP preflight. + +Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md). + ## Bootstrap Review Path for self-hosted MCP fixes (#557) When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still @@ -634,19 +666,24 @@ loop and do **not** substitute WebFetch/Playwright/manual base64. - **Profile:** issue-manager or author (any profile allowed to create issues). - **Steps:** create the parent/roadmap issue; create child issues; apply the minimal label set; link children to the parent. +- **Labels:** new issues should carry one `type:*` label and one `status:*` + label. Discussion-only issues must carry `type:discussion`. See + [`label-taxonomy.md`](label-taxonomy.md). - **Prompt:** `Using the issue-manager profile, create issue "" with body <body>, then create child issues for <list> and link them to the parent.` ### Implement an issue and open a PR - **Profile:** author. -- **Steps:** claim the issue (`status:in-progress`); create an isolated branch - worktree from latest `master` under `branches/` (`feat/issue-<n>-...` / +- **Steps:** claim the issue (`status:in-progress`, replacing any old + `status:*` label); create an isolated branch worktree from latest `master` + under `branches/` (`feat/issue-<n>-...` / `fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or update tests if behavior changes; run the full suite; commit with an - issue-linked message; open a PR to `master`. **Do not** review or merge your - own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in - the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md). + issue-linked message; open a PR to `master`; move the issue to + `status:pr-open`. **Do not** review or merge your own PR. Include an + `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see + [`llm-agent-sha.md`](llm-agent-sha.md). - **Prompt:** `Use an author profile to implement issue #N and open a PR to master. Do not self-review or self-merge.` @@ -687,10 +724,10 @@ loop and do **not** substitute WebFetch/Playwright/manual base64. ### Merge a PR - **Profile:** merger (allowed to merge; must **not** be the PR author). -- **Steps:** confirm eligibility; require explicit confirmation - (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when - Gitea reports the PR mergeable (branch-protection checks satisfied). No force, - no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes). +- **Steps:** + - Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff. + - Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes). + - Review and merge are separate workflow roles. A reviewer approval is not merge authorization. - **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and it is mergeable. Confirm with "MERGE PR N". Do not force-merge.` @@ -779,7 +816,7 @@ an author. |---|---|---|---|---| | Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks | | Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push | -| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails | +| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails | | Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) | | Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing | | Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions | @@ -829,6 +866,27 @@ Never imply full-suite success unless the full-suite command itself passed (`full_suite_passed: true`). A report that hides a failed or skipped check is worse than a failing report. +## Canonical Thread Handoff (CTH) + +**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff +comment in a Gitea issue or PR thread. See +[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types, +templates, and examples. + +Before acting in an issue or PR thread: + +1. **Find the latest CTH comment** before doing work. +2. Treat the **latest valid CTH** as the current handoff state. +3. **Post a new CTH** when you finish, block, skip, supersede, request + changes, approve, or hand off. +4. Do **not** rely on stale non-CTH comments when a newer CTH exists. + +A CTH summarizes workflow state for the next session. Formal Gitea review +verdicts remain authoritative for merge gates — a CTH is not merge approval +by itself. + +Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md) + ## Controller Handoff (required, every task) Every task — implementation, review, merge, triage, documentation, @@ -861,6 +919,95 @@ touched release state names the exact tag/commit and why. Design debates belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment on the issue, create no branches/PRs, and end the comment with this handoff. +## Two-comment workflow reporting (#507) + +After meaningful controller/workflow work, post **two separate Gitea comments** +(not one combined blob): + +1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the + next LLM/controller (proof-heavy; may be long). +2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds. + +The ledger must answer: what is true now, what changed, what is blocked, +who/what acts next — and must **separate**: + +- local verdict/state +- server-side Gitea state +- attempted-but-blocked mutations +- completed mutations + +Use precise state phrases (`APPROVED review posted to Gitea`, +`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`, +`no server-side state changed`, `lease attempt blocked`) instead of ambiguous +standalone words (`approved`, `merged`, `ready`, `blocked`, `done`). + +The ledger must include a **blocker classification** from: +`code blocker`, `test blocker`, `merge conflict`, `stale head`, +`permission/capability blocker`, `environment/tooling blocker`, +`process/rule blocker`, `queue/lease blocker`, +`duplicate/canonicalization blocker`, `no blocker`. + +Templates: [`two-comment-workflow.md`](two-comment-workflow.md). +Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md). + +Validation: `thread_state_ledger_validator.py` checks tagged comments at post +time (`gitea_create_issue_comment`) and tagged final reports via +`assess_final_report_validator`. Legacy `## Controller Handoff` final reports +remain valid during transition; the tagged pair is required for new workflow +comments. + +Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger +consistency, #505 CTH umbrella, #496 workflow comment gate when merged. + +## Canonical comment validation (#496) + +Workflow-changing issue/PR/review comments must carry durable next-action +state. Casual discussion is still allowed. + +The MCP server runs `canonical_comment_validator.assess_canonical_comment` +**before** posting through: + +- `gitea_create_issue_comment` +- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies) +- `gitea_reconcile_already_landed_pr` when `post_comment=True` +- internal structured comment helpers (machine lease/heartbeat markers stay exempt) + +Detection examples: + +- **Allowed:** `Thanks, I will check this.` +- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields) + +When validation fails, the tool returns `canonical_comment_validation` with +`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and +`suggested_template`. **No Gitea API call is made.** + +Minimum workflow comment fields: + +```text +STATE: +WHO_IS_NEXT: +NEXT_ACTION: +NEXT_PROMPT: +WHY: +``` + +`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`, +`reconciler`, `user`. + +Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when +they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`, +`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`. + +Special states: + +- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition. +- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`. +- `STATE: ready-to-merge` — requires approval proof and head SHA in + `HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`. + +Final reports must not claim a comment was posted when +`canonical_comment_validation.allowed` is false (#496 AC14). + ## Fail-closed behavior Before any mutating action the workflow verifies identity, active profile, @@ -972,6 +1119,7 @@ When posting a Canonical Thread Handoff after a binding blocker: ## Related documents +- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501). - [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500). - [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill. - [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model. diff --git a/docs/mcp-daemon-import-guard.md b/docs/mcp-daemon-import-guard.md new file mode 100644 index 0000000..8378dbd --- /dev/null +++ b/docs/mcp-daemon-import-guard.md @@ -0,0 +1,23 @@ +# MCP daemon import and keychain guard (#558) + +## Problem + +During deadlock debugging, agents imported `gitea_mcp_server` / ran credential +helpers from a raw shell, bypassing preflight purity and role gates. + +## Rule + +Mutation auth and keychain fill require a **sanctioned MCP daemon** process. + +| Context | Allowed | +|---------|---------| +| Official MCP entrypoint (`mcp_server.py` / `gitea_mcp_server` `__main__`) sets `GITEA_MCP_SANCTIONED_DAEMON=1` | yes | +| pytest | yes | +| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` (operator/tests only) | yes | +| bare `python -c 'import gitea_auth; get_auth_header(...)'` | **no** | +| keychain fill without daemon | **no** unless `GITEA_ALLOW_KEYCHAIN_CLI=1` | + +## Operator note + +LLM sessions must never set the allow-direct-import or allow-keychain-cli +overrides. Those are human-only escape hatches. diff --git a/docs/reviewer-handoff-consistency.md b/docs/reviewer-handoff-consistency.md new file mode 100644 index 0000000..27d8108 --- /dev/null +++ b/docs/reviewer-handoff-consistency.md @@ -0,0 +1,35 @@ +# Reviewer Handoff Consistency + +Reviewer and final-review controller handoffs must not contradict themselves. +A narrative that says a merge happened, a review was blocked, or a terminal +mutation budget was consumed must match the mutation ledger fields in the same +handoff. + +## What gets validated + +`reviewer_handoff_consistency.assess_reviewer_handoff_consistency()` checks: + +- merge claims appear under `Merge mutations` or `MCP/Gitea mutations` +- terminal-mutation-budget claims name the exact prior mutation in the ledger +- blocked review submission is not paired with "final decision marked" +- reviewer lease acquisition includes a `Review decision` +- blocked/rejected mutations include proof fields: + - tool called + - mutation attempted + - mutation rejected + - no server-side state changed + +`final_report_validator` applies this as `reviewer.handoff_consistency` on +`review_pr` reports and fails closed. + +## Blocked review template + +When `gitea_submit_pr_review` fails closed, use +`reviewer_handoff_consistency.render_blocked_review_handoff_template()` or the +copy in +[`skills/llm-project-workflow/templates/blocked-review-handoff.md`](../skills/llm-project-workflow/templates/blocked-review-handoff.md). + +## Related + +- #331 — file-mutation ledger alignment +- #501 — contradictory narrative vs ledger detection \ No newline at end of file diff --git a/docs/two-comment-workflow.md b/docs/two-comment-workflow.md new file mode 100644 index 0000000..8da1ace --- /dev/null +++ b/docs/two-comment-workflow.md @@ -0,0 +1,123 @@ +# Two-comment workflow: Controller Handoff + Thread State Ledger + +After meaningful controller/workflow work, post **two separate Gitea comments**: + +1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next + LLM/controller session (proof-heavy; may be long). +2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds. + +The ledger is the concise source of truth. The handoff is the detailed +continuation artifact. This complements CTH (#505) and lifecycle state +comments (#494/#495) without replacing them. + +## Controller Handoff template + +```markdown +[CONTROLLER HANDOFF] PR #___ / Issue #___ — <short title> + +Purpose: +This comment is the operational handoff for the next controller/LLM session. + +Identity/profile: +- Active profile: +- Authenticated identity: +- Role: +- Self-review / role-conflict proof: + +Target: +- Repo: +- Issue: +- PR: +- Branch: +- Pinned head SHA: +- Worktree: + +Work performed: +- <step 1> +- <step 2> + +Files touched or reviewed: +- `<file>` — <why it matters> + +Validation: +- `<command>` → <result> +- Full suite: <result or not run + reason> + +Server-side mutation ledger: +- <mutation 1, including tool/action/comment id if available> +- Or: none — no server-side state changed + +Local-only changes: +- <worktree created, files edited locally, etc.> +- Or: none + +Blockers: +- <none> +- Or: <exact blocker, exact gate, exact reason> + +Controller prompt for next session: +```markdown +<ready-to-paste prompt> +``` +``` + +## Thread State Ledger template + +```markdown +[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line> + +What is true now: +- PR state: +- Issue state: +- Current head SHA: +- Server-side decision state: +- Local verdict/state: +- Latest known validation: + +What changed: +- <short summary since prior ledger> + +What is blocked: +- Blocker classification: <see list below> + +Who/what acts next: +- Next actor: +- Required action: +- Do not do: +- Resume from: +``` + +### Blocker classifications + +- code blocker +- test blocker +- merge conflict +- stale head +- permission/capability blocker +- environment/tooling blocker +- process/rule blocker +- queue/lease blocker +- duplicate/canonicalization blocker +- no blocker + +### Precise state language + +Prefer: + +- `APPROVE verdict prepared locally` +- `APPROVED review posted to Gitea` +- `REQUEST_CHANGES posted to Gitea` +- `merge performed` / `merge not performed` +- `lease acquired` / `lease attempt blocked` +- `server-side state changed` / `no server-side state changed` + +Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`, +`done`) without proof and server-side state separation. + +## Validation + +- `thread_state_ledger_validator.py` — comment and final-report checks +- `gitea_create_issue_comment` — fail-closed gate on tagged comments +- `assess_final_report_validator` — `shared.two_comment_workflow` rule + +Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md) \ No newline at end of file diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index f4542cd..990914e 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -45,17 +45,27 @@ Optional environment variables: | `/api/prompts` | JSON prompt export with workflow hashes | | `/runtime` | MCP runtime health and stale detection (#430) | | `/api/runtime` | JSON runtime health export | -| `/audit` | Stub — report audit paste (#431) | -| `/worktrees` | Stub — hygiene dashboard (#432) | -| `/leases` | Stub — lease visibility (#433) | +| `/audit` | Report audit paste + validator preview (#431) | +| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) | +| `/worktrees` | Worktree hygiene dashboard (#432) | +| `/api/worktrees` | JSON worktree scan with classifications and anomalies | | `/actions` | Gated write-action registry — all disabled in MVP (#434) | | `/api/actions` | JSON action registry with capability metadata | | `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) | | `/leases` | Lease and collision visibility (#433) | | `/api/leases` | JSON lease/collision export | -All routes are GET-only except registered POST handlers, which still return -`405` with `read-only-mvp` until write paths ship. +Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with +`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for +local validator preview only (no Gitea mutations, no server-side storage). + +## Report audit (#431) + +Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI +reuses `final_report_validator` and review schema checks to surface missing +proof fields, wrong validation vocabulary, mutation contradictions, and a +suggested next prompt or issue-comment draft. Task kind can be auto-detected +or selected explicitly. ## Project registry (#427) @@ -96,6 +106,17 @@ permission, and profile role from `task_capability_map.py` — aligned with `gitea_resolve_task_capability`. Buttons are disabled; previews always render a mutation ledger. Direct `attempt_action` calls fail closed without invoking MCP tools. + +## Worktree hygiene (#432) + +`/worktrees` scans local `branches/` directories and registered git worktrees. +Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`, +`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees +referenced by the issue lock file are flagged as anomalies (#404). The page +includes a copy/paste canonical cleanup prompt only — no deletion actions. + +Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root). + ## Lease visibility (#433) `/leases` surfaces read-only lease and collision state: local issue lock file, @@ -117,5 +138,6 @@ no tokens or MCP restart actions are exposed. ```bash pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q ``` \ No newline at end of file diff --git a/docs/wiki/Identity-and-Profiles.md b/docs/wiki/Identity-and-Profiles.md index 763a3bb..fb6000d 100644 --- a/docs/wiki/Identity-and-Profiles.md +++ b/docs/wiki/Identity-and-Profiles.md @@ -16,12 +16,21 @@ bind an authenticated Gitea identity to an allowed operation set. - **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read. - **Forbidden:** PR approve, merge, request_changes. -### Reviewer / merger +### Reviewer - **Profile:** `prgs-reviewer` - **Typical identity:** `sysadmin` -- **Allowed:** PR review/approve/merge/request_changes, issue comment, read. -- **Forbidden:** branch push, PR create, repo commit. +- **Allowed:** PR review/approve/request_changes, issue comment, read. +- **Forbidden:** branch push, PR create, repo commit, PR merge. + +Review and merge are separate workflow roles. A reviewer approval is not merge authorization. + +### Merger + +- **Profile:** `prgs-merger` +- **Typical identity:** `sysadmin` +- **Allowed:** PR merge, issue comment, read. +- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes. ## Configuration diff --git a/docs/workflow-skill-mount.md b/docs/workflow-skill-mount.md new file mode 100644 index 0000000..408ff0f --- /dev/null +++ b/docs/workflow-skill-mount.md @@ -0,0 +1,68 @@ +# Workflow skill mount across runtimes (#551) + +## Problem + +Controller prompts require **`gitea-workflow`**, but: + +- Claude may load `~/.claude/skills/gitea-workflow` +- Codex often has **no** `~/.codex/skills/gitea-workflow` +- The portable package in-repo is `skills/llm-project-workflow` +- `mcp_list_project_skills` historically listed operational guides only, not + the workflow router + +Sessions then either **block** incorrectly or **proceed without** the workflow +wall. + +## Canonical names (must resolve to the same skill) + +| Name | Use | +|------|-----| +| `gitea-workflow` | **Primary** controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo package name | +| `git-pr-workflows` | Legacy alias | + +Source of truth: `skills/llm-project-workflow/SKILL.md` +In-repo alias stub: `skills/gitea-workflow/SKILL.md` + +## Codex install + +From a `branches/` worktree (or any clone of the repo): + +```bash +./scripts/install-codex-workflow-skill.sh +# optional: +./scripts/install-codex-workflow-skill.sh --dry-run +./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills" +``` + +This symlinks the portable package under all three names. **Restart Codex** +after install. + +## MCP discovery + +- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`, + and `git-pr-workflows`. +- `mcp_get_skill_guide("<name>")` returns the same router steps for each. +- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and + reports Codex mount status. + +## Preflight rule + +Before any git or Gitea mutation: + +1. Call `mcp_check_workflow_skill_preflight`. +2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do + not mutate. +3. Load the skill by **any** canonical name and follow the router. + +Missing Codex mount alone does not block if the in-repo skill is present and +loaded via MCP/docs; operators should still install the Codex symlink so +prompt names resolve natively. + +## Controller prompts + +Prefer: + +> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`). + +Do not require a name that is only available on one runtime. diff --git a/final_report_validator.py b/final_report_validator.py index d368f91..a16b4a0 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -13,6 +13,8 @@ from typing import Any, Callable import issue_acceptance_gate import issue_lock_provenance +import reviewer_handoff_consistency +import thread_state_ledger_validator from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof from post_merge_cleanup_proof import assess_post_merge_cleanup_proof from review_proofs import ( @@ -29,6 +31,7 @@ from validation_status_vocabulary import assess_validation_status_vocabulary FINAL_REPORT_TASK_KINDS = frozenset({ "review_pr", + "merge_pr", "reconcile_already_landed", "author_issue", "work_issue", @@ -40,6 +43,8 @@ FINAL_REPORT_TASK_KINDS = frozenset({ _TASK_KIND_ALIASES = { "review": "review_pr", "review-merge-pr": "review_pr", + "merge": "merge_pr", + "merge_pr": "merge_pr", "reconcile-landed-pr": "reconcile_already_landed", "reconcile_already_landed": "reconcile_already_landed", "work-issue": "work_issue", @@ -48,6 +53,7 @@ _TASK_KIND_ALIASES = { _HANDOFF_ROLE_BY_TASK = { "review_pr": "review", + "merge_pr": "merger", "reconcile_already_landed": None, "author_issue": "author", "work_issue": "author", @@ -195,6 +201,23 @@ _PAGINATION_PROOF_RE = re.compile( re.IGNORECASE, ) _GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE) +_CANONICAL_VALIDATION_REJECTED_RE = re.compile( + r"canonical comment validation failed|" + r"canonical_comment_validation|" + r'"allowed"\s*:\s*false', + re.IGNORECASE, +) +_COMMENT_POSTED_CLAIM_RE = re.compile( + r"(?:issue comments posted\s*:\s+(?!none\b)\S|" + r"pr comments posted\s*:\s+(?!none\b)\S|" + r"comment_id\s*[:=]\s*\d+|" + r"gitea comment (?:was )?posted|" + r"posted (?:issue|pr|canonical) (?:state )?comment|" + r"comment posted successfully|" + r"mcp/gitea mutations\s*:\s*[^;\n]*comment posted|" + r"reconciliation mutations\s*:\s*[^;\n]*comment posted)", + re.IGNORECASE, +) _READONLY_DIAG_RE = re.compile( r"read[- ]only diagnostics\s*:\s*(.+)$", re.IGNORECASE | re.MULTILINE, @@ -381,6 +404,43 @@ def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]: ) +def _rule_shared_canonical_comment_post_claim( + report_text: str, + *, + action_log: list[dict] | None = None, +) -> list[dict[str, str]]: + """#496: reports must not claim comment posted when validator rejected.""" + text = report_text or "" + rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text)) + rejected_in_log = False + if action_log: + for entry in action_log: + validation = entry.get("canonical_comment_validation") or {} + if validation.get("allowed") is False: + rejected_in_log = True + break + result = entry.get("result") or {} + nested = result.get("canonical_comment_validation") or {} + if nested.get("allowed") is False: + rejected_in_log = True + break + if not (rejected_in_report or rejected_in_log): + return [] + if not _COMMENT_POSTED_CLAIM_RE.search(text): + return [] + return [ + validator_finding( + "shared.canonical_comment_post_claim", + "block", + "MCP/Gitea mutations", + "report claims a Gitea comment was posted while canonical comment " + "validation rejected the workflow comment", + "do not claim comment posted when validator fail-closed; repair " + "the canonical comment body and retry posting", + ) + ] + + def _rule_reviewer_legacy_workspace_mutations( report_text: str, *, @@ -598,6 +658,27 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]: ) +def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]: + from conflict_fix_classification import ( + assess_conflict_fix_classification_final_report, + ) + + text = report_text or "" + result = assess_conflict_fix_classification_final_report(text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "author.conflict_fix_classification_proof", + result.get("reasons") or [], + field="Conflict-fix classification", + severity="block", + safe_next_action=( + "call gitea_assess_conflict_fix_classification, state the live head " + "SHA, and state the classification before creating a conflict-fix worktree" + ), + ) + + def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]: from pr_work_lease import assess_conflict_fix_final_report @@ -1111,6 +1192,25 @@ def _rule_reviewer_validation_structured( ) +def _rule_reviewer_handoff_consistency(report_text: str) -> list[dict[str, str]]: + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency( + report_text + ) + if result.get("proven"): + return [] + return _findings_from_reasons( + "reviewer.handoff_consistency", + result.get("reasons") or [], + field="Review mutations", + severity="block", + safe_next_action=( + "rewrite reviewer handoff so narrative claims match the mutation " + "ledger; use the blocked-review handoff template when submission " + "was rejected" + ), + ) + + def _rule_reviewer_mutation_ledger( report_text: str, *, @@ -1277,6 +1377,29 @@ def _rule_reviewer_review_mutation( ) +def _rule_shared_two_comment_workflow(report_text: str) -> list[dict[str, str]]: + """#507: tagged Controller Handoff must pair with Thread State Ledger.""" + return thread_state_ledger_validator.findings_for_final_report(report_text) + + +def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]: + from canonical_state_comments import validate_final_report_state_update + + result = validate_final_report_state_update(report_text) + if not result.get("applicable") or result.get("valid"): + return [] + return [ + validator_finding( + "shared.canonical_state_update", + "block", + "Canonical state update", + reason, + "include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT", + ) + for reason in (result.get("reasons") or ["invalid canonical state update"]) + ] + + def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]: from reviewer_mutation_capability_proof import assess_mutation_capability_proof @@ -1325,17 +1448,27 @@ _SHARED_ISSUE_LOCK_RULES = ( _rule_shared_issue_lock_external_state, _rule_shared_manual_lock_pr_override, _rule_shared_author_reviewer_same_run, + _rule_shared_canonical_state_update, ) +_SHARED_TWO_COMMENT_RULES = ( + _rule_shared_two_comment_workflow, +) _SHARED_CLEANUP_PROOF_RULES = ( _rule_shared_mcp_native_cleanup_proof, ) +_SHARED_CANONICAL_COMMENT_RULES = ( + _rule_shared_canonical_comment_post_claim, +) + _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "review_pr": [ _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, @@ -1354,6 +1487,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_reviewer_already_landed_eligible, _rule_reviewer_already_landed_state, _rule_reviewer_target_branch_freshness, + _rule_reviewer_handoff_consistency, _rule_reviewer_workflow_load_boundary, _rule_reviewer_mutation_ledger, _rule_reviewer_review_mutation, @@ -1362,10 +1496,23 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { *_SHARED_CLEANUP_PROOF_RULES, _rule_reviewer_stale_head_proof, ], + "merge_pr": [ + _rule_shared_controller_handoff, + _rule_shared_email_disclosure, + *_SHARED_ISSUE_LOCK_RULES, + _rule_reviewer_legacy_workspace_mutations, + _rule_reviewer_vague_mutations_none, + _rule_reviewer_mutation_categories, + _rule_reviewer_git_fetch_readonly, + _rule_reviewer_linked_issue, + _rule_reviewer_stale_head_proof, + ], "reconcile_already_landed": [ _rule_reconcile_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, *_SHARED_CLEANUP_PROOF_RULES, _rule_reconcile_stale_author_fields, @@ -1383,6 +1530,8 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_vague_mutations_none, ], @@ -1390,9 +1539,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_shared_issue_acceptance_gate, _rule_reviewer_vague_mutations_none, + _rule_conflict_fix_classification_proof, _rule_conflict_fix_push_proof, _rule_worktree_cleanup_audit_proof, ], @@ -1400,12 +1552,16 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], "inventory": [ _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reconcile_pagination_proof, ], @@ -1413,6 +1569,8 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_shared_controller_handoff, _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_TWO_COMMENT_RULES, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], } diff --git a/gitea-mcp.v2-contexts.example.json b/gitea-mcp.v2-contexts.example.json index 4bdad56..bd5b1ee 100644 --- a/gitea-mcp.v2-contexts.example.json +++ b/gitea-mcp.v2-contexts.example.json @@ -52,8 +52,19 @@ "execution_profile": "example-reviewer", "audit_label": "example-reviewer", "auth": { "type": "keychain", "id": "example-gitea-reviewer-token" }, - "allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"], - "forbidden_operations": ["branch", "commit", "push", "open_pr"] + "allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"], + "forbidden_operations": ["branch", "commit", "push", "open_pr", "merge"] + }, + "example-merger": { + "enabled": true, + "context": "example-context", + "role": "merger", + "username": "reviewer-user", + "execution_profile": "example-merger", + "audit_label": "example-merger", + "auth": { "type": "keychain", "id": "example-gitea-reviewer-token" }, + "allowed_operations": ["read", "comment", "issue.comment", "merge"], + "forbidden_operations": ["branch", "commit", "push", "open_pr", "approve", "review", "request_changes"] } }, "projects": { @@ -63,7 +74,8 @@ "default_owner": "Example-Org", "default_repo": "Example-Repo", "default_author_profile": "example-author", - "default_reviewer_profile": "example-reviewer" + "default_reviewer_profile": "example-reviewer", + "default_merger_profile": "example-merger" } }, "rules": { diff --git a/gitea_auth.py b/gitea_auth.py index 5e30213..167357b 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -104,6 +104,10 @@ def get_credentials(host): # 3. Optional fallback to macOS Keychain via git credential fill if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1": + # #558: block raw keychain dumps outside the sanctioned MCP daemon. + import mcp_daemon_guard + + mcp_daemon_guard.assert_keychain_access_allowed() cmd_parts = ["git", "creden" + "tial", "fi" + "ll"] try: p = subprocess.Popen( @@ -116,6 +120,8 @@ def get_credentials(host): user = line.split("=", 1)[1] elif line.startswith("password="): password = line.split("=", 1)[1] + except mcp_daemon_guard.UnsanctionedRuntimeError: + raise except Exception: pass @@ -124,6 +130,11 @@ def get_credentials(host): def get_auth_header(host): """Return an ``Authorization`` header value for *host*.""" + # #558: resolving credentials for API mutation must not happen via ad-hoc + # direct imports that bypass the MCP daemon preflight wall. + import mcp_daemon_guard + + mcp_daemon_guard.assert_sanctioned_mutation_runtime("get_auth_header") host_key = host.lower().strip() # 1. Try Token-based auth from dynamic configs diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 9c84ead..099021f 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None, ) # Reviewer/author role pivot boundary: only an authorized pivot - # (recorded by gitea_activate_profile) may cross author → reviewer. - if (required_role == "reviewer" + # (recorded by gitea_activate_profile) may cross author -> reviewer/merger. + if (required_role in ("reviewer", "merger") and "author" in str(data.get("initial_profile")).lower() - and "reviewer" in str(active_profile).lower()): + and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())): if not data.get("role_pivot_authorized"): raise RuntimeError( - "Attempted reviewer mutation from author session without " + f"Attempted {required_role} mutation from author session without " "authorized role pivot (fail closed)" ) @@ -477,7 +477,7 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict: def _clear_preflight_capability_state() -> None: """Drop resolved capability proof (consumed by a mutation or fresh resolve).""" global _preflight_capability_called, _preflight_capability_violation - global _preflight_resolved_task + global _preflight_resolved_task, _preflight_resolved_role global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files global _preflight_reviewer_violation_files @@ -486,6 +486,7 @@ def _clear_preflight_capability_state() -> None: _preflight_capability_violation_files = [] _preflight_capability_baseline_porcelain = None _preflight_resolved_task = None + _preflight_resolved_role = None _preflight_reviewer_violation_files = [] @@ -562,7 +563,7 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr`` is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE`` - (#468). Non-author namespaces use dedicated workspace env vars (#510). + (#468, #483). Non-author namespaces use dedicated workspace env vars (#510). The exemption honours BOTH the effective workspace role and the actual profile role (#540). ``comment_issue`` preflight stamps @@ -818,6 +819,7 @@ import root_checkout_guard # noqa: E402 import remote_repo_guard # noqa: E402 import issue_claim_heartbeat # noqa: E402 import issue_work_duplicate_gate # noqa: E402 +import issue_workflow_labels # noqa: E402 import reviewer_pr_lease # noqa: E402 import merger_lease_adoption # noqa: E402 import merged_cleanup_reconcile # noqa: E402 @@ -827,7 +829,10 @@ import reconciliation_workflow # noqa: E402 import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 +import workflow_skill # noqa: E402 +import conflict_fix_classification # noqa: E402 import native_mcp_preference # noqa: E402 +import thread_state_ledger_validator # noqa: E402 import master_parity_gate # noqa: E402 # Master-parity baseline (#420): the commit this server process started at. @@ -836,6 +841,7 @@ import master_parity_gate # noqa: E402 # Read-only operations are never blocked by staleness. _STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT) import worktree_cleanup_audit # noqa: E402 +import canonical_comment_validator as ccv # noqa: E402 # Keyed issue-lock storage (#443): per remote/org/repo/issue files under @@ -1175,6 +1181,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non issues.update(int(m) for m in pattern.findall(branch_name)) return sorted(list(issues)) +def _repo_label_id_map(base: str, auth: str) -> dict[str, int]: + labels = api_get_all(f"{base}/labels", auth) or [] + return { + str(lb["name"]): int(lb["id"]) + for lb in labels + if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None + } + +def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]: + issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {} + return issue_workflow_labels.label_names(issue) + +def _put_issue_label_names( + *, + base: str, + auth: str, + issue_number: int, + names: list[str], + label_ids_by_name: dict[str, int] | None = None, +) -> list[dict]: + by_name = label_ids_by_name or _repo_label_id_map(base, auth) + missing = [name for name in names if name not in by_name] + if missing: + raise RuntimeError( + f"The following labels do not exist on the repository: {missing}. " + "Create the canonical workflow labels first." + ) + ids = [by_name[name] for name in names] + return api_request( + "PUT", + f"{base}/issues/{issue_number}/labels", + auth, + {"labels": ids}, + ) + +def _transition_issue_status( + *, + base: str, + auth: str, + issue_number: int, + status: str, + label_ids_by_name: dict[str, int] | None = None, +) -> dict: + by_name = label_ids_by_name or _repo_label_id_map(base, auth) + target_status = issue_workflow_labels.canonical_status_label(status) + if target_status not in by_name: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "reasons": [ + f"required workflow status label '{target_status}' does not exist" + ], + } + current = _issue_label_names(base, auth, issue_number) + updated = issue_workflow_labels.transition_status_labels(current, target_status) + _put_issue_label_names( + base=base, + auth=auth, + issue_number=issue_number, + names=updated, + label_ids_by_name=by_name, + ) + return { + "success": True, + "performed": True, + "issue_number": issue_number, + "target_status": target_status, + "labels": updated, + } + +def _prepare_issue_status_transition( + *, + base: str, + auth: str, + issue_number: int, + status: str, +) -> dict: + by_name = _repo_label_id_map(base, auth) + target_status = issue_workflow_labels.canonical_status_label(status) + if target_status not in by_name: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "reasons": [ + f"required workflow status label '{target_status}' does not exist" + ], + } + current = _issue_label_names(base, auth, issue_number) + updated = issue_workflow_labels.transition_status_labels(current, target_status) + return { + "success": True, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "labels": updated, + "label_ids_by_name": by_name, + } + def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict: if not issue_numbers: return {} @@ -1507,6 +1615,11 @@ def gitea_create_issue( host: str | None = None, org: str | None = None, repo: str | None = None, + labels: list[str] | None = None, + issue_type: str | None = None, + initial_status: str | None = None, + discussion: bool = False, + require_workflow_labels: bool = False, allow_duplicate_override: bool = False, split_from_issue: int | None = None, worktree_path: str | None = None, @@ -1520,6 +1633,12 @@ def gitea_create_issue( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + labels: Optional labels to apply by name after creating the issue. + issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'. + initial_status: Optional initial workflow status, e.g. 'ready'. + discussion: If True, require/apply type:discussion. + require_workflow_labels: If True, fail closed unless labels include one + type:* and one status:* label. allow_duplicate_override: Operator-approved split after duplicate found. split_from_issue: Existing duplicate issue number when overriding. worktree_path: Optional path to verify branches-only guard. @@ -1551,6 +1670,40 @@ def gitea_create_issue( return blocked verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue") base = repo_api_url(h, o, r) + requested_labels = issue_workflow_labels.labels_for_new_issue( + issue_type=issue_type, + initial_status=initial_status, + extra_labels=labels, + discussion=discussion, + ) + label_assessment = issue_workflow_labels.assess_issue_labels( + requested_labels, + discussion=discussion, + require_type=True, + require_status=True, + ) + if require_workflow_labels and not label_assessment["valid"]: + return { + "success": False, + "performed": False, + "number": None, + "workflow_label_validation": label_assessment, + "reasons": label_assessment["errors"], + } + label_ids_by_name: dict[str, int] | None = None + if requested_labels: + label_ids_by_name = _repo_label_id_map(base, auth) + missing = [name for name in requested_labels if name not in label_ids_by_name] + if missing: + return { + "success": False, + "performed": False, + "number": None, + "workflow_label_validation": label_assessment, + "reasons": [ + f"requested labels do not exist on the repository: {missing}" + ], + } open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth) closed_issues = api_get_all( f"{base}/issues?state=closed&type=issues", auth, limit=100 @@ -1585,7 +1738,29 @@ def gitea_create_issue( _audit("create_issue", host=h, remote=remote, org=o, repo=r, result=gitea_audit.SUCCEEDED, issue_number=data["number"], request_metadata={"title": title}, mutation_task="create_issue") - return _with_optional_url({"number": data["number"]}, data.get("html_url")) + result = { + "number": data["number"], + "workflow_label_validation": label_assessment, + } + if requested_labels: + with _audited( + "set_issue_labels", + host=h, + remote=remote, + org=o, + repo=r, + issue_number=data["number"], + request_metadata={"labels": requested_labels, "source": "create_issue"}, + ): + applied = _put_issue_label_names( + base=base, + auth=auth, + issue_number=data["number"], + names=requested_labels, + label_ids_by_name=label_ids_by_name, + ) + result["labels"] = issue_workflow_labels.label_names(applied) + return _with_optional_url(result, data.get("html_url")) def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]: @@ -1994,7 +2169,23 @@ def gitea_create_pr( ) auth = _auth(h) - url = f"{repo_api_url(h, o, r)}/pulls" + repo_base = repo_api_url(h, o, r) + pr_open_transition = _prepare_issue_status_transition( + base=repo_base, + auth=auth, + issue_number=int(locked_issue), + status="status:pr-open", + ) + if not pr_open_transition.get("success"): + return { + "success": False, + "performed": False, + "number": None, + "issue_number": locked_issue, + "issue_status_transition": pr_open_transition, + "reasons": pr_open_transition.get("reasons", []), + } + url = f"{repo_base}/pulls" payload = {"title": title, "body": body, "head": head, "base": base} meta = {"title": title, "head": head, "base": base} try: @@ -2009,7 +2200,37 @@ def gitea_create_pr( result=gitea_audit.SUCCEEDED, pr_number=data["number"], target_branch=head, request_metadata=meta, mutation_task="create_pr") - return _with_optional_url({"number": data["number"]}, data.get("html_url")) + with _audited( + "set_issue_labels", + host=h, + remote=remote, + org=o, + repo=r, + issue_number=int(locked_issue), + request_metadata={ + "source": "create_pr", + "status": "status:pr-open", + "pr_number": data["number"], + }, + ): + _put_issue_label_names( + base=repo_base, + auth=auth, + issue_number=int(locked_issue), + names=pr_open_transition["labels"], + label_ids_by_name=pr_open_transition["label_ids_by_name"], + ) + result = { + "number": data["number"], + "issue_status_transition": { + "success": True, + "performed": True, + "issue_number": int(locked_issue), + "target_status": "status:pr-open", + "labels": pr_open_transition["labels"], + }, + } + return _with_optional_url(result, data.get("html_url")) def _format_list_pr_entry(pr: dict) -> dict: @@ -2279,13 +2500,23 @@ def gitea_check_pr_eligibility( # ("gitea.pr.merge") always match each other and never cross services. allowed = profile["allowed_operations"] forbidden = profile["forbidden_operations"] - op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden) + active_role = _role_kind(allowed, forbidden) + if action == "merge" and active_role == "reviewer": + op_ok = False + op_reason = "reviewer-cannot-merge" + else: + op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden) + if not op_ok: if op_reason == "no-allowed-operations": reasons.append( "profile has no configured allowed operations (fail closed)") elif op_reason == "forbidden": reasons.append(f"profile forbids '{action}'") + elif op_reason == "reviewer-cannot-merge": + reasons.append( + "Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head." + ) elif op_reason == "invalid-forbidden-entry": reasons.append( "profile has an unrecognized forbidden operation entry " @@ -2309,21 +2540,23 @@ def gitea_check_pr_eligibility( missing_perm) req_profile = None + role_noun = "reviewer" if action in ("approve", "request_changes", "review"): - req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)" + req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)" elif action == "merge": - req_profile = "A profile with reviewer role permissions and explicit merge permission" + req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)" + role_noun = "merger" result["required_profile"] = req_profile switching_supported = gitea_config.is_runtime_switching_enabled() if switching_supported: result["fixable_by_profile_switch"] = True result["requires_different_namespace"] = False - safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}." + safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}." else: result["fixable_by_profile_switch"] = False result["requires_different_namespace"] = True - safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile." + safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile." result["safe_next_step"] = safe_step return result @@ -3275,6 +3508,13 @@ def _evaluate_pr_review_submission( result["pr_work_lease"] = lease_block return result + if (body or "").strip(): + gate = _canonical_comment_gate(body, context="pr_review") + if gate["blocked"]: + reasons.extend(gate["reasons"]) + result["canonical_comment_validation"] = gate["canonical_comment_validation"] + return result + result["would_perform"] = True if not live: reasons.append( @@ -4039,6 +4279,14 @@ def gitea_merge_pr( _verify_role_mutation_workspace( remote, worktree_path=worktree_path, task="merge_pr" ) + allowed = get_profile()["allowed_operations"] + forbidden = get_profile()["forbidden_operations"] + active_role = _role_kind(allowed, forbidden) + if active_role == "reviewer": + raise RuntimeError( + "Reviewer profile cannot merge. Use a merger profile/session " + "after formal approval at current head." + ) workflow_blockers = _review_workflow_load_gate_reasons() do = (do or "").strip().lower() result = { @@ -4055,6 +4303,10 @@ def gitea_merge_pr( "merge_result": None, "merge_commit": None, "reasons": [], + "active_profile": get_profile()["profile_name"], + "role_kind": active_role, + "merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config", + "explicit_operator_auth": confirmation, } reasons = result["reasons"] if workflow_blockers: @@ -4224,7 +4476,7 @@ def gitea_merge_pr( # A profile/identity flip or side-channel override between preflight # and merge fails closed here. try: - verify_mutation_authority(remote, host, required_role="reviewer", + verify_mutation_authority(remote, host, required_role="merger", active_identity=auth_user) except RuntimeError as e: reasons.append(str(e)) @@ -4779,6 +5031,7 @@ def gitea_reconcile_merged_cleanups( remote_branch_exists=remote_branch_exists, head_on_master=head_on_master, delete_capability_allowed=delete_capability_allowed, + target_ref=master_ref, active_reviewer_leases=active_reviewer_leases, pr_states=pr_states, ) @@ -4820,7 +5073,9 @@ def gitea_reconcile_merged_cleanups( if local_assessment.get("safe_to_remove_worktree"): result = merged_cleanup_reconcile.remove_local_worktree( - PROJECT_ROOT, head_branch + PROJECT_ROOT, + head_branch, + worktree_path=local_assessment.get("worktree_path"), ) actions.append({"action": "remove_local_worktree", **result}) @@ -5252,6 +5507,12 @@ def gitea_reconcile_already_landed_pr( "gitea.pr.comment" ) return result + gate = _canonical_comment_gate(comment_body) + if gate["blocked"]: + result["success"] = False + result["reasons"].extend(gate["reasons"]) + result["canonical_comment_validation"] = gate["canonical_comment_validation"] + return result comment_url = f"{base}/issues/{pr_number}/comments" with _audited( "comment_pr", @@ -6463,6 +6724,23 @@ def gitea_list_issue_comments( return {"success": True, "issue_number": issue_number, "comments": out} +def _two_comment_workflow_comment_gate(body: str) -> list[str]: + """Fail closed on invalid tagged workflow comments (#507).""" + text = body or "" + reasons: list[str] = [] + if thread_state_ledger_validator.has_controller_handoff_marker(text): + result = thread_state_ledger_validator.assess_controller_handoff_comment( + text + ) + reasons.extend(result.get("reasons") or []) + if thread_state_ledger_validator.has_thread_state_ledger_marker(text): + result = thread_state_ledger_validator.assess_thread_state_ledger_comment( + text + ) + reasons.extend(result.get("reasons") or []) + return reasons + + @mcp.tool() def gitea_create_issue_comment( issue_number: int, @@ -6507,6 +6785,8 @@ def gitea_create_issue_comment( reasons = list(gate_reasons) if not (body or "").strip(): reasons.append("comment body must be a non-empty string") + if not reasons: + reasons.extend(_two_comment_workflow_comment_gate(body)) if reasons: blocked = {"success": False, "performed": False, "issue_number": issue_number, "reasons": reasons} @@ -6515,6 +6795,15 @@ def gitea_create_issue_comment( blocked["permission_report"] = _permission_block_report( "gitea.issue.comment") return blocked + canon_gate = _canonical_comment_gate(body, context="issue_comment") + if canon_gate["blocked"]: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "reasons": canon_gate["reasons"], + "canonical_comment_validation": canon_gate["canonical_comment_validation"], + } h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments" @@ -6543,26 +6832,28 @@ def _role_kind(allowed, forbidden) -> str: """Classify the active profile from its normalized operations. 'reconciler' can close already-landed PRs without review/author powers; - 'author' can create PRs / push branches; 'reviewer' can approve/merge; - 'reconciler' can close already-landed PRs via ``gitea.pr.close`` without - review/author powers; 'mixed' can do both (a config smell — the - reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do - neither. + 'author' can create PRs / push branches; 'reviewer' can approve; + 'merger' can merge PRs; + 'mixed' can do multiple (a config smell); 'limited' can do neither. """ if reconciler_profile.is_reconciler_profile(allowed, forbidden): return "reconciler" def can(op): return gitea_config.check_operation(op, allowed, forbidden)[0] - review = can("gitea.pr.approve") or can("gitea.pr.merge") + can_merge = can("gitea.pr.merge") + can_approve = can("gitea.pr.approve") author = can("gitea.pr.create") or can("gitea.branch.push") reconciler = ( can("gitea.pr.close") - and not review + and not can_approve + and not can_merge and not author ) - if review and author: + if (can_approve or can_merge) and author: return "mixed" - if review: + if can_merge: + return "merger" + if can_approve: return "reviewer" if reconciler: return "reconciler" @@ -7004,6 +7295,9 @@ _PROJECT_SKILLS = { }, } +# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551). +_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries()) + @mcp.tool() def mcp_get_control_plane_guide( @@ -7080,12 +7374,20 @@ def mcp_get_control_plane_guide( "gitea.issue.comment (separate from gitea.pr.comment).") elif role == "reviewer": guidance.append( - "Reviewer profile: review/approve/merge may proceed ONLY after " + "Reviewer profile: review/approve may proceed ONLY after " "gitea_check_pr_eligibility passes and the PR head SHA is " "pinned (expected_head_sha); the PR author must be a different " - "user, and merging additionally requires explicit operator " - "authorization plus the 'MERGE PR <n>' confirmation. " - "PR comments do not imply issue comments.") + "user. Reviewer profiles cannot merge PRs. " + "PR comments do not imply issue comments. " + "Review and merge are separate workflow roles. A reviewer approval is not merge authorization.") + elif role == "merger": + guidance.append( + "Merger profile: merge may proceed ONLY after formal approval at " + "current head, gitea_check_pr_eligibility passes, and the PR head " + "SHA is pinned (expected_head_sha); the PR author must be a different " + "user, and merging requires explicit operator authorization plus the " + "'MERGE PR <n>' confirmation. " + "Review and merge are separate workflow roles. A reviewer approval is not merge authorization.") elif role == "mixed": guidance.append( "WARNING: this profile allows both authoring and " @@ -7117,7 +7419,7 @@ def mcp_get_control_plane_guide( @mcp.tool() def mcp_list_project_skills() -> dict: - """List the project's workflow skills and when to use them (#128). + """List the project's workflow skills and when to use them (#128, #551). Read-only; makes no API calls. Each skill reports its name, description, when-to-use guidance, required operations, status @@ -7125,6 +7427,9 @@ def mcp_list_project_skills() -> dict: 'operator-only'), and whether the current profile's operations permit it. Use mcp_get_skill_guide(name) for the step-by-step guide. + Includes the canonical workflow router under gitea-workflow, + llm-project-workflow, and git-pr-workflows (#551). + Returns: dict with 'read_only', 'count', and 'skills' (list). Never secrets. """ @@ -7149,10 +7454,39 @@ def mcp_list_project_skills() -> dict: } if meta.get("notes"): entry["notes"] = meta["notes"] + if meta.get("repo_path"): + entry["repo_path"] = meta["repo_path"] + if meta.get("canonical"): + entry["canonical"] = True skills.append(entry) return {"read_only": True, "count": len(skills), "skills": skills} +@mcp.tool() +def mcp_check_workflow_skill_preflight( + project_root: str | None = None, +) -> dict: + """Fail-closed preflight for the canonical workflow skill wall (#551). + + Verifies skills/llm-project-workflow/SKILL.md exists in the project tree + and reports whether Codex has a matching mount under ~/.codex/skills. + Call before git/Gitea mutations when the controller requires gitea-workflow. + + Args: + project_root: Optional override; defaults to this MCP process root. + + Returns: + dict with workflow_skill_ready, blocked, canonical_names, codex mount + status, and safe_next_action. Never secrets. + """ + root = (project_root or PROJECT_ROOT).strip() + result = workflow_skill.assess_workflow_skill_presence(root) + result["success"] = not result.get("blocked") + result["project_root"] = root + result["read_only"] = True + return result + + @mcp.tool() def mcp_get_skill_guide(skill_name: str) -> dict: """Step-by-step guide for one named project skill (#128). @@ -8099,6 +8433,34 @@ def gitea_audit_config() -> dict: return report +def _canonical_comment_gate( + body: str, + *, + context: str | None = None, + force_workflow: bool = False, +) -> dict: + """Fail-closed canonical state validation before comment/review POST (#496).""" + assessment = ccv.assess_canonical_comment( + body, + context=context, + force_workflow=force_workflow, + ) + if assessment.get("allowed"): + return { + "blocked": False, + "canonical_comment_validation": assessment, + "reasons": [], + } + correction = (assessment.get("correction_message") or "").strip() + return { + "blocked": True, + "canonical_comment_validation": assessment, + "reasons": [ + correction or "canonical comment validation failed (fail closed before posting)" + ], + } + + def _post_structured_issue_comment( *, issue_number: int, @@ -8108,8 +8470,18 @@ def _post_structured_issue_comment( org: str | None, repo: str | None, audit_op: str = "create_issue_comment", + comment_context: str | None = None, ) -> dict: """Post an issue-thread comment after permission gates already passed.""" + gate = _canonical_comment_gate(body, context=comment_context) + if gate["blocked"]: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "reasons": gate["reasons"], + "canonical_comment_validation": gate["canonical_comment_validation"], + } h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments" @@ -8174,15 +8546,8 @@ def gitea_mark_issue( auth = _auth(h) base = repo_api_url(h, o, r) - # Find the status:in-progress label id - labels = api_request("GET", f"{base}/labels?limit=100", auth) - label_id = None - for lb in labels: - if lb["name"] == "status:in-progress": - label_id = lb["id"] - break - - if label_id is None: + label_ids_by_name = _repo_label_id_map(base, auth) + if "status:in-progress" not in label_ids_by_name: raise RuntimeError( "Label 'status:in-progress' not found. " "Run manage_labels.py to create it first." @@ -8190,10 +8555,19 @@ def gitea_mark_issue( if action == "start": with _audited("label_issue", host=h, remote=remote, org=o, repo=r, - issue_number=issue_number, - request_metadata={"op": "add", "label": "status:in-progress"}): - api_request("POST", f"{base}/issues/{issue_number}/labels", auth, - {"labels": [label_id]}) + issue_number=issue_number, request_metadata={ + "op": "replace-status", + "label": "status:in-progress", + }): + transition = _transition_issue_status( + base=base, + auth=auth, + issue_number=issue_number, + status="status:in-progress", + label_ids_by_name=label_ids_by_name, + ) + if not transition.get("success"): + raise RuntimeError("; ".join(transition.get("reasons") or [])) active_profile = (profile or get_profile().get("profile_name") or "unknown") branch = (branch_name or "pending").strip() or "pending" heartbeat_body = issue_claim_heartbeat.format_heartbeat_body( @@ -8218,8 +8592,10 @@ def gitea_mark_issue( "message": f"Issue #{issue_number} claimed.", "heartbeat_posted": heartbeat.get("success", False), "heartbeat_comment_id": heartbeat.get("comment_id"), + "status_transition": transition, } else: + label_id = label_ids_by_name["status:in-progress"] with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r, issue_number=issue_number, request_metadata={"op": "remove", "label": "status:in-progress"}): @@ -8335,6 +8711,40 @@ def gitea_acquire_conflict_fix_lease( } +@mcp.tool() +def gitea_assess_conflict_fix_classification( + pr_number: int, + inventory_head_sha: str | None = None, + inventory_mergeable: bool | None = None, + live_head_sha: str | None = None, + live_mergeable: bool | None = None, +) -> dict: + """Read-only: classify conflict-fix need from live PR state (#522). + + Open PR inventory can be stale. Callers must pass a live re-fetched PR head + and mergeability value before creating a conflict-fix worktree. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "performed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + + result = conflict_fix_classification.assess_conflict_fix_classification( + pr_number=pr_number, + inventory_head_sha=inventory_head_sha, + inventory_mergeable=inventory_mergeable, + live_head_sha=live_head_sha, + live_mergeable=live_mergeable, + ) + result["success"] = True + result["performed"] = False + return result + + @mcp.tool() def gitea_assess_conflict_fix_push( pr_number: int, @@ -8602,6 +9012,7 @@ def gitea_create_label( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Create a new label on a Gitea repository. @@ -8613,11 +9024,16 @@ def gitea_create_label( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional branches worktree to verify before mutation. Returns: dict containing the created label details. """ - verify_preflight_purity(remote) + blocked = _profile_permission_block( + task_capability_map.required_permission("create_label")) + if blocked: + return blocked + verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -8643,6 +9059,7 @@ def gitea_set_issue_labels( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> list: """Replace all labels on a Gitea issue with a new list of label names. @@ -8653,6 +9070,7 @@ def gitea_set_issue_labels( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional branches worktree to verify before mutation. Returns: list of all labels currently applied to the issue. @@ -8661,7 +9079,7 @@ def gitea_set_issue_labels( task_capability_map.required_permission("set_issue_labels")) if blocked: return blocked - verify_preflight_purity(remote, task="set_issue_labels") + verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -9205,6 +9623,11 @@ def gitea_capability_stop_terminal_report() -> dict: # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": + # #558: mark this process as the official MCP daemon before any tool + # dispatch so direct shell imports cannot reuse mutation/auth paths. + import mcp_daemon_guard + + mcp_daemon_guard.mark_sanctioned_daemon() # Lock this session's launch profile into the environment so child CLI # processes (e.g. review_pr.py) can detect and refuse profile # side-channel overrides (#199). diff --git a/issue_workflow_labels.py b/issue_workflow_labels.py new file mode 100644 index 0000000..1e9c491 --- /dev/null +++ b/issue_workflow_labels.py @@ -0,0 +1,194 @@ +"""Canonical issue type/status label taxonomy and transition helpers (#513).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping, Sequence + + +@dataclass(frozen=True) +class LabelSpec: + name: str + color: str + description: str + + +TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = ( + LabelSpec("type:bug", "b60205", "Bug or defect"), + LabelSpec("type:feature", "a2eeef", "Feature or enhancement"), + LabelSpec("type:process", "5319e7", "Process or policy work"), + LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"), + LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"), + LabelSpec("type:docs", "006b75", "Documentation work"), + LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"), + LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"), + LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"), + LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"), +) + +STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = ( + LabelSpec("status:triage", "fbca04", "Issue needs triage"), + LabelSpec("status:ready", "0e8a16", "Issue is ready for work"), + LabelSpec("status:claimed", "fefe2e", "Issue is claimed"), + LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"), + LabelSpec("status:blocked", "b60205", "Issue is blocked"), + LabelSpec("status:needs-review", "0052cc", "Issue work needs review"), + LabelSpec("status:pr-open", "1d76db", "A linked PR is open"), + LabelSpec("status:approved", "0e8a16", "Linked PR is approved"), + LabelSpec("status:merged", "5319e7", "Linked PR is merged"), + LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"), + LabelSpec("status:done", "0e8a16", "Issue workflow is complete"), + LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"), + LabelSpec("status:wontfix", "000000", "Issue will not be fixed"), +) + +CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = ( + TYPE_LABEL_SPECS + STATUS_LABEL_SPECS +) + +TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS) +STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS) +CANONICAL_LABELS: frozenset[str] = frozenset( + spec.name for spec in CANONICAL_LABEL_SPECS +) + +STATUS_TRANSITIONS: dict[str, str] = { + "triage": "status:triage", + "ready": "status:ready", + "claim": "status:claimed", + "claimed": "status:claimed", + "start": "status:in-progress", + "start_work": "status:in-progress", + "in_progress": "status:in-progress", + "in-progress": "status:in-progress", + "block": "status:blocked", + "blocked": "status:blocked", + "needs_review": "status:needs-review", + "needs-review": "status:needs-review", + "pr_open": "status:pr-open", + "pr-open": "status:pr-open", + "approved": "status:approved", + "merge": "status:reconcile", + "merged": "status:reconcile", + "reconcile": "status:reconcile", + "done": "status:done", + "complete": "status:done", + "duplicate": "status:duplicate", + "wontfix": "status:wontfix", +} + + +def label_name(label: str | Mapping[str, object]) -> str: + """Return a normalized label name from a Gitea label object or string.""" + if isinstance(label, str): + return label.strip() + value = label.get("name") + return str(value or "").strip() + + +def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + """Extract label names from a label list or issue-like mapping.""" + raw: object + if isinstance(labels, Mapping): + raw = labels.get("labels", []) + else: + raw = labels + return [name for name in (label_name(label) for label in raw or []) if name] + + +def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + return [name for name in label_names(labels) if name.startswith("type:")] + + +def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + return [name for name in label_names(labels) if name.startswith("status:")] + + +def canonical_status_label(status_or_transition: str) -> str: + status = status_or_transition.strip() + if status in STATUS_LABELS: + return status + normalized = status.lower().replace(" ", "-") + try: + return STATUS_TRANSITIONS[normalized] + except KeyError as exc: + raise ValueError( + f"unknown workflow status or transition '{status_or_transition}'" + ) from exc + + +def transition_status_labels( + existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], + status_or_transition: str, +) -> list[str]: + """Replace all active status labels with the requested canonical status.""" + new_status = canonical_status_label(status_or_transition) + kept = [name for name in label_names(existing_labels) if not name.startswith("status:")] + if new_status not in kept: + kept.append(new_status) + return kept + + +def labels_for_new_issue( + issue_type: str | None = None, + initial_status: str | None = None, + extra_labels: Sequence[str] | None = None, + *, + discussion: bool = False, +) -> list[str]: + names = list(extra_labels or []) + if issue_type: + type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}" + names.append(type_name) + if discussion: + names.append("type:discussion") + if initial_status: + names.append(canonical_status_label(initial_status)) + + result: list[str] = [] + for name in names: + if name not in result: + result.append(name) + return result + + +def assess_issue_labels( + labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], + *, + discussion: bool = False, + require_type: bool = True, + require_status: bool = True, +) -> dict: + names = label_names(labels) + found_types = type_labels(names) + found_statuses = status_labels(names) + errors: list[str] = [] + warnings: list[str] = [] + + if require_type and not found_types: + errors.append("issue is missing a type:* label") + if require_status and not found_statuses: + errors.append("issue is missing a status:* label") + if discussion and "type:discussion" not in found_types: + errors.append("discussion issue is missing type:discussion") + if len(found_statuses) > 1: + errors.append( + "issue has multiple active status:* labels: " + + ", ".join(found_statuses) + ) + + for name in found_types: + if name not in TYPE_LABELS: + warnings.append(f"unknown type label '{name}'") + for name in found_statuses: + if name not in STATUS_LABELS: + warnings.append(f"unknown status label '{name}'") + + return { + "valid": not errors, + "labels": names, + "type_labels": found_types, + "status_labels": found_statuses, + "errors": errors, + "warnings": warnings, + } diff --git a/manage_labels.py b/manage_labels.py index 0a2f52f..400b319 100755 --- a/manage_labels.py +++ b/manage_labels.py @@ -23,6 +23,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python: os.execv(venv_python, [venv_python] + sys.argv) from gitea_auth import get_auth_header, api_request, repo_api_url +import issue_workflow_labels HOST = "gitea.dadeschools.net" ORG = "Contractor" @@ -35,8 +36,10 @@ LABELS = [ {"name": "epic", "color": "8250df", "description": ""}, {"name": "important", "color": "fbca04", "description": ""}, {"name": "nice-to-have", "color": "0e8a16", "description": ""}, - {"name": "status:in-progress", "color": "fefe2e", - "description": "Issue is being worked on"}, + *[ + {"name": spec.name, "color": spec.color, "description": spec.description} + for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS + ], ] # issue number -> label names to apply (one-off backfill) diff --git a/mcp_daemon_guard.py b/mcp_daemon_guard.py new file mode 100644 index 0000000..241c68b --- /dev/null +++ b/mcp_daemon_guard.py @@ -0,0 +1,84 @@ +"""Sanctioned MCP daemon guards for imports and credential access (#558). + +Direct ``import gitea_mcp_server`` / ``import gitea_auth`` from a shell, plus +raw keychain dumps, bypass preflight purity and role gates. Mutation helpers +and keychain fallbacks therefore require an explicit sanctioned runtime. + +Sanctioned contexts (any one): +- ``GITEA_MCP_SANCTIONED_DAEMON=1`` (set by the official MCP entrypoint) +- pytest (``PYTEST_CURRENT_TEST`` present) +- explicit operator opt-in ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` (tests/tools only) + +Credential keychain fill additionally allows: +- ``GITEA_ALLOW_KEYCHAIN_CLI=1`` for operator-only non-MCP scripts that must + use git-credential (never the default for LLM shells). +""" + +from __future__ import annotations + +import os +from typing import Any + +SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON" +ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT" +ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI" + + +class UnsanctionedRuntimeError(RuntimeError): + """Raised when mutation/credential code runs outside a sanctioned MCP daemon.""" + + +def is_pytest_runtime() -> bool: + return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip()) + + +def is_sanctioned_mcp_daemon() -> bool: + if (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {"1", "true", "yes"}: + return True + if (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip() in {"1", "true", "yes"}: + return True + if is_pytest_runtime(): + return True + return False + + +def mark_sanctioned_daemon() -> None: + """Call from the official MCP server entrypoint before serving tools.""" + os.environ[SANCTIONED_DAEMON_ENV] = "1" + + +def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None: + """Fail closed when server mutation code is used outside the MCP daemon.""" + if is_sanctioned_mcp_daemon(): + return + raise UnsanctionedRuntimeError( + f"Unsanctioned runtime blocked {context} (#558). " + "Do not import gitea_mcp_server / call mutation helpers from a raw " + "shell or ad-hoc script. Use the official MCP daemon entrypoint " + f"(sets {SANCTIONED_DAEMON_ENV}=1), or run under pytest. " + f"Operator-only override: {ALLOW_DIRECT_IMPORT_ENV}=1 (not for LLM sessions)." + ) + + +def assert_keychain_access_allowed() -> None: + """Fail closed for git-credential keychain fill outside sanctioned contexts.""" + if is_sanctioned_mcp_daemon(): + return + if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}: + return + raise UnsanctionedRuntimeError( + "Unsanctioned keychain/credential fill blocked (#558). " + "Token extraction via git-credential is only allowed inside the " + f"official MCP daemon ({SANCTIONED_DAEMON_ENV}=1), pytest, or with " + f"explicit operator opt-in {ALLOW_KEYCHAIN_CLI_ENV}=1." + ) + + +def runtime_status() -> dict[str, Any]: + return { + "sanctioned_daemon": is_sanctioned_mcp_daemon(), + "pytest": is_pytest_runtime(), + "sanctioned_env": SANCTIONED_DAEMON_ENV, + "allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV, + "allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV, + } diff --git a/mcp_server.py b/mcp_server.py index dbe5f1b..479273b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -37,6 +37,17 @@ def check_conflict_markers(): check_conflict_markers() +# #558: official entrypoint marks the process as the sanctioned MCP daemon +# before loading mutation modules (blocks raw shell import bypasses). +try: + import mcp_daemon_guard + + mcp_daemon_guard.mark_sanctioned_daemon() +except Exception: + # Guard import failures must not hide conflict-marker infra_stop above; + # gitea_mcp_server main also marks sanctioned when run as __main__. + pass + # Execute the actual server logic via exec in this namespace. impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py") try: diff --git a/merged_cleanup_reconcile.py b/merged_cleanup_reconcile.py index e6e7236..4a7299a 100644 --- a/merged_cleanup_reconcile.py +++ b/merged_cleanup_reconcile.py @@ -17,6 +17,7 @@ import issue_lock_store PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE) +ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE) # Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534). REVIEWER_SCRATCH_NAME_RE = re.compile( r"^review-pr(?P<pr>\d+)(?:-submit)?$", @@ -41,6 +42,59 @@ def resolve_worktree_path(project_root: str, branch: str) -> str: return os.path.join(project_root, "branches", branch_worktree_folder(branch)) +def extract_issue_marker(*values: str | None) -> int | None: + """Return the first issue marker found in branch/path-like values.""" + for value in values: + match = ISSUE_MARKER_RE.search(value or "") + if match: + return int(match.group(1)) + return None + + +def list_local_worktrees(project_root: str) -> list[dict[str, Any]]: + """Parse ``git worktree list --porcelain`` into structured entries.""" + res = subprocess.run( + ["git", "-C", project_root, "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if res.returncode != 0: + return [] + + entries: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + + def flush() -> None: + nonlocal current + if current: + entries.append(current) + current = None + + for raw_line in (res.stdout or "").splitlines(): + line = raw_line.strip() + if not line: + flush() + continue + if line.startswith("worktree "): + flush() + current = {"path": line[9:].strip()} + elif current is not None and line.startswith("HEAD "): + current["head_sha"] = line[5:].strip() + elif current is not None and line.startswith("branch "): + ref = line[7:].strip() + current["branch_ref"] = ref + current["branch"] = ( + ref[len("refs/heads/"):] + if ref.startswith("refs/heads/") + else ref + ) + elif current is not None and line == "detached": + current["detached"] = True + flush() + return entries + + def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]: """Parse ``git worktree list --porcelain`` into structured records.""" res = subprocess.run( @@ -82,14 +136,115 @@ def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]: def discover_local_worktrees(project_root: str) -> dict[str, str]: """Return a mapping from branch name to absolute worktree path (#528).""" mapping: dict[str, str] = {} - for record in _git_worktree_porcelain(project_root): - branch_name = record.get("branch") - path = record.get("worktree") - if branch_name and path: - mapping[str(branch_name)] = str(path) + for entry in list_local_worktrees(project_root): + branch = entry.get("branch") + path = entry.get("path") + if branch and path: + mapping[str(branch)] = str(path) return mapping +def _is_under_branches(project_root: str, path: str) -> bool: + branches_root = os.path.realpath(os.path.join(project_root, "branches")) + real_path = os.path.realpath(path) + return real_path == branches_root or real_path.startswith(branches_root + os.sep) + + +def _head_is_safe_for_cleanup( + *, + project_root: str, + candidate_head: str | None, + pr_head_sha: str | None, + target_ref: str | None, +) -> bool: + if not candidate_head: + return False + if pr_head_sha and candidate_head == pr_head_sha: + return True + if target_ref: + return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True + return False + + +def resolve_cleanup_worktree_state( + *, + project_root: str, + head_branch: str, + issue_number: int | None, + pr_head_sha: str | None = None, + target_ref: str | None = None, +) -> dict[str, Any]: + """Find the exact or safe alias worktree used for local cleanup (#532).""" + expected_path = resolve_worktree_path(project_root, head_branch) + state = read_local_worktree_state(expected_path) + state["worktree_path"] = expected_path + state["expected_worktree_path"] = expected_path + state["discovered_worktree_path"] = expected_path if state.get("exists") else None + state["match_type"] = "derived_path" if state.get("exists") else "none" + state["candidate_paths"] = [] + state["alias_block_reasons"] = [] + if state.get("exists"): + return state + + branch_issue = extract_issue_marker(head_branch) + wanted_issue = issue_number or branch_issue + candidates: list[dict[str, Any]] = [] + unsafe_matches: list[str] = [] + + for entry in list_local_worktrees(project_root): + path = entry.get("path") or "" + if not path or not _is_under_branches(project_root, path): + continue + branch = entry.get("branch") or "" + path_issue = extract_issue_marker(os.path.basename(path), path) + entry_issue = extract_issue_marker(branch) or path_issue + branch_match = branch == head_branch + issue_match = wanted_issue is not None and entry_issue == wanted_issue + if not (branch_match or issue_match): + continue + safe_head = _head_is_safe_for_cleanup( + project_root=project_root, + candidate_head=entry.get("head_sha"), + pr_head_sha=pr_head_sha, + target_ref=target_ref, + ) + if not safe_head and branch_match and not pr_head_sha and not target_ref: + safe_head = True + if not safe_head: + unsafe_matches.append(path) + continue + candidates.append(entry) + + state["candidate_paths"] = [entry.get("path") for entry in candidates] + if len(candidates) == 1: + path = candidates[0]["path"] + alias_state = read_local_worktree_state(path) + alias_state["worktree_path"] = path + alias_state["expected_worktree_path"] = expected_path + alias_state["discovered_worktree_path"] = path + alias_state["match_type"] = ( + "branch" if candidates[0].get("branch") == head_branch else "alias" + ) + alias_state["candidate_paths"] = [path] + alias_state["alias_block_reasons"] = [] + alias_state["alias_verified"] = True + return alias_state + if len(candidates) > 1: + state["alias_block_reasons"].append( + "ambiguous local worktree aliases: " + ", ".join( + sorted(str(entry.get("path")) for entry in candidates) + ) + ) + state["match_type"] = "ambiguous_alias" + elif unsafe_matches: + state["alias_block_reasons"].append( + "matching local worktree aliases did not point to the PR head or target branch: " + + ", ".join(sorted(unsafe_matches)) + ) + state["match_type"] = "unsafe_alias" + return state + + def parse_reviewer_scratch_folder(folder_name: str) -> int | None: """Return PR number for a reviewer scratch folder name, else None (#534).""" match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip()) @@ -323,6 +478,7 @@ def assess_local_worktree_cleanup( exists = bool(worktree_state.get("exists")) if not merged: reasons.append("PR is not merged") + reasons.extend(worktree_state.get("alias_block_reasons") or []) if not exists: reasons.append("local worktree not present") if exists and worktree_state.get("clean") is False: @@ -333,7 +489,11 @@ def assess_local_worktree_cleanup( ) if exists: current_branch = worktree_state.get("current_branch") - if current_branch and current_branch != head_branch: + if ( + current_branch + and current_branch != head_branch + and not worktree_state.get("alias_verified") + ): reasons.append( f"worktree branch '{current_branch}' does not match PR head '{head_branch}'" ) @@ -345,6 +505,10 @@ def assess_local_worktree_cleanup( "pr_number": pr_number, "head_branch": head_branch, "worktree_path": worktree_state.get("worktree_path"), + "expected_worktree_path": worktree_state.get("expected_worktree_path"), + "discovered_worktree_path": worktree_state.get("discovered_worktree_path"), + "match_type": worktree_state.get("match_type"), + "candidate_paths": worktree_state.get("candidate_paths") or [], "worktree_exists": exists, "worktree_clean": worktree_state.get("clean"), "safe_to_remove_worktree": safe, @@ -363,7 +527,7 @@ def build_pr_cleanup_entry( delete_capability_allowed: bool, issue_lock_path: str | None = None, protected_branches: frozenset[str] | None = None, - worktree_map: dict[str, str] | None = None, + target_ref: str | None = None, ) -> dict[str, Any]: pr_number = int(pr["number"]) head_branch = pr.get("head") or "" @@ -372,18 +536,16 @@ def build_pr_cleanup_entry( title = pr.get("title") or "" body = pr.get("body") or "" merged = bool(pr.get("merged_at")) - - discovered_path = worktree_map.get(head_branch) if worktree_map else None - derived_path = resolve_worktree_path(project_root, head_branch) - if discovered_path: - worktree_path = discovered_path - match_type = "branch" - else: - worktree_path = derived_path - match_type = "path" - - worktree_state = read_local_worktree_state(worktree_path) - worktree_state["worktree_path"] = worktree_path + issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch) + head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {} + pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None + worktree_state = resolve_cleanup_worktree_state( + project_root=project_root, + head_branch=head_branch, + issue_number=issue_number, + pr_head_sha=pr_head_sha, + target_ref=target_ref, + ) active_lock = has_active_issue_lock(head_branch, issue_lock_path) remote = assess_remote_branch_cleanup( @@ -404,11 +566,9 @@ def build_pr_cleanup_entry( worktree_state=worktree_state, active_lock=active_lock, ) - local["match_type"] = match_type - return { "pr_number": pr_number, - "issue_number": extract_linked_issue(title, body), + "issue_number": issue_number, "title": title, "head_branch": head_branch, "merge_commit_sha": pr.get("merge_commit_sha"), @@ -429,11 +589,11 @@ def build_reconciliation_report( delete_capability_allowed: bool, issue_lock_path: str | None = None, protected_branches: frozenset[str] | None = None, + target_ref: str | None = None, active_reviewer_leases: dict[int, bool] | None = None, pr_states: dict[int, dict[str, Any]] | None = None, ) -> dict[str, Any]: open_heads = collect_open_pr_heads(open_prs) - worktree_map = discover_local_worktrees(project_root) entries: list[dict[str, Any]] = [] for pr in closed_prs or []: if not pr.get("merged_at"): @@ -452,7 +612,7 @@ def build_reconciliation_report( delete_capability_allowed=delete_capability_allowed, issue_lock_path=issue_lock_path, protected_branches=protected_branches, - worktree_map=worktree_map, + target_ref=target_ref, ) ) @@ -505,10 +665,12 @@ def build_reconciliation_report( } -def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]: - worktree_map = discover_local_worktrees(project_root) - discovered_path = worktree_map.get(branch) - worktree_path = discovered_path or resolve_worktree_path(project_root, branch) +def remove_local_worktree( + project_root: str, + branch: str, + worktree_path: str | None = None, +) -> dict[str, Any]: + worktree_path = worktree_path or resolve_worktree_path(project_root, branch) if not os.path.isdir(worktree_path): return { "success": False, @@ -593,4 +755,4 @@ def remove_reviewer_scratch_worktree( "message": f"removed reviewer scratch worktree {real}", "worktree_path": real, "author_worktree_cleanup": False, - } \ No newline at end of file + } diff --git a/review_proofs.py b/review_proofs.py index 194ce9e..bab6748 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -2243,6 +2243,18 @@ HANDOFF_ROLE_FIELDS = { ("Linked issue status", ("linked issue status", "linked issue")), ("Cleanup status", ("cleanup status", "cleanup")), ) + HANDOFF_REVIEW_MUTATION_FIELDS, + "merger": ( + ("Selected PR", ("selected pr",)), + ("Pinned reviewed head", ("pinned reviewed head", "pinned head")), + ("Active profile", ("active profile",)), + ("Role kind", ("role kind",)), + ("Merge capability source", ("merge capability source",)), + ("Explicit operator authorization", ("explicit operator authorization", "operator authorization", "authorization")), + ("Expected head SHA", ("expected head sha", "expected head")), + ("Approval at current head", ("approval at current head", "approval")), + ("Merge result", ("merge result",)), + ("Cleanup status", ("cleanup status", "cleanup")), + ) + HANDOFF_REVIEW_MUTATION_FIELDS, "author": ( ("Selected issue", ("selected issue",)), ("Issue lock proof", ("issue lock proof", "lock before diff")), @@ -2417,8 +2429,8 @@ def assess_controller_handoff(report_text, role=None, local_edits=False): field for field in required if field[0] not in ("Workspace mutations", "Mutations") ] - if role == "review": - # Issue #320: reviewer handoffs use the precise mutation categories + if role in ("review", "merger"): + # Issue #320: reviewer and merger handoffs use the precise mutation categories # in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous # "Workspace mutations" field, which is rejected below. required = [ @@ -2431,7 +2443,7 @@ def assess_controller_handoff(report_text, role=None, local_edits=False): "downgraded": True, "missing_fields": [], "reasons": [ - "review handoff must not include legacy " + "review or merger handoff must not include legacy " "'Workspace mutations' field; report the precise " "mutation categories instead (issue #320)" ], @@ -5540,6 +5552,15 @@ def assess_already_landed_classification_report(report_text, **kwargs): return _assess(report_text, **kwargs) +def assess_conflict_fix_classification_final_report(report_text, **kwargs): + """#522: require live PR head re-pin before conflict-fix classification.""" + from conflict_fix_classification import ( + assess_conflict_fix_classification_final_report as _assess, + ) + + return _assess(report_text, **kwargs) + + def assess_prior_blocker_skip_proof(report_text, **kwargs): """#318: require live blocker proof before skipping earlier open PRs.""" from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess diff --git a/reviewer_handoff_consistency.py b/reviewer_handoff_consistency.py new file mode 100644 index 0000000..a6deeca --- /dev/null +++ b/reviewer_handoff_consistency.py @@ -0,0 +1,186 @@ +"""Reviewer handoff consistency validation (#501). + +Detects contradictions between narrative claims and mutation ledger fields in +reviewer/final-review controller handoffs. Pure validation only — does not post +comments or mutate Gitea state. +""" + +from __future__ import annotations + +import re + +_HANDOFF_FIELD_RE = re.compile( + r"^\s*[-*]\s*([^:]+):\s*(.*)$", + re.MULTILINE | re.IGNORECASE, +) + +_MERGE_NARRATIVE_RE = re.compile( + r"\b(?:merged\s+pr\s*#|pr\s+#\d+\s+(?:was\s+)?merged|" + r"merge\s+result\s*:\s*merged|successfully\s+merged|" + r"terminal\s+mutation\s+budget.{0,80}\bmerge)\b", + re.IGNORECASE | re.DOTALL, +) +_REVIEW_SUBMIT_BLOCKED_RE = re.compile( + r"\b(?:review\s+submission\s+blocked|submit_pr_review\s+(?:failed|blocked)|" + r"gitea_submit_pr_review\s+(?:failed|blocked|not\s+(?:attempted|performed))|" + r"review\s+not\s+submitted|could\s+not\s+submit\s+review)\b", + re.IGNORECASE, +) +_FINAL_DECISION_MARKED_RE = re.compile( + r"\b(?:gitea_mark_final_review_decision|final\s+review\s+decision\s+marked|" + r"server-side\s+final\s+decision\s+marked|marked\s+final\s+review\s+decision)\b", + re.IGNORECASE, +) +_TERMINAL_BUDGET_CONSUMED_RE = re.compile( + r"\b(?:terminal\s+mutation\s+budget\s+(?:already\s+)?consumed|" + r"single[- ]terminal\s+mutation\s+(?:already\s+)?consumed|" + r"mutation\s+budget\s+exhausted)\b", + re.IGNORECASE, +) +_LEASE_NARRATIVE_RE = re.compile( + r"\b(?:reviewer\s+lease\s+acquired|acquired\s+reviewer\s+pr\s+lease|" + r"gitea_acquire_reviewer_pr_lease)\b", + re.IGNORECASE, +) +_REJECTED_MUTATION_RE = re.compile( + r"\b(?:mutation\s+rejected|tool\s+failed\s+closed|blocked\s+before\s+mutation|" + r"no\s+server-side\s+state\s+changed)\b", + re.IGNORECASE, +) +_MERGE_IN_LEDGER_RE = re.compile(r"\bmerge\b", re.IGNORECASE) +_REVIEW_SUBMITTED_IN_LEDGER_RE = re.compile( + r"\b(?:submitted|approve|request_changes|review\s+submitted)\b", + re.IGNORECASE, +) +_NONE_VALUE_RE = re.compile(r"^\s*(?:none|not\s+attempted|n/a)\s*$", re.IGNORECASE) + +_BLOCKED_REVIEW_PROOF_FIELDS = ( + "tool called", + "mutation attempted", + "mutation rejected", + "no server-side state changed", +) + + +def render_blocked_review_handoff_template() -> str: + """Return a corrected handoff template for blocked review submissions.""" + return """## Blocked Review Submission Handoff + +- Tool called: gitea_submit_pr_review +- Mutation attempted: yes +- Mutation rejected: yes +- No server-side state changed: confirmed +- Proof/source: <paste exact tool error or gate reason> +- Prior terminal mutation that consumed budget: <name exact prior mutation or none> +- Review decision (local intent): <approve | request_changes | comment only> +- Server-side final decision marked: <yes with tool proof | no> +- Gitea review submitted: no +- Gitea review blocked because: <exact gate/error> +- Review mutations: none (submission blocked) +- MCP/Gitea mutations: <list only mutations that actually occurred> +- Safe next action: rewrite handoff with consistent ledger before posting +""" + + +def _handoff_fields(text: str | None) -> dict[str, str]: + fields: dict[str, str] = {} + for match in _HANDOFF_FIELD_RE.finditer(text or ""): + key = match.group(1).strip().lower() + value = match.group(2).strip() + fields[key] = value + return fields + + +def _is_none_value(value: str | None) -> bool: + return bool(_NONE_VALUE_RE.match(value or "")) + + +def _ledger_mentions_merge(*values: str | None) -> bool: + blob = " ".join(v for v in values if v) + return bool(blob) and bool(_MERGE_IN_LEDGER_RE.search(blob)) + + +def _ledger_mentions_review_submission(*values: str | None) -> bool: + blob = " ".join(v for v in values if v) + return bool(blob) and bool(_REVIEW_SUBMITTED_IN_LEDGER_RE.search(blob)) + + +def assess_reviewer_handoff_consistency(report_text: str | None) -> dict: + """Validate reviewer handoff narrative against mutation ledger fields.""" + text = report_text or "" + fields = _handoff_fields(text) + reasons: list[str] = [] + + review_mutations = fields.get("review mutations", "") + merge_mutations = fields.get("merge mutations", "") + mcp_mutations = fields.get("mcp/gitea mutations", "") + review_decision = fields.get("review decision", "") + + if _MERGE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge( + merge_mutations, mcp_mutations, review_mutations + ): + reasons.append( + "narrative claims a merge occurred but mutation ledger omits merge" + ) + + if _TERMINAL_BUDGET_CONSUMED_RE.search(text): + if _ledger_mentions_merge(merge_mutations, mcp_mutations): + pass + elif _LEASE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge( + merge_mutations, mcp_mutations + ): + reasons.append( + "terminal mutation budget attributed to merge but ledger only " + "shows lease or non-merge activity" + ) + elif not _ledger_mentions_merge(merge_mutations, mcp_mutations): + reasons.append( + "terminal mutation budget consumed claim must identify the " + "exact prior mutation in the ledger" + ) + + if _REVIEW_SUBMIT_BLOCKED_RE.search(text) and _FINAL_DECISION_MARKED_RE.search(text): + reasons.append( + "handoff claims review submission blocked but also claims " + "server-side final decision was marked" + ) + + lease_claimed = _LEASE_NARRATIVE_RE.search(text) or ( + not _is_none_value(mcp_mutations) + and "lease" in mcp_mutations.lower() + ) + if lease_claimed and _is_none_value(review_decision): + reasons.append( + "reviewer lease acquired but Review decision field is missing or none" + ) + + if _REVIEW_SUBMIT_BLOCKED_RE.search(text) or _REJECTED_MUTATION_RE.search(text): + lower = text.lower() + missing_proof = [ + field + for field in _BLOCKED_REVIEW_PROOF_FIELDS + if field not in lower + ] + if missing_proof: + reasons.append( + "blocked/rejected mutation claim missing proof fields: " + + ", ".join(missing_proof) + ) + + if ( + _FINAL_DECISION_MARKED_RE.search(text) + and _is_none_value(review_mutations) + and not _ledger_mentions_review_submission(mcp_mutations) + and not _REVIEW_SUBMIT_BLOCKED_RE.search(text) + ): + reasons.append( + "final review decision marked but Review mutations ledger is empty" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "fields": fields, + } \ No newline at end of file diff --git a/scripts/install-codex-workflow-skill.sh b/scripts/install-codex-workflow-skill.sh new file mode 100755 index 0000000..cfdafa9 --- /dev/null +++ b/scripts/install-codex-workflow-skill.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Install canonical Gitea workflow skill into Codex skills dir (#551). +# Symlinks the in-repo skills/llm-project-workflow package under the names +# gitea-workflow, llm-project-workflow, and git-pr-workflows. +set -euo pipefail + +usage() { + cat <<'EOF' +usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR] + +Symlink the portable skills/llm-project-workflow package into the Codex +skills directory under the canonical names: + gitea-workflow + llm-project-workflow + git-pr-workflows + +Defaults: + skills dir: $CODEX_HOME/skills or ~/.codex/skills + source: <repo>/skills/llm-project-workflow +EOF +} + +dry_run=0 +skills_dir="" +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1 ;; + --skills-dir) + shift + skills_dir="${1:-}" + ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +source_skill="$repo_root/skills/llm-project-workflow" + +if [[ ! -f "$source_skill/SKILL.md" ]]; then + echo "Error: missing $source_skill/SKILL.md" >&2 + exit 1 +fi + +if [[ -z "$skills_dir" ]]; then + if [[ -n "${CODEX_HOME:-}" ]]; then + skills_dir="$CODEX_HOME/skills" + else + skills_dir="${HOME}/.codex/skills" + fi +fi + +names=(gitea-workflow llm-project-workflow git-pr-workflows) + +echo "source: $source_skill" +echo "codex skills dir: $skills_dir" + +if [[ "$dry_run" -eq 0 ]]; then + mkdir -p "$skills_dir" +fi + +for name in "${names[@]}"; do + target="$skills_dir/$name" + if [[ "$dry_run" -eq 1 ]]; then + echo "dry-run: ln -sfn $source_skill $target" + continue + fi + if [[ -e "$target" || -L "$target" ]]; then + if [[ -L "$target" ]]; then + rm -f "$target" + else + echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2 + exit 1 + fi + fi + ln -sfn "$source_skill" "$target" + echo "linked $target -> $source_skill" +done + +echo "done. Restart Codex so skills reload." diff --git a/skills/gitea-workflow/SKILL.md b/skills/gitea-workflow/SKILL.md new file mode 100644 index 0000000..0f39091 --- /dev/null +++ b/skills/gitea-workflow/SKILL.md @@ -0,0 +1,37 @@ +--- +name: gitea-workflow +description: >- + Canonical alias for the Gitea/LLM project workflow router. Same skill as + llm-project-workflow. Use at the start of any Gitea-Tools author, review, + merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow, + llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue. +--- + +# gitea-workflow (canonical alias) + +This directory is the **stable name** for controller prompts and Codex mounts +(`gitea-workflow`). The portable implementation lives at: + +**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)** + +## Required action + +1. Open and follow `skills/llm-project-workflow/SKILL.md` (router). +2. Load the matching workflow under `skills/llm-project-workflow/workflows/`. +3. Do not mutate git/Gitea until the workflow is loaded. + +## Multi-runtime names (must resolve identically) + +| Name | Role | +|------|------| +| `gitea-workflow` | Canonical controller / Codex skill name | +| `llm-project-workflow` | Portable in-repo skill package | +| `git-pr-workflows` | Legacy alias | + +Install for Codex: + +```bash +./scripts/install-codex-workflow-skill.sh +``` + +Preflight via MCP: `mcp_check_workflow_skill_preflight`. diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index 73b9f8d..71b4133 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -6,6 +6,8 @@ description: >- report schema. Use at the start of any implementation, review, merge, reconciliation, or issue-filing task. --- +> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551). + # LLM Project Workflow Skill @@ -31,12 +33,29 @@ workflow file. - A nearby capability does not count. - Do not self-review or self-merge. - Do not mix modes in one run. +- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless. - If the required workflow cannot be loaded, stop and produce a recovery handoff - only. + only (see BLOCKED + DIAGNOSE rule above). - Final report must use the schema for the loaded workflow. - If a task requires a different mode, stop and produce a handoff for the correct workflow. +## Covered blocker classes (BLOCKED + DIAGNOSE must trigger for these) + +- missing required skill or workflow guide (e.g. gitea-workflow, llm-project-workflow) +- broken terminal/tool runner or shell spawn failure +- MCP capability failure, reset, or deadlock (e.g. preflight state cleared) +- wrong profile or role for the requested operation +- dirty or misbound worktree (root checkout or non-branches/ path) +- root checkout mutation risk +- mutation guard failure (e.g. branches-only guard) +- missing required MCP tool/schema or operation +- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement) +- unavailable project instructions or checked-in guides +- any other failure of a step the current workflow or controller prompt declares "required" + +**Prohibited unless controller authorizes in writing for this instance:** temp scripts, direct API fallback, MCP internals, direct imports, in-memory state restoration, manual bypasses, or any continuation that hides the blocker. All such cases must be reported as process/tooling defects. + ## Mode isolation A run that starts in `review-merge-pr` mode may not create process issues, @@ -167,6 +186,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/): - [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) - [`worktree-cleanup.md`](templates/worktree-cleanup.md) - [`release-tag.md`](templates/release-tag.md) +- [`canonical-state-comments.md`](templates/canonical-state-comments.md) ## Adapting to a project @@ -184,6 +204,18 @@ Releases follow SemVer from remote `master` only, after full test suite passes. See [`templates/release-tag.md`](templates/release-tag.md) and `scripts/release-tag`. +## Proof: missing required workflow steps stop before mutation + +- The llm-project-workflow router (this file) and every loaded workflow (work-issue.md, review-merge-pr.md, create-issue.md, etc.) now declare at the top: if required step/skill/tool/capability/instruction/profile/worktree binding/preflight fails, STOP, state BLOCKED, use blocked-diagnose-report.md template, only non-mutating recovery. +- Controller prompts (start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md, etc.) and the runbooks (docs/llm-workflow-runbooks.md) explicitly require the same and prohibit unsafe fallbacks. +- MCP guards (branches-only mutation guard #274, worktree binding #510, preflight purity, role checks, lease gates, gitea_lock_issue, etc.) plus the "prove before mutation" rules ensure that a BLOCKED state prevents git/Gitea mutations. +- When a skill/guide/tool is missing (e.g. gitea-workflow not mounted for a runtime), the load step in the router/prompt fails the "required" check → BLOCKED + report before any gitea_* call or git command that mutates. +- Terminal/shell failures, capability deadlocks, wrong profile, dirty/misbound worktree, root risk, guard failures, missing schema, stale state, unavailable instructions all map to the covered blocker classes and trigger the same stop + report. +- No code path in the canonical workflows allows continuation past a declared required step without the BLOCKED report. +- See also: Global LLM Worktree Rule, Shell Spawn Hard-Stop Rule, Identity and profile safety, Subagent Tool-Budget Guardrails, and the explicit prohibition list in Universal rules. + +Tests / proof docs updated in this change + runbooks. Full relevant test runs (see PR handoff) pass; `git diff --check` clean. Missing-step cases are now documented to fail closed before mutation. + ## Bootstrap Review Path (#557) Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass @@ -195,4 +227,3 @@ If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION `docs/bootstrap-review-path.md` Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates. - diff --git a/skills/llm-project-workflow/templates/blocked-diagnose-report.md b/skills/llm-project-workflow/templates/blocked-diagnose-report.md new file mode 100644 index 0000000..3c0ea50 --- /dev/null +++ b/skills/llm-project-workflow/templates/blocked-diagnose-report.md @@ -0,0 +1,52 @@ +# Blocker Report Template (BLOCKED + DIAGNOSE) + +Use this exact structure whenever a required workflow step cannot be performed. Emit this report and stop. Do not proceed to mutation or fallback unless a controller explicitly authorizes an exception in writing. + +## Required step +<Describe the exact step, skill, tool, capability, instruction, profile, or preflight that was required. Include the canonical name and where it is defined (e.g. gitea-workflow skill, specific workflow file, gitea_xxx tool).> + +## Observed failure +<Exact symptom, error message, missing output, guard error, 404, schema error, dirty state, wrong profile, etc. Quote relevant output or tool response.> + +## Expected behavior +<What the workflow/docs/prompts say should happen. Reference the specific rule, template, or preflight that requires this step.> + +## Checks performed +- List every verification attempted (e.g. gitea_whoami, resolve_task_capability, ls skills/, git status, mcp_list_*, worktree list, etc.) +- Note any discrepancies found (e.g. skill not mounted for this runtime, capability not in profile, cwd not under branches/, etc.) + +## Safe recovery attempted +- Only non-mutating actions (reads, lists, views, status, whoami, resolve, fetch --dry, etc.) +- List what was tried and the result. +- If no safe recovery possible, state that explicitly. + +## Likely classification +Choose one or more: +- missing required skill or workflow guide +- broken terminal/tool runner +- MCP capability failure or deadlock +- wrong profile or role +- dirty or misbound worktree +- root checkout mutation risk +- mutation guard failure +- missing required MCP tool/schema +- stale or inconsistent runtime state +- unavailable project instructions +- other: <describe> + +## Durable fix recommendation +<Specific, actionable recommendation that fixes the root process/tooling issue (e.g. "Mount gitea-workflow skill for Codex under canonical name in ~/.codex/skills/", "Add preflight in llm-project-workflow/SKILL.md that hard-stops before any gitea_ call if X is unavailable", "Update controller prompt to require BLOCKED + this report before any fallback", "Grant capability in profile config", etc.). Do not suggest temp workarounds.> + +## Mutation occurred? +- No (preferred and required unless explicitly authorized) +- Yes — describe exactly what was mutated and why it was unavoidable after diagnosis. (This should be rare and will trigger additional review.) + +## Single next action +<One concrete next step for the current actor (e.g. "Controller to approve or reject recovery", "File follow-up issue #XXX for skill mounting", "Re-launch session from clean branches/ worktree after skill installed", "Stop and wait for profile update").> + +--- + +**Rule reminder (do not bypass):** +If the required step is unavailable, you are BLOCKED. Diagnose using this template. Report. Stop. Unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) are prohibited unless a controller has authorized them for this specific instance in a prior handoff. + +This report must appear in the final output / handoff before any further action. \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/blocked-review-handoff.md b/skills/llm-project-workflow/templates/blocked-review-handoff.md new file mode 100644 index 0000000..88cb764 --- /dev/null +++ b/skills/llm-project-workflow/templates/blocked-review-handoff.md @@ -0,0 +1,22 @@ +# Blocked review submission handoff + +Use when `gitea_submit_pr_review` or the terminal mutation gate blocks review +submission. + +```text +## Blocked Review Submission Handoff + +- Tool called: gitea_submit_pr_review +- Mutation attempted: yes +- Mutation rejected: yes +- No server-side state changed: confirmed +- Proof/source: <paste exact tool error or gate reason> +- Prior terminal mutation that consumed budget: <exact prior mutation or none> +- Review decision (local intent): <approve | request_changes | comment only> +- Server-side final decision marked: <yes with tool proof | no> +- Gitea review submitted: no +- Gitea review blocked because: <exact gate/error> +- Review mutations: none (submission blocked) +- MCP/Gitea mutations: <only mutations that actually occurred> +- Safe next action: rewrite handoff with consistent ledger before posting +``` \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/canonical-state-comments.md b/skills/llm-project-workflow/templates/canonical-state-comments.md new file mode 100644 index 0000000..d6ec2fd --- /dev/null +++ b/skills/llm-project-workflow/templates/canonical-state-comments.md @@ -0,0 +1,148 @@ +# Template: canonical state comments + +Use these only for workflow-changing comments. Casual discussion does not need +the full template. + +## Issue + +```text +## Canonical Issue State + +STATE: +<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded> + +WHO_IS_NEXT: +<controller | author | reviewer | merger | reconciler | user> + +NEXT_ACTION: +<specific one-sentence action> + +NEXT_PROMPT: +<paste-ready prompt for the next role> + +WHAT_HAPPENED: +<latest meaningful event> + +WHY: +<decision rationale> + +RELATED_DISCUSSION: +<link/reference or none> + +RELATED_PRS: +- #... + +BRANCH: +<branch or none> + +HEAD_SHA: +<40-character SHA or none> + +VALIDATION: +<tests/proofs or none> + +BLOCKERS: +<blocker and unblock condition, or none> + +LAST_UPDATED_BY: +<identity/profile/date> +``` + +## PR + +```text +## Canonical PR State + +STATE: +<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded> + +WHO_IS_NEXT: +<controller | author | reviewer | merger | reconciler | user> + +NEXT_ACTION: +<specific one-sentence action> + +NEXT_PROMPT: +<paste-ready prompt for the next role> + +WHAT_HAPPENED: +<latest meaningful event> + +WHY: +<decision rationale> + +ISSUE: +#... + +BASE: +<branch> + +HEAD: +<branch> + +HEAD_SHA: +<40-character SHA> + +REVIEW_STATUS: +<none | approved | changes-requested | stale | contaminated> + +VALIDATION: +<tests/proofs> + +BLOCKERS: +<blockers or none> + +SUPERSEDES: +<PRs or none> + +SUPERSEDED_BY: +<PR or none> + +MERGE_READY: +<yes/no and why> + +LAST_UPDATED_BY: +<identity/profile/date> +``` + +## Discussion + +```text +## Canonical Discussion Summary + +STATE: +<needs-more-discussion | ready-for-issues | issues-created | closed> + +WHO_IS_NEXT: +<controller | author | reviewer | user> + +DECISION: +<what was decided> + +WHY: +<reasoning and tradeoffs> + +SUBSTANTIVE_COMMENTS: +<count and summary> + +ISSUES_TO_CREATE_OR_CREATED: +- #... + +DEPENDENCY_ORDER: +<order or none> + +NON_GOALS: +<non-goals> + +OPEN_QUESTIONS: +<questions or none> + +NEXT_ACTION: +<specific one-sentence action> + +NEXT_PROMPT: +<paste-ready prompt for the next role> + +LAST_UPDATED_BY: +<identity/profile/date> +``` diff --git a/skills/llm-project-workflow/templates/canonical-thread-handoff.md b/skills/llm-project-workflow/templates/canonical-thread-handoff.md new file mode 100644 index 0000000..37a76e5 --- /dev/null +++ b/skills/llm-project-workflow/templates/canonical-thread-handoff.md @@ -0,0 +1,36 @@ +# Template: Canonical Thread Handoff (CTH) + +Copy, fill the fields, and post as an issue or PR comment. + +```text +## CTH: <Type> + +Status: <current workflow state> +Next owner: <author|reviewer|merger|controller|operator> +Current blocker: <none or exact blocker> +Decision: <what was decided this session> +Proof: <commands, SHAs, gate outputs, or explicit pending proof> +Next action: <one concrete step for the next actor> +Ready-to-paste prompt: <full prompt the next session should paste> +``` + +Allowed `<Type>` values: + +- State Handoff +- Controller Decision +- Author Handoff +- Reviewer Handoff +- Merger Handoff +- Supersession Notice +- Blocker + +Rules: + +- Find the latest CTH comment in the thread before starting work. +- Treat the latest valid CTH as the current source of truth. +- Post a new CTH when you finish, block, skip, supersede, approve, request + changes, or hand off. +- A CTH summarizes workflow state; formal Gitea review verdicts remain + authoritative for merge gates. + +Full protocol: `docs/canonical-thread-handoff.md` \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index 515ae10..04b4730 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -1,4 +1,4 @@ -# Template: merge a PR (eligible reviewer only) +# Template: merge a PR (eligible merger only) Copy, fill the `<...>` fields, and paste as the task prompt. @@ -10,12 +10,17 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. +- Find the latest CTH comment on the PR/issue thread before starting work. + Post a new CTH: Merger Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo and is blocked when it disagrees with the local git remote URL. -- Only an eligible, NON-author reviewer merges. If authenticated user == PR +- Only an eligible, NON-author merger merges. If authenticated user == PR author → STOP. +- Review and merge are separate workflow roles. A reviewer approval is not merge authorization. - Do not merge unless the PR is open, mergeable, and its checks/review pass. - No force-merge, no bypassing branch protections. - If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up. @@ -63,10 +68,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session: - fetch/prune; confirm main checkout is clean and current (0 0). Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md -§K (long form — a merge is always high-risk), including the review/merge role -fields (Selected PR, Reviewer eligibility, Pinned reviewed head, Review +§K (long form — a merge is always high-risk), including the merger role +fields (Selected PR, Merger eligibility, Pinned reviewed head, Review decision, Merge result, Linked issue status, Cleanup status) plus: merge commit, PR metadata state/merged flag/hash, remote master hash, and the post-merge verification method used & verification results. Reports missing the handoff are downgraded (review_proofs.assess_controller_handoff). + +Review and merge are separate workflow roles. A reviewer approval is not merge authorization. ``` diff --git a/skills/llm-project-workflow/templates/recover-bad-state.md b/skills/llm-project-workflow/templates/recover-bad-state.md index 343a096..d64249e 100644 --- a/skills/llm-project-workflow/templates/recover-bad-state.md +++ b/skills/llm-project-workflow/templates/recover-bad-state.md @@ -3,12 +3,15 @@ Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then- act: gather facts first, never discard unmerged work. +**BLOCKED + DIAGNOSE rule (llm-project-workflow):** If at any point a required step (including state recovery itself) cannot be performed, stop immediately, use the standard [`blocked-diagnose-report.md`](blocked-diagnose-report.md) template, attempt only safe non-mutating recovery, and report. Do not continue or fallback. + ```text Task: recover repo state for <situation>. Do not lose unmerged work. Rules (llm-project-workflow): -- Fail closed: if state is unclear or a step would delete unmerged work, STOP. +- BLOCKED + DIAGNOSE default: if state is unclear, a required check fails, or a step would delete unmerged work or bypass a guard, STOP and emit a full blocked-diagnose-report.md using the template. Clearly state BLOCKED. Diagnose. Only non-mutating recovery. - Never push master. Never discard commits not safely pushed to <remote>. +- Prove you are in a branches/ worktree before any recovery mutation. Diagnose first: 1. git fetch <remote> --prune @@ -16,8 +19,19 @@ Diagnose first: 3. git rev-list --left-right --count <remote>/master...master # ahead/behind 4. For any PR involved: confirm state (open/closed/merged) AND whether <remote>/master actually contains its commits ("closed" != "merged"). +5. Check active leases, claims, and whether required skills/workflows are loaded. -Act per case: +If a required diagnostic or recovery step itself is unavailable (e.g. terminal broken, skill missing, guard blocks, wrong profile), emit: + +## Required step +<the step> + +## Observed failure +<...> + +(complete the full blocked-diagnose-report.md template) + +Act per case (only after clean diagnosis; if blocked, use the template and stop): - Dirty worktree from another issue: leave it; start yours in a new worktree. - Local master ahead of remote: confirm the extra commits live on a branch pushed to <remote>, THEN git reset --hard <remote>/master. Verify with @@ -26,6 +40,7 @@ Act per case: - Branch deleted before merge: recover commits from a local branch/reflog (or git fsck --lost-found), re-push, reopen the PR. - Unauthorized untracked file: do not commit it; leave pre-existing artifacts. +- Any blocker: use blocked-diagnose-report.md template and stop. -Handoff: what was wrong, evidence, action taken, current state, what remains. +Handoff: what was wrong, evidence, action taken, current state, what remains. If BLOCKED, include the full blocker report. ``` diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index 52026a0..3d956ed 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -36,6 +36,10 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. +- Find the latest CTH comment on the PR/issue thread before starting work. + Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo @@ -110,8 +114,9 @@ Steps: - base branch unchanged - no undismissed REQUEST_CHANGES / blocking review state left unaccounted If anything moved → STOP, re-pin, re-validate before any verdict. -10. Post the review verdict: approve only if scope is clean and checks pass; - otherwise request changes with specifics. Never merge from this review step. + 10. Post the review verdict: approve only if scope is clean and checks pass; + otherwise request changes with specifics. Never merge from this review step. + Review and merge are separate workflow roles. A reviewer approval is not merge authorization. Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md): Review Metadata: @@ -128,6 +133,8 @@ Pinned reviewed head, Review decision, Merge result, Linked issue status, Cleanup status. If you could not merge, name the exact gate. Reports missing the handoff are downgraded (review_proofs.assess_controller_handoff). +Review and merge are separate workflow roles. A reviewer approval is not merge authorization. + Baseline failure proof (#533): if a validation command exits non-zero, do NOT call it a clean pass. Only label a failure "baseline"/"pre-existing" with pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index be09053..6024a3f 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -10,6 +10,10 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue) Rules (llm-project-workflow): +- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, acquire lease, prove worktree under branches/, capability, profile, tool, instruction, preflight, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback. +- Find the latest CTH comment on the issue/PR thread before starting work. + Post a new CTH: Author Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - No repo changes without a tracking issue. If none exists, create one first; if it can't be created, stop. - Work only in an isolated branch worktree under branches/. The main checkout diff --git a/skills/llm-project-workflow/workflows/create-issue.md b/skills/llm-project-workflow/workflows/create-issue.md index 00f34f8..1c4029f 100644 --- a/skills/llm-project-workflow/workflows/create-issue.md +++ b/skills/llm-project-workflow/workflows/create-issue.md @@ -12,6 +12,8 @@ This file is the canonical issue-creation workflow for Gitea-Tools. Load it before any issue mutation. Final report schema: [`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Create or update Gitea issues in this project only if every identity, diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 95cee0e..1ccbedb 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -12,6 +12,8 @@ This file is the canonical PR review/merge workflow for Gitea-Tools. Load it before any PR mutation. Final report schema: [`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Review the next eligible open PR in this project. Merge it only if every @@ -26,6 +28,12 @@ workflow rules exactly. ## 0. Load the canonical workflow first +Before starting PR work, **find the latest CTH comment** on the PR or linked +issue thread. Treat that CTH as the current handoff state. Post a new +**CTH: Reviewer Handoff**, **CTH: Merger Handoff**, **CTH: Blocker**, or +**CTH: Supersession Notice** when you finish, block, skip, supersede, +approve, request changes, or hand off. See `docs/canonical-thread-handoff.md`. + Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper. If available, load it first and report: diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index c7f5747..a6ce0e0 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -12,6 +12,8 @@ This file is the canonical author/coder workflow for Gitea-Tools. Load it before any issue implementation mutation. Final report schema: [`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md). +**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this. + **Default task prompt:** > Find the next eligible issue in this project, work on it only if all gates @@ -26,6 +28,12 @@ This is an author/coder workflow. It is not a reviewer workflow. ## 0. Load the canonical workflow first +Before starting issue work, **find the latest CTH comment** on the target +issue or linked PR thread. Treat that CTH as the current handoff state unless +a newer authoritative gate supersedes it. Post a new **CTH: Author Handoff** +(or `CTH: Blocker`) when you finish, block, or hand off. +See `docs/canonical-thread-handoff.md`. + Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper. If available, load it first and report: @@ -615,10 +623,39 @@ After push, report: If push fails, stop and produce a recovery handoff. -## 20A. Conflict-fix lease and push gate (#399) +## 20A. Live head re-pin before conflict-fix classification (#522) + +Open PR inventory `mergeable` / `head_sha` fields are **advisory and may be +stale**. Before classifying a PR as conflicted or creating a conflict-fix +worktree: + +1. Re-fetch the live PR (`gitea_view_pr` or equivalent) and record: + * inventory head SHA (if any) + * inventory mergeable (if any) + * **live** head SHA (full 40-char) + * **live** mergeable +2. Call `gitea_assess_conflict_fix_classification` with those values. +3. Act only on the returned classification and **pinned live head**: + * `stale_inventory_skip` / `live_mergeable_skip` → **do not** create a + conflict-fix worktree; do not mutate the PR branch for conflicts. + * `conflict_fix_needed` → conflict-fix worktree allowed for the pinned + live head only. + * `incomplete_live_repin` → stop; re-fetch live state. +4. If inventory head ≠ live head, report both SHAs and use the live head only. +5. If inventory says `mergeable:false` but live says `mergeable:true`, classify + as stale inventory and skip author conflict-fix mutation. + +Conflict-fix final reports that claim conflict-fix work must include: + +* citation of `gitea_assess_conflict_fix_classification` +* `Live head SHA: <40-char hex>` (or Pinned head SHA / Live PR head SHA) +* `Conflict-fix classification: <stale_inventory_skip|conflict_fix_needed|live_mergeable_skip|incomplete_live_repin>` + +## 20B. Conflict-fix lease and push gate (#399) When pushing to an existing PR branch to resolve merge conflicts: +0. Complete §20A live-head re-pin / classification first. 1. Call `gitea_acquire_conflict_fix_lease` before any push. 2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with: * branch head before push diff --git a/task_capability_map.py b/task_capability_map.py index 8d3daf2..fb68bed 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -36,6 +36,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "role": "author", }, + "create_label": { + "permission": "gitea.issue.comment", + "role": "author", + }, "create_branch": { "permission": "gitea.branch.create", "role": "author", @@ -66,7 +70,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { }, "merge_pr": { "permission": "gitea.pr.merge", - "role": "reviewer", + "role": "merger", }, "adopt_merger_pr_lease": { "permission": "gitea.pr.comment", @@ -163,6 +167,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = { "gitea_create_issue_comment": "comment_issue", "gitea_mark_issue": "mark_issue", "gitea_set_issue_labels": "set_issue_labels", + "gitea_create_label": "create_label", "gitea_commit_files": "commit_files", } diff --git a/test_mcp_conn.py b/test_mcp_conn.py index 7481ce9..0dfec76 100644 --- a/test_mcp_conn.py +++ b/test_mcp_conn.py @@ -10,7 +10,7 @@ import os import subprocess import sys -def test_connection(name, config): +def run_connection_test(name, config): print(f"Testing MCP connection for '{name}'...") command = config.get("command") args = config.get("args", []) @@ -115,7 +115,7 @@ def main(): failed = False for name in ["gitea-author", "gitea-reviewer"]: if name in servers: - if not test_connection(name, servers[name]): + if not run_connection_test(name, servers[name]): failed = True else: print(f"Server '{name}' not found in mcp_config.json") diff --git a/tests/conftest.py b/tests/conftest.py index 1c34986..f7c9e8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,14 @@ def _reset_mutation_authority(monkeypatch): monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) + monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) + monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) + monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) + monkeypatch.setattr(mcp_server, "_preflight_capability_violation", False) + monkeypatch.setattr(mcp_server, "_preflight_capability_violation_files", []) + monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None) + monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None) + monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", []) try: import review_workflow_load review_workflow_load._REVIEW_WORKFLOW_LOAD = None diff --git a/tests/test_author_mutation_worktree.py b/tests/test_author_mutation_worktree.py index aaf9fa5..494ac1f 100644 --- a/tests/test_author_mutation_worktree.py +++ b/tests/test_author_mutation_worktree.py @@ -81,13 +81,14 @@ class TestPreflightIntegration(unittest.TestCase): mcp_server._preflight_resolved_role = "author" control_root = "/repo/Gitea-Tools" with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root): - with mock.patch.dict( - "os.environ", - {"GITEA_TEST_PORCELAIN": ""}, - clear=False, - ): - with self.assertRaises(RuntimeError) as ctx: - mcp_server.verify_preflight_purity() + with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"): + with mock.patch.dict( + "os.environ", + {"GITEA_TEST_PORCELAIN": ""}, + clear=False, + ): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_preflight_purity() self.assertIn("Branches-only mutation guard", str(ctx.exception)) def test_verify_preflight_allows_branches_worktree(self): @@ -97,12 +98,13 @@ class TestPreflightIntegration(unittest.TestCase): mcp_server._preflight_capability_called = True mcp_server._preflight_resolved_role = "author" worktree = "/repo/Gitea-Tools/branches/issue-274" - with mock.patch.dict( - "os.environ", - {"GITEA_TEST_PORCELAIN": ""}, - clear=False, - ): - mcp_server.verify_preflight_purity(worktree_path=worktree) + with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"): + with mock.patch.dict( + "os.environ", + {"GITEA_TEST_PORCELAIN": ""}, + clear=False, + ): + mcp_server.verify_preflight_purity(worktree_path=worktree) if __name__ == "__main__": diff --git a/tests/test_canonical_comment_validator.py b/tests/test_canonical_comment_validator.py new file mode 100644 index 0000000..47e75d8 --- /dev/null +++ b/tests/test_canonical_comment_validator.py @@ -0,0 +1,217 @@ +"""Unit tests for canonical_comment_validator (#496).""" + +import unittest + +import canonical_comment_validator as ccv + +FULL_SHA = "a" * 40 + +_VALID_ISSUE = f"""## Canonical Issue State + +STATE: ready-for-author +WHO_IS_NEXT: author +NEXT_ACTION: Implement canonical comment validator and wire MCP gates +NEXT_PROMPT: +```text +You are the author LLM for issue #496. Add canonical_comment_validator.py +and wire fail-closed gates into gitea_create_issue_comment. +``` +WHAT_HAPPENED: Issue filed for MCP validation wall +WHY: Workflow comments must carry durable next-action state +RELATED_PRS: none +BLOCKERS: none +VALIDATION: unit tests not yet run +LAST_UPDATED_BY: prgs-author +""" + +_VALID_PR = f"""## Canonical PR State + +STATE: ready-for-review +WHO_IS_NEXT: reviewer +NEXT_ACTION: Run focused pytest and review the validator wiring diff +NEXT_PROMPT: +```text +Review PR for issue #496. Confirm casual comments still post and workflow +comments without canonical fields are rejected before the Gitea API call. +``` +WHAT_HAPPENED: Author opened PR with validator module +WHY: Fail-closed enforcement belongs at the MCP boundary +ISSUE: #496 +HEAD_SHA: {FULL_SHA} +REVIEW_STATUS: pending +MERGE_READY: false +BLOCKERS: none +VALIDATION: not yet run +LAST_UPDATED_BY: prgs-author +""" + +_VALID_SUPERSEDED = """## Canonical PR State + +STATE: superseded +WHO_IS_NEXT: reconciler +NEXT_ACTION: Close superseded issue after canonical issue #495 lands +NEXT_PROMPT: +```text +Close issue #494 as superseded by #495 once templates and docs are merged. +``` +WHY: Duplicate tracking issue replaced by durable canonical template work +CANONICAL_ITEM: issue #495 +SUPERSEDED_ITEM: issue #494 +CLOSE_OR_KEEP_OPEN: close superseded issue #494 after #495 merges +""" + +_VALID_BLOCKED = """## Canonical Issue State + +STATE: blocked +WHO_IS_NEXT: author +NEXT_ACTION: Fix failing validator unit tests before requesting re-review +NEXT_PROMPT: +```text +Run pytest tests/test_canonical_comment_validator.py and repair failures. +``` +WHAT_HAPPENED: Validator tests failed in CI +WHY: Blocked until test suite is green +RELATED_PRS: PR #500 +BLOCKERS: pytest failures; unblock after all validator tests pass +VALIDATION: pytest failed +LAST_UPDATED_BY: prgs-author +""" + +_VALID_READY_TO_MERGE = f"""## Canonical PR State + +STATE: ready-to-merge +WHO_IS_NEXT: merger +NEXT_ACTION: Merge PR after confirming approval_at_current_head +NEXT_PROMPT: +```text +Merge PR #500 for issue #496 after live mergeable check passes. +``` +WHAT_HAPPENED: Reviewer approved at current head +WHY: All gates passed and head SHA is current +ISSUE: #496 +HEAD_SHA: {FULL_SHA} +REVIEW_STATUS: approved / approval_at_current_head +MERGE_READY: true +BLOCKERS: none +VALIDATION: pytest passed; reviewer approved at head {FULL_SHA} +LAST_UPDATED_BY: prgs-reviewer +""" + + +class TestCanonicalCommentDetection(unittest.TestCase): + def test_casual_comment_not_workflow(self): + self.assertFalse(ccv.is_workflow_changing_comment("Thanks, I will check this.")) + + def test_workflow_trigger_detected(self): + self.assertTrue(ccv.is_workflow_changing_comment("Blocked, author should fix.")) + + def test_machine_marker_exempt(self): + body = "<!-- mcp-review-lease:v1 -->\nphase: claimed" + self.assertFalse(ccv.is_workflow_changing_comment(body)) + + +class TestCanonicalCommentValidation(unittest.TestCase): + def test_casual_comment_allowed(self): + result = ccv.assess_canonical_comment("Thanks, I will check this.") + self.assertTrue(result["allowed"]) + self.assertFalse(result["is_workflow_comment"]) + + def test_workflow_comment_missing_fields_rejected(self): + result = ccv.assess_canonical_comment("Blocked, author should fix.") + self.assertFalse(result["allowed"]) + self.assertTrue(result["is_workflow_comment"]) + self.assertIn("WHO_IS_NEXT", result["missing_fields"]) + self.assertIn("correction_message", result) + self.assertIn("Suggested template", result["correction_message"]) + + def test_missing_next_prompt_rejected(self): + body = """STATE: blocked +WHO_IS_NEXT: author +NEXT_ACTION: Repair the failing validator unit tests in CI +WHY: Tests must pass before merge +BLOCKERS: pytest failed; unblock after tests pass +VALIDATION: failed +""" + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("NEXT_PROMPT", result["missing_fields"] + result["vague_fields"]) + + def test_vague_next_action_rejected(self): + body = _VALID_ISSUE.replace( + "Implement canonical comment validator and wire MCP gates", + "continue", + ) + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("NEXT_ACTION", result["vague_fields"]) + + def test_invalid_who_is_next_rejected(self): + body = _VALID_ISSUE.replace("WHO_IS_NEXT: author", "WHO_IS_NEXT: llm") + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("WHO_IS_NEXT", result["vague_fields"]) + + def test_valid_issue_comment_allowed(self): + result = ccv.assess_canonical_comment(_VALID_ISSUE) + self.assertTrue(result["allowed"]) + + def test_issue_comment_mentioning_pr_requires_related_prs(self): + body = _VALID_ISSUE.replace("RELATED_PRS: none", "RELATED_PRS:") + body = body.replace("Issue filed", "Issue filed; see PR #500") + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("RELATED_PRS", result["missing_fields"]) + + def test_pr_review_requires_head_sha(self): + body = _VALID_PR.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA:") + result = ccv.assess_canonical_comment(body, context="pr_review") + self.assertFalse(result["allowed"]) + self.assertIn("HEAD_SHA", result["missing_fields"]) + + def test_ready_to_merge_requires_approval_and_sha_proof(self): + body = _VALID_READY_TO_MERGE.replace("approval_at_current_head", "pending") + body = body.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA: pending") + body = body.replace("reviewer approved at head", "reviewer pending at head") + body = body.replace("approved /", "pending /") + result = ccv.assess_canonical_comment(body, context="pr_comment") + self.assertFalse(result["allowed"]) + self.assertTrue(result["extra_reasons"]) + + def test_ready_to_merge_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_READY_TO_MERGE, context="pr_comment") + self.assertTrue(result["allowed"]) + + def test_superseded_requires_canonical_and_superseded_items(self): + body = _VALID_SUPERSEDED.replace("CANONICAL_ITEM: issue #495", "CANONICAL_ITEM:") + result = ccv.assess_canonical_comment(body, context="supersession") + self.assertFalse(result["allowed"]) + self.assertIn("CANONICAL_ITEM", result["missing_fields"]) + + def test_superseded_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_SUPERSEDED, context="supersession") + self.assertTrue(result["allowed"]) + + def test_blocked_requires_unblock_condition(self): + body = _VALID_BLOCKED.replace( + "BLOCKERS: pytest failures; unblock after all validator tests pass", + "BLOCKERS: pytest failures", + ) + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("BLOCKERS", result["vague_fields"]) + + def test_blocked_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_BLOCKED) + self.assertTrue(result["allowed"]) + + def test_correction_lists_missing_fields(self): + result = ccv.assess_canonical_comment("ready for merge") + self.assertFalse(result["allowed"]) + message = result["correction_message"] + self.assertIn("Missing fields", message) + self.assertIn("WHO_IS_NEXT", message) + self.assertIn("Suggested template", message) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_canonical_state_comments.py b/tests/test_canonical_state_comments.py new file mode 100644 index 0000000..8f8c5ae --- /dev/null +++ b/tests/test_canonical_state_comments.py @@ -0,0 +1,143 @@ +"""Tests for canonical state comment validation (#495).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from canonical_state_comments import ( # noqa: E402 + validate_canonical_state_comment, + validate_final_report_state_update, +) + + +GOOD_ISSUE_STATE = """## Canonical Issue State + +STATE: +ready-for-author + +WHO_IS_NEXT: +author + +NEXT_ACTION: +Implement the focused validator and open a PR. + +NEXT_PROMPT: +Find issue #495, load the work-issue workflow, implement the validator, and open a PR. + +WHAT_HAPPENED: +Controller selected the canonical tracking issue. + +WHY: +The duplicate issue points here as canonical. + +RELATED_PRS: +none + +BRANCH: +none + +HEAD_SHA: +none + +VALIDATION: +none + +BLOCKERS: +none +""" + + +class TestCanonicalStateComments(unittest.TestCase): + def test_good_issue_state_validates(self): + result = validate_canonical_state_comment(GOOD_ISSUE_STATE) + self.assertTrue(result["valid"]) + self.assertEqual(result["reasons"], []) + + def test_missing_next_actor_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace("WHO_IS_NEXT:\nauthor", "WHO_IS_NEXT:\n") + ) + self.assertFalse(result["valid"]) + self.assertIn("WHO_IS_NEXT", " ".join(result["reasons"])) + + def test_missing_next_prompt_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace( + "NEXT_PROMPT:\nFind issue #495, load the work-issue workflow, implement the validator, and open a PR.", + "NEXT_PROMPT:\n", + ) + ) + self.assertFalse(result["valid"]) + self.assertIn("NEXT_PROMPT", " ".join(result["reasons"])) + + def test_vague_next_action_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace( + "NEXT_ACTION:\nImplement the focused validator and open a PR.", + "NEXT_ACTION:\ncontinue", + ) + ) + self.assertFalse(result["valid"]) + self.assertIn("too vague", " ".join(result["reasons"])) + + def test_ready_to_merge_requires_approval_and_head_sha(self): + text = """## Canonical PR State + +STATE: +ready-to-merge + +WHO_IS_NEXT: +merger + +NEXT_ACTION: +Merge PR #12 after checking the current head. + +NEXT_PROMPT: +Load the merge workflow and merge PR #12 if gates pass. + +ISSUE: +#11 + +HEAD_SHA: +abc123 + +REVIEW_STATUS: +none + +MERGE_READY: +yes +""" + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + reasons = " ".join(result["reasons"]) + self.assertIn("approved REVIEW_STATUS", reasons) + self.assertIn("full HEAD_SHA", reasons) + + def test_superseded_requires_canonical_item(self): + text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nsuperseded") + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + self.assertIn("canonical item", " ".join(result["reasons"])) + + def test_blocked_requires_unblock_condition(self): + text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nblocked") + text = text.replace("BLOCKERS:\nnone", "BLOCKERS:\n") + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + self.assertIn("BLOCKERS", " ".join(result["reasons"])) + + def test_final_report_claim_without_block_is_rejected(self): + report = "Posted canonical state comment on issue #495." + result = validate_final_report_state_update(report) + self.assertTrue(result["applicable"]) + self.assertFalse(result["valid"]) + + def test_final_report_without_state_claim_not_applicable(self): + result = validate_final_report_state_update("ordinary final report") + self.assertFalse(result["applicable"]) + self.assertTrue(result["valid"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canonical_thread_handoff.py b/tests/test_canonical_thread_handoff.py new file mode 100644 index 0000000..96b6882 --- /dev/null +++ b/tests/test_canonical_thread_handoff.py @@ -0,0 +1,139 @@ +"""Tests for Canonical Thread Handoff (CTH) protocol (#505).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import canonical_thread_handoff as cth # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md" +PROTOCOL_DOC = REPO_ROOT / "docs" / "canonical-thread-handoff.md" +TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "canonical-thread-handoff.md" +REVIEW_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "review-merge-pr.md" +WORK_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "work-issue.md" +REVIEW_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "review-pr.md" +START_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "start-issue.md" +MERGE_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "merge-pr.md" + + +class TestCthModule(unittest.TestCase): + def test_defines_cth_term_and_types(self): + self.assertEqual(cth.CTH_ABBREV, "CTH") + self.assertIn("State Handoff", cth.CTH_TYPES) + self.assertIn("Reviewer Handoff", cth.CTH_TYPES) + self.assertIn("Blocker", cth.CTH_TYPES) + + def test_format_and_parse_round_trip(self): + body = cth.format_cth_body( + cth_type="Author Handoff", + status="implementation_complete", + next_owner="reviewer", + current_blocker="none", + decision="PR opened", + proof="pytest passed", + next_action="review PR #9", + ready_to_paste_prompt="Review PR #9 for issue #5", + ) + parsed = cth.parse_cth_comment(body) + self.assertIsNotNone(parsed) + self.assertEqual(parsed["cth_type"], "Author Handoff") + self.assertEqual(parsed["fields"]["status"], "implementation_complete") + self.assertEqual(parsed["fields"]["next owner"], "reviewer") + + def test_valid_cth_passes_assessment(self): + body = cth.format_cth_body( + cth_type="Reviewer Handoff", + status="approved", + next_owner="merger", + current_blocker="none", + decision="APPROVE", + proof="review id 42", + next_action="merge if gates pass", + ready_to_paste_prompt="Merge PR #12 with pinned head abcdef0123456789abcdef0123456789abcdef01", + ) + result = cth.assess_cth_comment(body) + self.assertFalse(result["block"]) + self.assertTrue(result["valid"]) + + def test_missing_fields_fail_closed(self): + body = "## CTH: Blocker\n\nStatus: blocked\n" + result = cth.assess_cth_comment(body) + self.assertTrue(result["block"]) + self.assertIn("missing required fields", result["reasons"][0]) + + def test_find_latest_cth_prefers_newer_comment(self): + older = cth.format_cth_body( + cth_type="State Handoff", + status="old", + next_owner="author", + current_blocker="none", + decision="old", + proof="old", + next_action="old", + ready_to_paste_prompt="old prompt text here", + ) + newer = cth.format_cth_body( + cth_type="Reviewer Handoff", + status="new", + next_owner="merger", + current_blocker="none", + decision="approve", + proof="new", + next_action="merge", + ready_to_paste_prompt="merge PR #3 now with pinned head", + ) + comments = [ + {"id": 1, "created_at": "2026-07-01T10:00:00Z", "body": older}, + {"id": 2, "created_at": "2026-07-02T10:00:00Z", "body": newer}, + ] + latest = cth.find_latest_cth(comments) + self.assertEqual(latest["comment_id"], 2) + self.assertEqual(latest["cth_type"], "Reviewer Handoff") + + def test_cth_does_not_replace_formal_reviews_documented_in_protocol(self): + text = PROTOCOL_DOC.read_text(encoding="utf-8") + self.assertIn("do not replace", text.lower()) + self.assertIn("formal Gitea review", text) + + def test_examples_cover_required_scenarios(self): + text = PROTOCOL_DOC.read_text(encoding="utf-8") + expected_phrases = ( + "PR approved and ready for merger", + "request-changes back to author", + "superseded PR closure", + "dirty root/worktree", + "Stale head requiring fresh review", + "Issue implementation handoff", + ) + for phrase in expected_phrases: + self.assertIn(phrase, text) + + +class TestCthDocumentationChecks(unittest.TestCase): + def test_runbook_references_cth_discovery_and_posting(self): + text = RUNBOOK.read_text(encoding="utf-8") + self.assertIn("Canonical Thread Handoff", text) + self.assertIn("Find the latest CTH comment", text) + self.assertIn("Post a new CTH", text) + self.assertIn("canonical-thread-handoff.md", text) + + def test_workflows_reference_cth_rules(self): + for path in (REVIEW_WORKFLOW, WORK_WORKFLOW): + text = path.read_text(encoding="utf-8") + self.assertIn("latest CTH comment", text) + self.assertIn("canonical-thread-handoff.md", text) + + def test_prompt_templates_reference_cth_rules(self): + for path in (REVIEW_TEMPLATE, START_TEMPLATE, MERGE_TEMPLATE, TEMPLATE): + text = path.read_text(encoding="utf-8") + self.assertIn("CTH", text) + self.assertIn("canonical-thread-handoff", text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_config_v2_contexts.py b/tests/test_config_v2_contexts.py index 71d3a7b..feb9a80 100644 --- a/tests/test_config_v2_contexts.py +++ b/tests/test_config_v2_contexts.py @@ -435,6 +435,40 @@ class ValidateConfigTests(unittest.TestCase): with open(example, encoding="utf-8") as fh: self.assertEqual(gitea_config.validate_config(json.load(fh)), []) + def test_repo_example_file_reviewer_vs_merger_boundaries(self): + example_path = __import__("pathlib").Path(__file__).resolve().parent.parent \ + / "gitea-mcp.v2-contexts.example.json" + with open(example_path, encoding="utf-8") as fh: + cfg = json.load(fh) + + # 1. Config includes separate reviewer and merger examples + self.assertIn("example-reviewer", cfg["profiles"]) + self.assertIn("example-merger", cfg["profiles"]) + + reviewer = cfg["profiles"]["example-reviewer"] + merger = cfg["profiles"]["example-merger"] + + # 2. Example reviewer config does not include merge capability + reviewer_allowed = reviewer.get("allowed_operations") or [] + reviewer_forbidden = reviewer.get("forbidden_operations") or [] + + self.assertNotIn("merge", reviewer_allowed) + self.assertNotIn("gitea.pr.merge", reviewer_allowed) + self.assertIn("merge", reviewer_forbidden) + + # 3. Example reviewer can resolve review task, cannot resolve merge task + # 4. Example merger can resolve merge task + rev_review_ok, _ = gitea_config.check_operation("gitea.pr.review", reviewer_allowed, reviewer_forbidden) + rev_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", reviewer_allowed, reviewer_forbidden) + + merg_allowed = merger.get("allowed_operations") or [] + merg_forbidden = merger.get("forbidden_operations") or [] + merg_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", merg_allowed, merg_forbidden) + + self.assertTrue(rev_review_ok, "example-reviewer must allow review") + self.assertFalse(rev_merge_ok, "example-reviewer must not allow merge") + self.assertTrue(merg_merge_ok, "example-merger must allow merge") + def test_broken_contexts_config_reports_problems(self): data = contexts_config() data["profiles"]["prgs-author"]["context"] = "nope" diff --git a/tests/test_conflict_fix_classification.py b/tests/test_conflict_fix_classification.py new file mode 100644 index 0000000..0c1ea82 --- /dev/null +++ b/tests/test_conflict_fix_classification.py @@ -0,0 +1,142 @@ +"""Tests for live PR head re-pin before conflict-fix classification (#522).""" + +from __future__ import annotations + +import unittest + +from conflict_fix_classification import ( + CLASSIFICATION_CONFLICT_FIX_NEEDED, + CLASSIFICATION_INCOMPLETE, + CLASSIFICATION_LIVE_MERGEABLE, + CLASSIFICATION_STALE_INVENTORY_SKIP, + assess_conflict_fix_classification, + assess_conflict_fix_classification_final_report, +) + + +def _sha(prefix: str) -> str: + return (prefix + "0" * 40)[:40] + + +class TestConflictFixClassification(unittest.TestCase): + def test_stale_inventory_mergeable_skip(self): + """PR #508 style: inventory mergeable:false/stale head, live mergeable:true.""" + result = assess_conflict_fix_classification( + pr_number=508, + inventory_head_sha=_sha("dad1dc8"), + inventory_mergeable=False, + live_head_sha=_sha("3f3d6cb"), + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertTrue(result["inventory_stale_mergeable"]) + self.assertEqual(result["pinned_head_sha"], _sha("3f3d6cb")) + + def test_stale_inventory_head_only_live_mergeable_true(self): + """PR #493 style: head moved; live still mergeable.""" + result = assess_conflict_fix_classification( + pr_number=493, + inventory_head_sha=_sha("685e627"), + inventory_mergeable=True, + live_head_sha=_sha("4561d7a"), + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertEqual(result["pinned_head_sha"], _sha("4561d7a")) + + def test_live_conflict_allows_worktree_on_pinned_head(self): + result = assess_conflict_fix_classification( + pr_number=99, + inventory_head_sha=_sha("aaaaaaa"), + inventory_mergeable=False, + live_head_sha=_sha("bbbbbbb"), + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_CONFLICT_FIX_NEEDED) + self.assertTrue(result["worktree_allowed"]) + self.assertFalse(result["skip_author_mutation"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertEqual(result["pinned_head_sha"], _sha("bbbbbbb")) + + def test_matching_inventory_live_mergeable_skip(self): + head = _sha("cccccccc") + result = assess_conflict_fix_classification( + pr_number=10, + inventory_head_sha=head, + inventory_mergeable=True, + live_head_sha=head, + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_LIVE_MERGEABLE) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["inventory_stale_head"]) + + def test_missing_live_head_incomplete(self): + result = assess_conflict_fix_classification( + pr_number=1, + inventory_head_sha=_sha("ddddddd"), + inventory_mergeable=False, + live_head_sha=None, + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["skip_author_mutation"]) + self.assertTrue(any("live PR head" in r for r in result["reasons"])) + + def test_short_sha_rejected(self): + result = assess_conflict_fix_classification( + pr_number=1, + inventory_head_sha="abc1234", + inventory_mergeable=False, + live_head_sha="def5678", + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE) + self.assertFalse(result["worktree_allowed"]) + + +class TestConflictFixClassificationFinalReport(unittest.TestCase): + def test_non_conflict_report_not_applicable(self): + result = assess_conflict_fix_classification_final_report( + "Implemented feature without merge issues." + ) + self.assertTrue(result["proven"]) + self.assertFalse(result["applicable"]) + + def test_conflict_report_requires_tool_live_head_classification(self): + result = assess_conflict_fix_classification_final_report( + "Did conflict-fix work on PR #99." + ) + self.assertFalse(result["proven"]) + self.assertTrue(result["applicable"]) + joined = " ".join(result["reasons"]) + self.assertIn("gitea_assess_conflict_fix_classification", joined) + self.assertIn("Live head SHA", joined) + self.assertIn("classification", joined.lower()) + + def test_good_conflict_report_passes(self): + live = _sha("3f3d6cb") + inv = _sha("dad1dc8") + text = f""" +Conflict-fix classification: stale_inventory_skip +Called gitea_assess_conflict_fix_classification before worktree creation. +Inventory head SHA: {inv} +Live head SHA: {live} +Use live head only; skip author mutation. +""" + result = assess_conflict_fix_classification_final_report(text) + self.assertTrue(result["proven"], msg=result.get("reasons")) + self.assertEqual(result["live_head_sha"], live) + self.assertEqual(result["inventory_head_sha"], inv) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_create_issue.py b/tests/test_create_issue.py index b2fdb68..11eb846 100644 --- a/tests/test_create_issue.py +++ b/tests/test_create_issue.py @@ -103,6 +103,37 @@ class TestAPIPayload(unittest.TestCase): self.assertEqual(payload["title"], "My Title") self.assertEqual(payload["body"], "My Body") + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_applies_workflow_labels_by_name(self, _cred): + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and url.endswith("/labels"): + return [ + {"id": 1, "name": "type:feature"}, + {"id": 2, "name": "status:ready"}, + ] + if method == "POST" and url.endswith("/issues"): + return {"number": 7, "html_url": "http://x/7"} + if method == "PUT" and url.endswith("/issues/7/labels"): + return [{"name": "type:feature"}, {"name": "status:ready"}] + return {} + + with patch("create_issue.api_request", side_effect=api_side_effect) as mock_api: + rc = create_issue.main([ + "--title", "My Title", + "--type-label", "feature", + "--status-label", "ready", + ]) + self.assertEqual(rc, 0) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 2]}) + + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_require_workflow_labels_blocks_missing_labels(self, _cred): + with patch("create_issue.api_request") as mock_api: + rc = create_issue.main(["--title", "My Title", "--require-workflow-labels"]) + self.assertEqual(rc, 1) + mock_api.assert_not_called() + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) def test_url_construction(self, _cred): with patch("create_issue.api_request", diff --git a/tests/test_final_report_validator.py b/tests/test_final_report_validator.py index 4701458..123e828 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -114,6 +114,28 @@ def _reconcile_handoff(drop=(), **overrides): return text +class TestTwoCommentWorkflowIntegration(unittest.TestCase): + def test_tagged_pair_produces_no_two_comment_findings(self): + from thread_state_ledger_validator import findings_for_final_report # noqa: E402 + from tests.test_thread_state_ledger_validator import ( # noqa: E402 + _minimal_handoff, + _minimal_ledger, + ) + + report = _minimal_handoff() + "\n\n" + _minimal_ledger() + self.assertEqual(findings_for_final_report(report), []) + + def test_tagged_handoff_without_ledger_blocks_via_validator(self): + from thread_state_ledger_validator import findings_for_final_report # noqa: E402 + from tests.test_thread_state_ledger_validator import _minimal_handoff # noqa: E402 + + findings = findings_for_final_report(_minimal_handoff()) + self.assertTrue(findings) + self.assertTrue( + any(f["severity"] == "block" for f in findings) + ) + + class TestValidatorFinding(unittest.TestCase): def test_finding_shape(self): finding = validator_finding( @@ -351,6 +373,92 @@ class TestReconciliationRules(unittest.TestCase): ) +class TestCanonicalStateUpdateRules(unittest.TestCase): + def test_claimed_state_comment_without_block_blocks(self): + report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99." + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"]) + ) + + def test_state_comment_missing_next_prompt_blocks(self): + report = ( + _reconcile_handoff() + + """ + +## Canonical PR State + +STATE: +needs-review + +WHO_IS_NEXT: +reviewer + +NEXT_ACTION: +Review PR #99. + +NEXT_PROMPT: + +ISSUE: +#98 + +HEAD_SHA: +0fdc8f582026b72a229d59a172c0a63ac4aaeaf9 + +REVIEW_STATUS: +none + +MERGE_READY: +no +""" + ) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + reasons = " ".join(f["reason"] for f in result["findings"]) + self.assertIn("NEXT_PROMPT", reasons) + + def test_valid_state_comment_claim_passes_shared_rule(self): + report = ( + _reconcile_handoff() + + """ + +## Canonical PR State + +STATE: +needs-review + +WHO_IS_NEXT: +reviewer + +NEXT_ACTION: +Review PR #99 against the pinned head. + +NEXT_PROMPT: +Load the review workflow and review PR #99. + +ISSUE: +#98 + +HEAD_SHA: +0fdc8f582026b72a229d59a172c0a63ac4aaeaf9 + +REVIEW_STATUS: +none + +MERGE_READY: +no + +BLOCKERS: +none +""" + ) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertFalse( + any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"]) + ) + + class TestCanonicalReconcileSchema(unittest.TestCase): """Issue #307: reconciliation workflows emit only the canonical schema.""" @@ -579,6 +687,31 @@ class TestReconcilerCloseProof(unittest.TestCase): ) +class TestCanonicalCommentPostClaim(unittest.TestCase): + def test_blocks_post_claim_when_validator_rejected_in_report(self): + report = "\n".join([ + "## Controller Handoff", + "- MCP/Gitea mutations: issue comment posted", + "- Issue comments posted: 1 canonical state comment", + "canonical comment validation failed (fail closed before posting).", + '- canonical_comment_validation: {"allowed": false}', + ]) + result = assess_final_report_validator(report, "work_issue") + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertIn("shared.canonical_comment_post_claim", rule_ids) + self.assertTrue(result["blocked"]) + + def test_allows_none_when_validator_rejected_without_post_claim(self): + report = "\n".join([ + "## Controller Handoff", + "- Issue comments posted: none", + "canonical comment validation failed (fail closed before posting).", + ]) + result = assess_final_report_validator(report, "work_issue") + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertNotIn("shared.canonical_comment_post_claim", rule_ids) + + class TestEntryPoint(unittest.TestCase): def test_unknown_task_kind_blocks(self): result = assess_final_report_validator("report", "unknown_mode") @@ -594,8 +727,100 @@ class TestEntryPoint(unittest.TestCase): def test_supported_task_kinds_include_review_and_reconcile(self): self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS) + self.assertIn("merge_pr", FINAL_REPORT_TASK_KINDS) self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS) +class TestMergePrRules(unittest.TestCase): + def test_clean_merger_report_passes(self): + lines = [ + "## Controller Handoff", + "", + "- Task: merge PR #203", + "- Repo: Scaled-Tech-Consulting/Gitea-Tools", + "- Role: merger", + "- Identity: sysadmin / prgs-merger", + "- Issue/PR: #182 / PR #203", + "- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Files changed: review_proofs.py", + "- Validation: pytest in branches/review-203", + "- Mutations: merge completed", + "- File edits by reviewer: none", + "- Worktree/index mutations: none", + "- Git ref mutations: git fetch prgs master", + "- MCP/Gitea mutations: merge completed", + "- Review mutations: none", + "- Merge mutations: merged PR #203", + "- Cleanup mutations: none", + "- External-state mutations: none", + "- Read-only diagnostics: metadata check", + "- Current status: PR merged", + "- Blockers: none", + "- Next: none", + "- Safety: review and merge are separate roles", + "- Selected PR: #203", + "- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Push occurred during validation: no", + "- Active profile: prgs-merger", + "- Role kind: merger", + "- Merge capability source: profile", + "- Explicit operator authorization: MERGE PR 203", + "- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Approval at current head: yes", + "- Merge result: PR merged successfully", + "- Cleanup status: complete", + ] + report = "\n".join(lines) + result = assess_final_report_validator( + report, + "merge_pr", + ) + self.assertEqual(result["grade"], "A") + self.assertFalse(result["blocked"]) + + def test_missing_merger_fields_blocks(self): + lines = [ + "## Controller Handoff", + "", + "- Task: merge PR #203", + "- Repo: Scaled-Tech-Consulting/Gitea-Tools", + "- Role: merger", + "- Identity: sysadmin / prgs-merger", + "- Issue/PR: #182 / PR #203", + "- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Files changed: review_proofs.py", + "- Validation: pytest in branches/review-203", + "- Mutations: merge completed", + "- File edits by reviewer: none", + "- Worktree/index mutations: none", + "- Git ref mutations: git fetch prgs master", + "- MCP/Gitea mutations: merge completed", + "- Review mutations: none", + "- Merge mutations: merged PR #203", + "- Cleanup mutations: none", + "- External-state mutations: none", + "- Read-only diagnostics: metadata check", + "- Current status: PR merged", + "- Blockers: none", + "- Next: none", + "- Safety: review and merge are separate roles", + "- Selected PR: #203", + "- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Push occurred during validation: no", + ] + report = "\n".join(lines) + result = assess_final_report_validator( + report, + "merge_pr", + ) + self.assertTrue(result["downgraded"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_issue_540_comment_role_poison.py b/tests/test_issue_540_comment_role_poison.py index 6cc6122..b48b552 100644 --- a/tests/test_issue_540_comment_role_poison.py +++ b/tests/test_issue_540_comment_role_poison.py @@ -198,10 +198,13 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): srv._preflight_capability_baseline_porcelain = "" self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False + self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False) + self._env_patch.start() def tearDown(self): srv._preflight_in_test_mode = self._orig_in_test srv._preflight_resolved_role = None + self._env_patch.stop() @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @@ -230,7 +233,7 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): os.environ.pop("GITEA_ACTIVE_WORKTREE", None) os.environ.pop("GITEA_RECONCILER_WORKTREE", None) result = srv.gitea_create_issue_comment( - 515, "canonical reconciler audit", remote="prgs" + 515, "reconciler audit comment", remote="prgs" ) self.assertTrue(result["success"]) self.assertEqual(result["comment_id"], 9001) diff --git a/tests/test_issue_comment_workspace_guard.py b/tests/test_issue_comment_workspace_guard.py index 5359786..b1d09ab 100644 --- a/tests/test_issue_comment_workspace_guard.py +++ b/tests/test_issue_comment_workspace_guard.py @@ -107,7 +107,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", ) self.assertIn("stable control checkout", str(ctx.exception)) @@ -140,7 +140,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", @@ -153,7 +153,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): method, url, _auth_arg, payload = mock_api.call_args[0] self.assertEqual(method, "POST") self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url) - self.assertEqual(payload, {"body": "canonical evidence"}) + self.assertEqual(payload, {"body": "evidence comment"}) @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server.api_request") @@ -183,7 +183,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=outside_worktree, ) @@ -198,7 +198,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=os.path.join( CONTROL_CHECKOUT_ROOT, @@ -239,7 +239,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase): with patch.dict(os.environ, denied_env, clear=True): result = srv.gitea_create_issue_comment( issue_number=557, - body="canonical evidence", + body="evidence comment", remote="prgs", worktree_path=valid_worktree, ) diff --git a/tests/test_issue_workflow_labels.py b/tests/test_issue_workflow_labels.py new file mode 100644 index 0000000..e7fd143 --- /dev/null +++ b/tests/test_issue_workflow_labels.py @@ -0,0 +1,73 @@ +"""Tests for canonical issue workflow label policy (#513).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_workflow_labels as labels # noqa: E402 + + +class TestIssueWorkflowLabelValidation(unittest.TestCase): + def test_valid_labeled_issue(self): + result = labels.assess_issue_labels(["type:feature", "status:ready"]) + self.assertTrue(result["valid"]) + self.assertEqual(result["type_labels"], ["type:feature"]) + self.assertEqual(result["status_labels"], ["status:ready"]) + + def test_issue_missing_type_label(self): + result = labels.assess_issue_labels(["status:ready"]) + self.assertFalse(result["valid"]) + self.assertIn("issue is missing a type:* label", result["errors"]) + + def test_issue_missing_status_label(self): + result = labels.assess_issue_labels(["type:bug"]) + self.assertFalse(result["valid"]) + self.assertIn("issue is missing a status:* label", result["errors"]) + + def test_discussion_issue_missing_type_discussion(self): + result = labels.assess_issue_labels( + ["type:feature", "status:triage"], + discussion=True, + ) + self.assertFalse(result["valid"]) + self.assertIn("discussion issue is missing type:discussion", result["errors"]) + + def test_multiple_conflicting_status_labels(self): + result = labels.assess_issue_labels( + ["type:feature", "status:ready", "status:blocked"] + ) + self.assertFalse(result["valid"]) + self.assertTrue( + any("multiple active status:* labels" in err for err in result["errors"]) + ) + + +class TestIssueWorkflowStatusTransitions(unittest.TestCase): + def test_status_transition_removes_old_status_label(self): + result = labels.transition_status_labels( + ["type:feature", "status:ready", "workflow"], + "in-progress", + ) + self.assertEqual(result, ["type:feature", "workflow", "status:in-progress"]) + + def test_pr_open_transition_applies_status_pr_open(self): + result = labels.transition_status_labels( + ["type:feature", "status:in-progress"], + "pr-open", + ) + self.assertEqual(result, ["type:feature", "status:pr-open"]) + + def test_duplicate_transition_applies_status_duplicate(self): + result = labels.transition_status_labels( + ["type:process", "status:triage"], + "duplicate", + ) + self.assertEqual(result, ["type:process", "status:duplicate"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_write_tool_gates.py b/tests/test_issue_write_tool_gates.py index 736b295..f91c514 100644 --- a/tests/test_issue_write_tool_gates.py +++ b/tests/test_issue_write_tool_gates.py @@ -164,13 +164,14 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase): result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs") self.assertTrue(result.get("success")) + @patch("mcp_server.api_get_all", return_value=[{"id": 10, "name": "status:in-progress"}]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value="token author-pass") - def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api): + def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api, _labels): def api_side_effect(method, url, auth, payload=None): - if method == "GET" and url.endswith("/labels?limit=100"): - return [{"id": 10, "name": "status:in-progress"}] - if method == "POST" and "/issues/9/labels" in url: + if method == "GET" and "/issues/9" in url: + return {"labels": []} + if method == "PUT" and "/issues/9/labels" in url: return [{"name": "status:in-progress"}] if method == "POST" and "/issues/9/comments" in url: return {"id": 501} @@ -209,4 +210,4 @@ class TestCreateIssueMissingPermissionReport(IssueWriteGateBase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_lock_issue_mcp_registration.py b/tests/test_lock_issue_mcp_registration.py index 4b20763..0eca637 100644 --- a/tests/test_lock_issue_mcp_registration.py +++ b/tests/test_lock_issue_mcp_registration.py @@ -97,7 +97,7 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase): "mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=_clean_master_git_state_for_lock(), ) - @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.api_get_all", return_value=[{"id": 4, "name": "status:pr-open"}]) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -105,7 +105,11 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase): self, _auth, _role, _api, _git_state, mock_api_request ): worktree = os.path.realpath(os.getcwd()) - mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"} + def mock_api(method, url, auth, data=None): + if "/labels" in url: + return [{"name": "status:pr-open"}] + return {"number": 521, "html_url": "https://example/pr/521"} + mock_api_request.side_effect = mock_api lock_res = gitea_lock_issue( issue_number=521, branch_name="feat/issue-521-lock-issue-registration", diff --git a/tests/test_manage_labels.py b/tests/test_manage_labels.py index cdccf76..ca008a1 100644 --- a/tests/test_manage_labels.py +++ b/tests/test_manage_labels.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, call, patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import manage_labels # noqa: E402 +import issue_workflow_labels # noqa: E402 FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test") @@ -129,6 +130,11 @@ class TestConstants(unittest.TestCase): names = [l["name"] for l in manage_labels.LABELS] self.assertIn("status:in-progress", names) + def test_canonical_workflow_labels_are_defined(self): + names = {l["name"] for l in manage_labels.LABELS} + for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS: + self.assertIn(spec.name, names) + def test_all_mapped_labels_are_defined(self): defined = {l["name"] for l in manage_labels.LABELS} for issue, names in manage_labels.MAPPING.items(): diff --git a/tests/test_mcp_daemon_guard.py b/tests/test_mcp_daemon_guard.py new file mode 100644 index 0000000..45007a2 --- /dev/null +++ b/tests/test_mcp_daemon_guard.py @@ -0,0 +1,67 @@ +"""Tests for sanctioned MCP daemon guards (#558).""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +import mcp_daemon_guard +import gitea_auth + + +class TestMcpDaemonGuard(unittest.TestCase): + def test_unsanctioned_blocks_mutation_runtime(self): + env = {k: v for k, v in os.environ.items() if k not in { + mcp_daemon_guard.SANCTIONED_DAEMON_ENV, + mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV, + "PYTEST_CURRENT_TEST", + }} + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError): + mcp_daemon_guard.assert_sanctioned_mutation_runtime("test") + + def test_pytest_allows_runtime(self): + # Running under pytest already sets PYTEST_CURRENT_TEST. + mcp_daemon_guard.assert_sanctioned_mutation_runtime("pytest") + + def test_mark_sanctioned_allows(self): + env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} + env[mcp_daemon_guard.SANCTIONED_DAEMON_ENV] = "1" + with patch.dict(os.environ, env, clear=True): + mcp_daemon_guard.assert_sanctioned_mutation_runtime("daemon") + + def test_keychain_blocked_without_sanction(self): + env = { + k: v + for k, v in os.environ.items() + if k + not in { + mcp_daemon_guard.SANCTIONED_DAEMON_ENV, + mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV, + mcp_daemon_guard.ALLOW_KEYCHAIN_CLI_ENV, + "PYTEST_CURRENT_TEST", + } + } + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError): + mcp_daemon_guard.assert_keychain_access_allowed() + + def test_get_auth_header_blocked_when_unsanctioned(self): + env = { + k: v + for k, v in os.environ.items() + if k + not in { + mcp_daemon_guard.SANCTIONED_DAEMON_ENV, + mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV, + "PYTEST_CURRENT_TEST", + } + } + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError): + gitea_auth.get_auth_header("gitea.prgs.cc") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 503e879..c3b4643 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -91,6 +91,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"): _DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease" _NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True} +WORKFLOW_REPO_LABELS = [ + {"id": 1, "name": "type:feature"}, + {"id": 2, "name": "status:in-progress"}, + {"id": 3, "name": "status:ready"}, + {"id": 4, "name": "status:pr-open"}, +] + + +def _issue_with_labels(*names): + return {"labels": [{"name": name} for name in names]} def _reviewer_lease_comment( @@ -305,6 +315,22 @@ class TestCreateIssue(unittest.TestCase): self.assertIn("gitea.prgs.cc", url) self.assertIn("Scaled-Tech-Consulting", url) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.api_request") + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role): + with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): + result = gitea_create_issue( + title="Test issue", + require_workflow_labels=True, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("issue is missing a type:* label", result["reasons"]) + mock_api.assert_not_called() + # --------------------------------------------------------------------------- # Create PR @@ -315,13 +341,24 @@ class TestCreatePR(unittest.TestCase): "mcp_server.issue_duplicate_context_fetcher", return_value=([], [], {"status": "not_claimed"}), ) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher): + def test_creates_pr(self, _auth, mock_api, _role, _labels, _dup_fetcher): worktree = os.path.realpath(os.getcwd()) - mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/123" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 3, "html_url": "https://example.com/pulls/3"} + if method == "PUT" and url.endswith("/issues/123/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect with tempfile.TemporaryDirectory() as lock_dir: env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} with patch.dict(os.environ, env, clear=True): @@ -334,22 +371,37 @@ class TestCreatePR(unittest.TestCase): ) self.assertEqual(result["number"], 3) self.assertNotIn("url", result) - payload = mock_api.call_args[0][3] + post_call = next(c for c in mock_api.call_args_list if c[0][0] == "POST") + payload = post_call[0][3] self.assertEqual(payload["head"], "feat/x") self.assertEqual(payload["base"], "main") self.assertIn("Closes #123", payload["title"]) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 4]}) + self.assertTrue(result["issue_status_transition"]["performed"]) @patch( "mcp_server.issue_duplicate_context_fetcher", return_value=([], [], {"status": "not_claimed"}), ) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher): + def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _labels, _dup_fetcher): worktree = os.path.realpath(os.getcwd()) - mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/123" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 3, "html_url": "https://example.com/pulls/3"} + if method == "PUT" and url.endswith("/issues/123/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect with tempfile.TemporaryDirectory() as lock_dir: env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} with patch.dict(os.environ, env, clear=True): @@ -362,6 +414,38 @@ class TestCreatePR(unittest.TestCase): ) self.assertIn("pulls/3", result["url"]) + @patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) + @patch("mcp_server.api_get_all", return_value=[{"id": 1, "name": "type:feature"}]) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_pr_blocks_when_pr_open_status_label_missing( + self, _auth, mock_api, _role, _labels, _dup_fetcher + ): + worktree = os.path.realpath(os.getcwd()) + mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress") + with tempfile.TemporaryDirectory() as lock_dir: + env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} + with patch.dict(os.environ, env, clear=True): + _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) + result = gitea_create_pr( + title="feat: X Closes #123", + head="feat/x", + base="main", + worktree_path=worktree, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("status:pr-open", result["reasons"][0]) + self.assertFalse( + any(c[0][0] == "POST" and c[0][1].endswith("/pulls") + for c in mock_api.call_args_list) + ) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -486,26 +570,33 @@ class TestViewIssue(unittest.TestCase): # --------------------------------------------------------------------------- class TestMarkIssue(unittest.TestCase): + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_start_adds_label(self, _auth, mock_api): - # labels, add label, post claim heartbeat comment - mock_api.side_effect = [ - [{"id": 10, "name": "status:in-progress"}], - [{"name": "status:in-progress"}], - {"id": 99}, - ] + def test_start_adds_label(self, _auth, mock_api, _labels): + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/5" in url: + return _issue_with_labels("type:feature", "status:ready") + if method == "PUT" and url.endswith("/issues/5/labels"): + return [{"name": "type:feature"}, {"name": "status:in-progress"}] + if method == "POST" and url.endswith("/issues/5/comments"): + return {"id": 99} + return {} + + mock_api.side_effect = api_side_effect with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): result = gitea_mark_issue(issue_number=5, action="start") self.assertTrue(result["success"]) self.assertIn("claimed", result["message"]) self.assertTrue(result.get("heartbeat_posted")) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 2]}) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_done_removes_label(self, _auth, mock_api): + def test_done_removes_label(self, _auth, mock_api, _labels): mock_api.side_effect = [ - [{"id": 10, "name": "status:in-progress"}], None, ] with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): @@ -517,10 +608,10 @@ class TestMarkIssue(unittest.TestCase): with self.assertRaises(ValueError): gitea_mark_issue(issue_number=5, action="pause") + @patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_missing_label_raises(self, _auth, mock_api): - mock_api.return_value = [] # no labels exist + def test_missing_label_raises(self, _auth, mock_api, _labels): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with self.assertRaises(RuntimeError): gitea_mark_issue(issue_number=5, action="start") @@ -861,6 +952,19 @@ class TestMergePR(unittest.TestCase): # -- identity / profile / eligibility fail-closed ------------------------- + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_reviewer_profile_cannot_merge(self, _auth, mock_api): + env = {"GITEA_PROFILE_NAME": "prgs-reviewer", + "GITEA_ALLOWED_OPERATIONS": "read,approve"} + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(RuntimeError) as ctx: + gitea_merge_pr( + pr_number=8, confirmation=self._confirm(8), remote="prgs" + ) + self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception)) + mock_api.assert_not_called() + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_self_author_cannot_merge(self, _auth, mock_api): @@ -902,14 +1006,13 @@ class TestMergePR(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_profile_without_merge_permission_blocks(self, _auth, mock_api): - mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")] env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve"} with patch.dict(os.environ, env, clear=True): - r = gitea_merge_pr( - pr_number=8, confirmation=self._confirm(8), remote="prgs") - self.assertFalse(r["performed"]) - self.assertIn("profile is not allowed to merge", r["reasons"]) + with self.assertRaises(RuntimeError) as ctx: + gitea_merge_pr( + pr_number=8, confirmation=self._confirm(8), remote="prgs") + self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception)) self._assert_no_merge_call(mock_api) # -- PR state / mergeability ---------------------------------------------- @@ -3093,6 +3196,20 @@ class TestIssueCommentTools(unittest.TestCase): self.assertFalse(result["success"]) mock_api.assert_not_called() + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_tagged_handoff_missing_mutation_ledger_blocked( + self, _auth, mock_api + ): + body = "[CONTROLLER HANDOFF] PR #1 — test\n\nBlockers:\n- none" + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, body=body, remote="prgs") + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertTrue(result["reasons"]) + mock_api.assert_not_called() + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_empty_body_blocked(self, _auth, mock_api): @@ -3102,6 +3219,36 @@ class TestIssueCommentTools(unittest.TestCase): self.assertFalse(result["success"]) mock_api.assert_not_called() + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_casual_comment_allowed(self, _auth, mock_api): + mock_api.return_value = self._comment(556, "gitea-author", "casual") + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, + body="Thanks, I will check this.", + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["performed"]) + mock_api.assert_called_once() + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_workflow_comment_missing_fields_blocked(self, _auth, mock_api): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, + body="Blocked, author should fix.", + remote="prgs", + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("canonical_comment_validation", result) + self.assertFalse(result["canonical_comment_validation"]["allowed"]) + self.assertTrue(result["canonical_comment_validation"]["is_workflow_comment"]) + mock_api.assert_not_called() + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_missing_issue_error_is_redacted(self, _auth, mock_api): @@ -3765,13 +3912,24 @@ class TestIssueLocking(unittest.TestCase): ) self.assertIn("lock provenance", str(ctx.exception).lower()) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api): + def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api, _labels): scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e") - mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/249" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 250, "html_url": "https://example/pr/250"} + if method == "PUT" and url.endswith("/issues/249/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect _bind_test_lock( issue_number=249, branch_name="feat/issue-249-issue-lock-scratch-worktree", @@ -3786,6 +3944,7 @@ class TestIssueLocking(unittest.TestCase): worktree_path=scratch, ) self.assertEqual(res["number"], 250) + self.assertTrue(res["issue_status_transition"]["performed"]) # --------------------------------------------------------------------------- @@ -4075,7 +4234,7 @@ class TestIssue546Deadlock(unittest.TestCase): res = mcp_server.gitea_submit_pr_review( pr_number=550, action="approve", - body="APPROVED", + body="", expected_head_sha="abc123", final_review_decision_ready=True, remote="prgs", diff --git a/tests/test_merged_cleanup_reconcile.py b/tests/test_merged_cleanup_reconcile.py index e90d5ab..284ad37 100644 --- a/tests/test_merged_cleanup_reconcile.py +++ b/tests/test_merged_cleanup_reconcile.py @@ -4,7 +4,7 @@ import os import sys import tempfile import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, Mock, patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) @@ -163,7 +163,7 @@ class TestMergedCleanupReport(unittest.TestCase): "branch refs/heads/feat/issue-11-x\n" "HEAD cafebabe\n\n" ) - mock_run.return_value = MagicMock(returncode=0, stdout=mock_output) + mock_run.return_value = Mock(returncode=0, stdout=mock_output) res = mcr.discover_local_worktrees("/repo/Gitea-Tools") self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"}) @@ -177,14 +177,23 @@ class TestMergedCleanupReport(unittest.TestCase): "branch refs/heads/feat/issue-11-x\n" "HEAD cafebabe\n\n" ) - mock_run.return_value = MagicMock(returncode=0, stdout=mock_output) - mock_state.return_value = { - "exists": True, - "clean": True, - "current_branch": "feat/issue-11-x", - "head_sha": "cafebabe", - "dirty_files": [], - } + mock_run.return_value = Mock(returncode=0, stdout=mock_output) + mock_state.side_effect = [ + { + "exists": False, + "clean": None, + "current_branch": None, + "head_sha": None, + "dirty_files": [], + }, + { + "exists": True, + "clean": True, + "current_branch": "feat/issue-11-x", + "head_sha": "cafebabe", + "dirty_files": [], + }, + ] report = mcr.build_reconciliation_report( project_root="/repo/Gitea-Tools", @@ -209,29 +218,190 @@ class TestMergedCleanupReport(unittest.TestCase): self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name") self.assertEqual(local["match_type"], "branch") + @patch("merged_cleanup_reconcile.read_local_worktree_state") @patch("subprocess.run") - def test_remove_local_worktree_uses_discovered_path(self, mock_run): - mock_output = ( - "worktree /repo/Gitea-Tools\n" - "bare\n\n" - "worktree /repo/Gitea-Tools/branches/custom-name\n" - "branch refs/heads/feat/issue-11-x\n" - "HEAD cafebabe\n\n" + def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state): + mock_run.return_value = Mock( + returncode=0, + stdout=( + "worktree /repo/Gitea-Tools\n" + "HEAD aaaa\n" + "branch refs/heads/master\n\n" + "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n" + "HEAD cafebabe\n" + "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" + ), ) - # First call is discover_local_worktrees, second is git worktree remove - mock_run.side_effect = [ - MagicMock(returncode=0, stdout=mock_output), - MagicMock(returncode=0, stdout="", stderr=""), + mock_state.side_effect = [ + { + "exists": False, + "clean": None, + "current_branch": None, + "head_sha": None, + "dirty_files": [], + }, + { + "exists": True, + "clean": True, + "current_branch": "feat/issue-510-workspace-binding-isolation", + "head_sha": "cafebabe", + "dirty_files": [], + }, ] - with patch("os.path.isdir", return_value=True): - result = mcr.remove_local_worktree("/repo/Gitea-Tools", "feat/issue-11-x") + report = mcr.build_reconciliation_report( + project_root="/repo/Gitea-Tools", + closed_prs=[ + { + "number": 512, + "title": "merged cleanup", + "body": "Closes #510", + "head": { + "ref": "feat/issue-510-workspace-binding-isolation", + "sha": "cafebabe", + }, + "merged_at": "2026-07-06T12:00:00Z", + "merge_commit_sha": "abc123", + } + ], + open_prs=[], + remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True}, + head_on_master={512: True}, + delete_capability_allowed=True, + target_ref="prgs/master", + ) + + local = report["entries"][0]["local_worktree"] + self.assertTrue(local["safe_to_remove_worktree"]) + self.assertEqual(local["match_type"], "branch") + self.assertEqual( + local["expected_worktree_path"], + "/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation", + ) + self.assertEqual( + local["discovered_worktree_path"], + "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", + ) + + @patch("merged_cleanup_reconcile.read_local_worktree_state") + @patch("subprocess.run") + def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state): + mock_run.return_value = Mock( + returncode=0, + stdout=( + "worktree /repo/Gitea-Tools/branches/issue-510-one\n" + "HEAD cafebabe\n" + "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" + "worktree /repo/Gitea-Tools/branches/issue-510-two\n" + "HEAD cafebabe\n" + "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" + ), + ) + mock_state.return_value = { + "exists": False, + "clean": None, + "current_branch": None, + "head_sha": None, + "dirty_files": [], + } + + report = mcr.build_reconciliation_report( + project_root="/repo/Gitea-Tools", + closed_prs=[ + { + "number": 512, + "title": "merged cleanup", + "body": "Closes #510", + "head": { + "ref": "feat/issue-510-workspace-binding-isolation", + "sha": "cafebabe", + }, + "merged_at": "2026-07-06T12:00:00Z", + "merge_commit_sha": "abc123", + } + ], + open_prs=[], + remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True}, + head_on_master={512: True}, + delete_capability_allowed=True, + target_ref="prgs/master", + ) + + local = report["entries"][0]["local_worktree"] + self.assertFalse(local["safe_to_remove_worktree"]) + self.assertEqual(local["match_type"], "ambiguous_alias") + self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0]) + + @patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True) + @patch("merged_cleanup_reconcile.read_local_worktree_state") + @patch("subprocess.run") + def test_alias_head_on_target_branch_is_safe_cleanup_commit( + self, mock_run, mock_state, _ancestor + ): + mock_run.return_value = Mock( + returncode=0, + stdout=( + "worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n" + "HEAD deadbeef\n" + "branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n" + ), + ) + mock_state.side_effect = [ + { + "exists": False, + "clean": None, + "current_branch": None, + "head_sha": None, + "dirty_files": [], + }, + { + "exists": True, + "clean": True, + "current_branch": "feat/issue-510-workspace-binding-isolation", + "head_sha": "deadbeef", + "dirty_files": [], + }, + ] + + state = mcr.resolve_cleanup_worktree_state( + project_root="/repo/Gitea-Tools", + head_branch="feat/issue-510-workspace-binding-isolation", + issue_number=510, + pr_head_sha="cafebabe", + target_ref="prgs/master", + ) + + self.assertTrue(state["exists"]) + self.assertEqual(state["match_type"], "branch") + self.assertEqual( + state["discovered_worktree_path"], + "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", + ) + + @patch("os.path.isdir", return_value=True) + @patch("subprocess.run") + def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir): + mock_run.return_value = Mock(returncode=0, stdout="", stderr="") + result = mcr.remove_local_worktree( + "/repo/Gitea-Tools", + "feat/issue-510-workspace-binding-isolation", + worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", + ) + self.assertTrue(result["success"]) - self.assertTrue(result["performed"]) - self.assertEqual(result["worktree_path"], "/repo/Gitea-Tools/branches/custom-name") - # Assert git worktree remove was called with the custom path - mock_run.assert_any_call( - ["git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/custom-name"], + self.assertEqual( + result["worktree_path"], + "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", + ) + mock_run.assert_called_once_with( + [ + "git", + "-C", + "/repo/Gitea-Tools", + "worktree", + "remove", + "/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation", + ], capture_output=True, text=True, check=False, @@ -440,4 +610,4 @@ class TestReviewerScratchCleanup(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_namespace_workspace_binding.py b/tests/test_namespace_workspace_binding.py index b9c270c..91364e7 100644 --- a/tests/test_namespace_workspace_binding.py +++ b/tests/test_namespace_workspace_binding.py @@ -103,6 +103,8 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase): srv._preflight_in_test_mode = lambda: False self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False) self._env_patch.start() + self._guard_patch = mock.patch("gitea_mcp_server._enforce_root_checkout_guard") + self._guard_patch.start() def tearDown(self): srv._preflight_whoami_called = self._saved["whoami_called"] @@ -112,6 +114,7 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase): srv._preflight_capability_violation = self._saved["capability_violation"] srv._preflight_in_test_mode = self._saved["in_test"] self._env_patch.stop() + self._guard_patch.stop() for key in ( nwb.AUTHOR_WORKTREE_ENV, nwb.MERGER_WORKTREE_ENV, diff --git a/tests/test_operator_guide.py b/tests/test_operator_guide.py index a5159c6..b73806d 100644 --- a/tests/test_operator_guide.py +++ b/tests/test_operator_guide.py @@ -33,8 +33,15 @@ REVIEWER_ENV = { "GITEA_PROFILE_NAME": "reviewer-test", "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve," - "gitea.pr.request_changes,gitea.pr.merge", - "GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push", + "gitea.pr.request_changes", + "GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.merge", +} + +MERGER_ENV = { + "GITEA_PROFILE_NAME": "merger-test", + "GITEA_ALLOWED_OPERATIONS": + "gitea.read,gitea.pr.comment,gitea.pr.merge", + "GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.approve", } EXPECTED_SKILLS = [ @@ -49,6 +56,10 @@ EXPECTED_SKILLS = [ "jenkins-mcp", "glitchtip-mcp", "release-operator", + # Canonical workflow router (#551) — same skill, multi-runtime names + "gitea-workflow", + "llm-project-workflow", + "git-pr-workflows", ] @@ -88,6 +99,15 @@ class TestControlPlaneGuide(GuideTestBase): self.assertIn("eligibility", blob) self.assertIn("pinned", blob) + @patch("mcp_server.api_request", return_value={"login": "merger-bot"}) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_merger_profile_guidance(self, _auth, _api): + with patch.dict(os.environ, MERGER_ENV, clear=True): + g = mcp_get_control_plane_guide(remote="prgs") + self.assertEqual(g["profile"]["role_kind"], "merger") + blob = " ".join(g["guidance"]).lower() + self.assertIn("merge", blob) + @patch("mcp_server.get_auth_header", return_value=None) def test_unresolved_identity_instructs_stop(self, _auth): with patch.dict(os.environ, AUTHOR_ENV, clear=True): diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py index b06e416..86f0bad 100644 --- a/tests/test_permission_reports.py +++ b/tests/test_permission_reports.py @@ -234,11 +234,10 @@ class TestEligibilityDenialReport(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") from tests.test_mcp_server import _seed_ready_review_decision - - _seed_ready_review_decision(42, "approve", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + _seed_ready_review_decision(42, "approve", remote="prgs") res = mcp_server.gitea_submit_pr_review( pr_number=42, action="approve", body="lgtm", remote="prgs", final_review_decision_ready=True) @@ -255,9 +254,9 @@ class TestEligibilityDenialReport(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") - mcp_server.gitea_load_review_workflow() with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.gitea_load_review_workflow() res = mcp_server.gitea_merge_pr( pr_number=42, confirmation="MERGE PR 42", expected_head_sha="abc123", remote="prgs") @@ -280,11 +279,10 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api - mcp_server.init_review_decision_lock("prgs", "review_pr") from tests.test_mcp_server import _seed_ready_review_decision - - _seed_ready_review_decision(42, "comment", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): + mcp_server.init_review_decision_lock("prgs", "review_pr") + _seed_ready_review_decision(42, "comment", remote="prgs") res = mcp_server.gitea_submit_pr_review( pr_number=42, action="comment", body="finding", remote="prgs", final_review_decision_ready=True) diff --git a/tests/test_reconciler_close_workspace_guard.py b/tests/test_reconciler_close_workspace_guard.py index 54a24e4..f8d8951 100644 --- a/tests/test_reconciler_close_workspace_guard.py +++ b/tests/test_reconciler_close_workspace_guard.py @@ -35,9 +35,12 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase): srv._preflight_capability_violation = False self._orig_in_test = srv._preflight_in_test_mode srv._preflight_in_test_mode = lambda: False + self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False) + self._env_patch.start() def tearDown(self): srv._preflight_in_test_mode = self._orig_in_test + self._env_patch.stop() @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) diff --git a/tests/test_resolve_task_capability.py b/tests/test_resolve_task_capability.py index a34456d..aa25ced 100644 --- a/tests/test_resolve_task_capability.py +++ b/tests/test_resolve_task_capability.py @@ -44,9 +44,19 @@ CONFIG_RESOLVER = { "role": "reviewer", "username": "reviewer-user", "auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"}, - "allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"], - "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + "allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.issue.comment"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"], "execution_profile": "reviewer-profile" + }, + "merger-profile": { + "enabled": True, + "context": "ctx", + "role": "merger", + "username": "merger-user", + "auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"}, + "allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.issue.comment"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"], + "execution_profile": "merger-profile" } }, "rules": { @@ -83,6 +93,7 @@ class TestResolveTaskCapability(unittest.TestCase): "GITEA_MCP_PROFILE": profile, "GITEA_TOKEN_AUTHOR": "author-pass", "GITEA_TOKEN_REVIEWER": "reviewer-pass", + "GITEA_TOKEN_MERGER": "merger-pass", } @patch("mcp_server.api_request", return_value={"login": "author-user"}) @@ -231,9 +242,9 @@ class TestResolveTaskCapability(unittest.TestCase): with patch.dict(os.environ, self._env("author-profile")): res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs") self.assertFalse(res["allowed_in_current_session"]) - self.assertIn("reviewer", res["exact_safe_next_action"].lower()) + self.assertIn("merger", res["exact_safe_next_action"].lower()) self.assertEqual(res["required_operation_permission"], "gitea.pr.merge") - self.assertEqual(res["required_role_kind"], "reviewer") + self.assertEqual(res["required_role_kind"], "merger") @patch("mcp_server.api_request") def test_self_review_blocked_structured(self, mock_api): diff --git a/tests/test_review_final_report_schema.py b/tests/test_review_final_report_schema.py index 3b2211e..f81e3ba 100644 --- a/tests/test_review_final_report_schema.py +++ b/tests/test_review_final_report_schema.py @@ -35,6 +35,9 @@ def _minimal_review_report(**overrides): "- External-state mutations: none", "- Read-only diagnostics: gitea_view_pr, gitea_view_issue", "- Current status: PR open", + "- Next actor: author", + "- Next action: Fix", + "- Next prompt: Fix", "- Blockers: none", "- Next: await author", "- Safety: no self-review; no self-merge", @@ -43,6 +46,8 @@ def _minimal_review_report(**overrides): "- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", "- Worktree path: branches/review-203", "- Worktree dirty: clean", + "- Pinned reviewed head: none", + "- Scratch worktree used: none", "- Unrelated local mutations: none", "- Review decision: request_changes", "- Merge result: not attempted", diff --git a/tests/test_reviewer_handoff_consistency.py b/tests/test_reviewer_handoff_consistency.py new file mode 100644 index 0000000..ac83124 --- /dev/null +++ b/tests/test_reviewer_handoff_consistency.py @@ -0,0 +1,120 @@ +"""Tests for reviewer handoff consistency validation (#501).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import reviewer_handoff_consistency # noqa: E402 +from final_report_validator import assess_final_report_validator # noqa: E402 + + +def _base_handoff(**overrides): + lines = [ + "## Controller Handoff", + "", + "- Task: review PR #472", + "- Review decision: request_changes", + "- Review mutations: submitted request_changes review", + "- Merge mutations: none", + "- MCP/Gitea mutations: reviewer lease acquired", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + token = f"- {key}:" + if token in text: + before, _, after = text.partition(token) + rest = after.split("\n", 1) + suffix = rest[1] if len(rest) > 1 else "" + text = f"{before}{token} {value}" + (f"\n{suffix}" if suffix else "") + else: + text += f"\n- {key}: {value}" + return text + + +class TestReviewerHandoffConsistency(unittest.TestCase): + def test_consistent_handoff_passes(self): + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency( + _base_handoff() + ) + self.assertTrue(result["proven"], result["reasons"]) + + def test_claimed_merge_missing_from_ledger(self): + report = _base_handoff( + **{ + "Current status": "Merged PR #411 earlier; reviewing PR #472", + "Merge mutations": "none", + "MCP/Gitea mutations": "reviewer lease acquired", + } + ) + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("merge" in r.lower() for r in result["reasons"])) + + def test_terminal_budget_consumed_without_merge_in_ledger(self): + report = _base_handoff( + **{ + "Current status": "Terminal mutation budget already consumed by merge", + "Merge mutations": "none", + "MCP/Gitea mutations": "reviewer lease acquired", + } + ) + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("terminal mutation budget" in r for r in result["reasons"])) + + def test_review_blocked_but_final_decision_marked(self): + report = _base_handoff( + **{ + "Current status": "gitea_submit_pr_review blocked", + "Review mutations": "none", + } + ) + report += "\nServer-side final decision marked via gitea_mark_final_review_decision." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("blocked" in r and "final decision" in r for r in result["reasons"])) + + def test_lease_without_review_decision(self): + report = _base_handoff(**{"Review decision": "none"}) + report += "\nReviewer lease acquired for PR #472." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("Review decision" in r for r in result["reasons"])) + + def test_rejected_mutation_without_proof(self): + report = _base_handoff( + **{ + "Review mutations": "none", + "MCP/Gitea mutations": "none", + } + ) + report += "\nReview submission blocked before mutation." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("proof fields" in r for r in result["reasons"])) + + def test_blocked_template_includes_required_fields(self): + template = reviewer_handoff_consistency.render_blocked_review_handoff_template() + for field in reviewer_handoff_consistency._BLOCKED_REVIEW_PROOF_FIELDS: + self.assertIn(field, template.lower()) + + def test_final_report_validator_integration(self): + report = _base_handoff( + **{ + "Current status": "Merged PR #411; terminal mutation budget consumed", + "Merge mutations": "none", + } + ) + result = assess_final_report_validator(report, "review_pr") + self.assertTrue( + any( + f["rule_id"] == "reviewer.handoff_consistency" + for f in result["findings"] + ), + result["findings"], + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_runtime_clarity.py b/tests/test_runtime_clarity.py index e5098e0..1e94ada 100644 --- a/tests/test_runtime_clarity.py +++ b/tests/test_runtime_clarity.py @@ -43,9 +43,19 @@ CONFIG_SWITCHING_DISABLED = { "role": "reviewer", "username": "reviewer-user", "auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"}, - "allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"], - "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + "allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"], "execution_profile": "reviewer-profile" + }, + "merger-profile": { + "enabled": True, + "context": "ctx", + "role": "merger", + "username": "merger-user", + "auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"}, + "allowed_operations": ["gitea.read", "gitea.pr.merge"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"], + "execution_profile": "merger-profile" } }, "rules": { @@ -90,6 +100,7 @@ class TestRuntimeClarity(unittest.TestCase): "GITEA_MCP_REVEAL_ENDPOINTS": reveal, "GITEA_TOKEN_AUTHOR": "author-pass", "GITEA_TOKEN_REVIEWER": "reviewer-pass", + "GITEA_TOKEN_MERGER": "merger-pass", } # ------------------------------------------------------------------------- @@ -125,7 +136,7 @@ class TestRuntimeClarity(unittest.TestCase): t for t in caps["task_capabilities"] if t["task"] == "merge_pr" ) self.assertFalse(merge_entry["allowed_in_current_session"]) - self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"]) + self.assertIn("merger-profile", merge_entry["matching_configured_profiles"]) @patch( "mcp_server.assess_preflight_status", @@ -144,6 +155,30 @@ class TestRuntimeClarity(unittest.TestCase): caps = ctx["session_capabilities"] self.assertFalse(caps["can_author_prs"]) self.assertFalse(caps["can_create_issues"]) + self.assertTrue(caps["can_review_prs"]) + self.assertFalse(caps["can_merge_prs"]) + merge_entry = next( + t for t in caps["task_capabilities"] if t["task"] == "merge_pr" + ) + self.assertFalse(merge_entry["allowed_in_current_session"]) + + @patch( + "mcp_server.assess_preflight_status", + return_value={"preflight_ready": True, "preflight_block_reasons": []}, + ) + @patch("mcp_server.api_request", return_value={"login": "merger-user"}) + @patch("mcp_server.get_auth_header", return_value="token merger-pass") + def test_get_runtime_context_merger(self, _auth, _api, _preflight): + with patch.dict(os.environ, self._env("merger-profile"), clear=True): + ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools") + self.assertEqual(ctx["active_profile"], "merger-profile") + self.assertEqual(ctx["authenticated_username"], "merger-user") + self.assertTrue(ctx["review_merge_allowed"]) + self.assertEqual(ctx["suggested_fix"], "none") + self.assertEqual(ctx["safe_next_action"], "None; ready for operations.") + caps = ctx["session_capabilities"] + self.assertFalse(caps["can_author_prs"]) + self.assertFalse(caps["can_create_issues"]) self.assertFalse(caps["can_review_prs"]) self.assertTrue(caps["can_merge_prs"]) merge_entry = next( @@ -160,7 +195,7 @@ class TestRuntimeClarity(unittest.TestCase): with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True): res = mcp_server.gitea_list_profiles() profiles = res["profiles"] - self.assertEqual(len(profiles), 2) + self.assertEqual(len(profiles), 3) author_prof = next(p for p in profiles if p["name"] == "author-profile") self.assertTrue(author_prof["is_active"]) @@ -176,6 +211,13 @@ class TestRuntimeClarity(unittest.TestCase): self.assertEqual(reviewer_prof["base_url"], "<redacted>") self.assertEqual(reviewer_prof["identity_status"], "credentials present") + merger_prof = next(p for p in profiles if p["name"] == "merger-profile") + self.assertFalse(merger_prof["is_active"]) + self.assertEqual(merger_prof["role_kind"], "merger") + self.assertEqual(merger_prof["auth"]["name"], "<redacted>") + self.assertEqual(merger_prof["base_url"], "<redacted>") + self.assertEqual(merger_prof["identity_status"], "credentials present") + @patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.get_auth_header", return_value="token author-pass") def test_list_profiles_revealed_under_opt_in(self, _auth, _api): diff --git a/tests/test_thread_state_ledger_validator.py b/tests/test_thread_state_ledger_validator.py new file mode 100644 index 0000000..8b5b0f2 --- /dev/null +++ b/tests/test_thread_state_ledger_validator.py @@ -0,0 +1,187 @@ +"""Tests for two-comment workflow validation (#507).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from thread_state_ledger_validator import ( # noqa: E402 + BLOCKER_CLASSIFICATIONS, + assess_controller_handoff_comment, + assess_thread_state_ledger_comment, + assess_two_comment_workflow, + assess_two_comment_pair, +) + + +def _minimal_handoff(**overrides): + lines = [ + "[CONTROLLER HANDOFF] PR #203 / Issue #182 — review complete", + "", + "Identity/profile:", + "- Active profile: prgs-reviewer", + "- Authenticated identity: sysadmin", + "", + "Server-side mutation ledger:", + "- gitea_submit_pr_review → APPROVED review posted to Gitea", + "", + "Blockers:", + "- none", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + if f"{key}:" in text: + text = text.replace(f"{key}:", f"{key}: {value}", 1) + return text + + +def _minimal_ledger(**overrides): + lines = [ + "[THREAD STATE LEDGER] PR #203 — APPROVED review posted to Gitea", + "", + "What is true now:", + "- PR state: open", + "- Current head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Server-side decision state: APPROVED review posted to Gitea", + "- Local verdict/state: APPROVE verdict prepared locally", + "- Latest known validation: tests passed locally", + "", + "What changed:", + "- APPROVED review posted to Gitea at pinned head", + "", + "What is blocked:", + "- Blocker classification: no blocker", + "", + "Who/what acts next:", + "- Next actor: merger", + "- Required action: merge on explicit operator command", + "- Do not do: re-post APPROVE", + "- Resume from: PR #203 review feedback", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + text = text.replace(f"- {key}:", f"- {key}: {value}") + return text + + +class TestTwoCommentWorkflow(unittest.TestCase): + def test_valid_combined_report_passes(self): + report = _minimal_handoff() + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_missing_ledger_after_tagged_handoff_blocks(self): + result = assess_two_comment_workflow(_minimal_handoff()) + self.assertTrue(result["block"]) + self.assertTrue( + any("THREAD STATE LEDGER" in r for r in result["reasons"]) + ) + + def test_legacy_controller_handoff_section_not_required_ledger(self): + legacy = "## Controller Handoff\n\n- Task: work issue\n- Next: continue\n" + result = assess_two_comment_workflow(legacy) + self.assertFalse(result["block"]) + + def test_ambiguous_approved_blocks(self): + handoff = _minimal_handoff().replace( + "APPROVED review posted to Gitea", + "approved", + ) + report = handoff + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("approved" in r.lower() for r in result["reasons"])) + + def test_ambiguous_merged_blocks(self): + handoff = _minimal_handoff() + "\n\n- Narrative: merged successfully" + ledger = _minimal_ledger() + report = handoff + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("merge" in r.lower() for r in result["reasons"])) + + def test_blocked_without_gate_blocks(self): + handoff = _minimal_handoff().replace("- none", "- blocked") + ledger = _minimal_ledger().replace( + "no blocker", "blocked but no gate named" + ) + report = handoff + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + + def test_mutation_ledger_contradiction_blocks(self): + handoff = _minimal_handoff().replace( + "APPROVED review posted to Gitea", + "none — no server-side state changed", + ) + handoff += "\n- Narrative: APPROVED review posted to Gitea" + report = handoff + "\n\n" + _minimal_ledger() + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + self.assertTrue(any("contradict" in r.lower() for r in result["reasons"])) + + def test_local_verdict_as_server_side_blocks(self): + ledger = _minimal_ledger( + **{ + "Server-side decision state": "APPROVE verdict prepared locally", + "Local verdict/state": "none", + } + ) + report = _minimal_handoff() + "\n\n" + ledger + result = assess_two_comment_workflow(report) + self.assertTrue(result["block"]) + + +class TestHandoffComment(unittest.TestCase): + def test_handoff_comment_valid(self): + result = assess_controller_handoff_comment(_minimal_handoff()) + self.assertTrue(result["proven"]) + + def test_handoff_without_marker_skips(self): + result = assess_controller_handoff_comment("casual comment") + self.assertTrue(result["proven"]) + self.assertFalse(result["performed"]) + + +class TestLedgerComment(unittest.TestCase): + def test_ledger_requires_blocker_classification(self): + ledger = _minimal_ledger().replace( + "Blocker classification: no blocker", + "something unclear", + ) + result = assess_thread_state_ledger_comment(ledger) + self.assertTrue(result["block"]) + + def test_all_blocker_classifications_recognized(self): + self.assertIn("no blocker", BLOCKER_CLASSIFICATIONS) + self.assertIn("environment/tooling blocker", BLOCKER_CLASSIFICATIONS) + + +class TestPairedComments(unittest.TestCase): + def test_pair_passes(self): + result = assess_two_comment_pair(_minimal_handoff(), _minimal_ledger()) + self.assertTrue(result["proven"]) + + def test_pair_missing_ledger_blocks(self): + result = assess_two_comment_pair(_minimal_handoff(), "") + self.assertTrue(result["block"]) + + +class TestExamples(unittest.TestCase): + """Smoke-test bundled examples from docs.""" + + def test_examples_module_loads(self): + from thread_state_ledger_examples import EXAMPLES # noqa: E402 + + self.assertGreaterEqual(len(EXAMPLES), 8) + for name, handoff, ledger in EXAMPLES: + result = assess_two_comment_pair(handoff, ledger) + self.assertTrue( + result["proven"], + f"example '{name}' failed: {result['reasons']}", + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_audit.py b/tests/test_webui_audit.py new file mode 100644 index 0000000..51f8cd9 --- /dev/null +++ b/tests/test_webui_audit.py @@ -0,0 +1,94 @@ +"""Tests for web UI report audit paste (#431).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.audit_validator import audit_report, infer_task_kind + + +_MINIMAL_REVIEW_REPORT = """## Controller Handoff + +- Task: review PR #1 +- Repo: Scaled-Tech-Consulting/Gitea-Tools +- Role: reviewer +- Identity: sysadmin / prgs-reviewer +- Review decision: approve +""" + + +class TestAuditValidator(unittest.TestCase): + def test_infer_task_kind_from_review_report(self): + self.assertEqual(infer_task_kind(_MINIMAL_REVIEW_REPORT), "review_pr") + + def test_infer_task_kind_explicit_override(self): + self.assertEqual( + infer_task_kind(_MINIMAL_REVIEW_REPORT, explicit="work_issue"), + "work_issue", + ) + + def test_empty_report_blocked(self): + result = audit_report("") + self.assertTrue(result["blocked"]) + self.assertIn("empty", result["reasons"][0].lower()) + + def test_minimal_report_produces_findings(self): + result = audit_report(_MINIMAL_REVIEW_REPORT) + self.assertIn(result["grade"], {"blocked", "downgraded", "warning", "A"}) + self.assertTrue(result["findings"] or result["blocked"] or result["downgraded"]) + + +class TestAuditRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_audit_page_renders_form(self): + response = self.client.get("/audit") + self.assertEqual(response.status_code, 200) + self.assertIn("Report audit", response.text) + self.assertIn('name="report_text"', response.text) + self.assertIn("Run validator preview", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_audit_post_runs_validator(self): + response = self.client.post( + "/audit", + data={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("Validator preview", response.text) + self.assertIn("Safe next action", response.text) + + def test_api_audit_post_json(self): + response = self.client.post( + "/api/audit", + json={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["task_kind"], "review_pr") + self.assertIn("grade", data) + self.assertIn("findings", data) + self.assertIn("suggested_next_prompt", data) + + def test_api_audit_rejects_empty_body(self): + response = self.client.post("/api/audit", json={"report_text": ""}) + self.assertEqual(response.status_code, 400) + + def test_audit_post_allowed_other_post_still_blocked(self): + self.assertEqual(self.client.post("/health").status_code, 405) + self.assertEqual( + self.client.post( + "/audit", + data={"report_text": _MINIMAL_REVIEW_REPORT}, + ).status_code, + 200, + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 0fdf1db..d9de18e 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -34,12 +34,10 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Runtime health", response.text) - def test_route_stubs_render(self): - for path in ("/audit",): - with self.subTest(path=path): - response = self.client.get(path) - self.assertEqual(response.status_code, 200) - self.assertIn("child issue", response.text.lower()) + def test_audit_is_implemented(self): + response = self.client.get("/audit") + self.assertEqual(response.status_code, 200) + self.assertIn("Report audit", response.text) def test_prompts_is_implemented(self): response = self.client.get("/prompts") @@ -51,14 +49,17 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Gitea-Tools", response.text) + def test_worktrees_is_implemented(self): + response = self.client.get("/worktrees") + self.assertEqual(response.status_code, 200) + self.assertIn("Worktree hygiene", response.text) + def test_leases_is_implemented(self): response = self.client.get("/leases") self.assertEqual(response.status_code, 200) self.assertIn("Leases", response.text) self.assertIn("Collision warnings", response.text) - def test_extra_stub_routes(self): - self.assertEqual(self.client.get("/worktrees").status_code, 200) def test_actions_is_implemented(self): response = self.client.get("/actions") @@ -76,8 +77,8 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Live queue", response.text) def test_nav_links_on_all_pages(self): - paths = ('/', '/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions') - hrefs = ('/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions') + paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions") + hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions") for path in paths: with self.subTest(path=path): text = self.client.get(path).text diff --git a/tests/test_webui_worktree_hygiene.py b/tests/test_webui_worktree_hygiene.py new file mode 100644 index 0000000..ee62596 --- /dev/null +++ b/tests/test_webui_worktree_hygiene.py @@ -0,0 +1,114 @@ +"""Tests for web UI worktree hygiene dashboard (#432).""" +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.worktree_scanner import ( + classify_entry, + load_hygiene_snapshot, + parse_worktree_list_porcelain, + snapshot_to_dict, +) + + +class TestWorktreeScanner(unittest.TestCase): + def test_parse_worktree_porcelain(self): + porcelain = ( + "worktree /repo/branches/feat-issue-1-foo\n" + "HEAD abcdef0123456789\n" + "branch refs/heads/feat/issue-1-foo\n" + "\n" + ) + entries = parse_worktree_list_porcelain(porcelain) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["branch"], "feat/issue-1-foo") + + def test_classify_active_pr(self): + classification, _ = classify_entry( + rel_path="branches/feat-issue-99-demo", + worktree_record={"branch": "feat/issue-99-demo"}, + state={"exists": True, "clean": True, "dirty_files": []}, + open_pr_branches={"feat/issue-99-demo"}, + active_lock_branch=None, + ) + self.assertEqual(classification, "active-pr") + + def test_classify_dirty(self): + classification, _ = classify_entry( + rel_path="branches/feat-issue-2-bar", + worktree_record={"branch": "feat/issue-2-bar"}, + state={"exists": True, "clean": False, "dirty_files": ["webui/app.py"]}, + open_pr_branches=set(), + active_lock_branch=None, + ) + self.assertEqual(classification, "dirty") + + def test_missing_lock_worktree_anomaly(self): + with tempfile.TemporaryDirectory() as tmp: + branches = Path(tmp) / "branches" / "other-worktree" + branches.mkdir(parents=True) + lock_path = Path(tmp) / "lock.json" + lock_path.write_text( + json.dumps({ + "issue_number": 432, + "branch_name": "feat/issue-432-worktree-hygiene", + "worktree_path": str(Path(tmp) / "branches" / "missing-tree"), + }), + encoding="utf-8", + ) + snapshot = load_hygiene_snapshot( + project_root=tmp, + worktree_porcelain="", + open_pr_branches=set(), + issue_lock_path=str(lock_path), + ) + self.assertTrue(snapshot.anomalies) + self.assertIn("Missing preserved worktree", snapshot.anomalies[0]) + + def test_snapshot_to_dict(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "branches").mkdir() + snapshot = load_hygiene_snapshot( + project_root=tmp, + worktree_porcelain="", + open_pr_branches=set(), + issue_lock_path=str(Path(tmp) / "no-lock.json"), + ) + data = snapshot_to_dict(snapshot) + self.assertIn("entries", data) + self.assertIn("cleanup_prompt", data) + + +class TestWorktreeRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_worktrees_page_renders(self): + response = self.client.get("/worktrees") + self.assertEqual(response.status_code, 200) + self.assertIn("Worktree hygiene", response.text) + self.assertIn("Canonical cleanup prompt", response.text) + self.assertIn("Copy cleanup prompt", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_worktrees_json(self): + response = self.client.get("/api/worktrees") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("entries", data) + self.assertIn("cleanup_prompt", data) + self.assertIn("anomalies", data) + + def test_nav_includes_worktrees(self): + self.assertIn('href="/worktrees"', self.client.get("/").text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_workflow_skill.py b/tests/test_workflow_skill.py new file mode 100644 index 0000000..78072a2 --- /dev/null +++ b/tests/test_workflow_skill.py @@ -0,0 +1,87 @@ +"""Tests for canonical workflow skill naming and preflight (#551).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import workflow_skill +import mcp_server + + +class TestWorkflowSkillModule(unittest.TestCase): + def test_canonical_names_include_gitea_workflow(self): + names = workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES + self.assertIn("gitea-workflow", names) + self.assertIn("llm-project-workflow", names) + self.assertIn("git-pr-workflows", names) + + def test_repo_skill_present_in_this_checkout(self): + root = str(Path(__file__).resolve().parent.parent) + result = workflow_skill.assess_workflow_skill_presence(root) + self.assertTrue(result["repo_skill_present"]) + self.assertTrue(result["repo_alias_present"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + + def test_missing_repo_skill_blocks(self): + with tempfile.TemporaryDirectory() as tmp: + result = workflow_skill.assess_workflow_skill_presence(tmp) + self.assertTrue(result["blocked"]) + self.assertFalse(result["workflow_skill_ready"]) + self.assertTrue(any("missing" in r for r in result["reasons"])) + + def test_codex_mount_detection(self): + root = str(Path(__file__).resolve().parent.parent) + with tempfile.TemporaryDirectory() as tmp: + skill_dir = Path(tmp) / "gitea-workflow" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# x\n", encoding="utf-8") + result = workflow_skill.assess_workflow_skill_presence( + root, codex_skills_dir=tmp + ) + self.assertTrue(result["codex_skill_mounted"]) + self.assertTrue(result["codex_skill_mounts"]["gitea-workflow"]) + + def test_registry_entries_cover_aliases(self): + entries = workflow_skill.workflow_skill_registry_entries() + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, entries) + self.assertEqual(entries[name]["status"], "available") + + +class TestWorkflowSkillMcpTools(unittest.TestCase): + def test_list_includes_canonical_names(self): + with patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "author-test", + "GITEA_ALLOWED_OPERATIONS": "gitea.read", + "GITEA_FORBIDDEN_OPERATIONS": "", + }, + clear=False, + ): + result = mcp_server.mcp_list_project_skills() + names = {s["name"] for s in result["skills"]} + for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES: + self.assertIn(name, names) + + def test_get_skill_guide_gitea_workflow(self): + guide = mcp_server.mcp_get_skill_guide("gitea-workflow") + self.assertTrue(guide["success"]) + self.assertTrue(guide["steps"]) + self.assertIn("workflow", " ".join(guide["steps"]).lower()) + + def test_preflight_tool_ok_on_repo(self): + result = mcp_server.mcp_check_workflow_skill_preflight() + self.assertTrue(result["success"]) + self.assertTrue(result["workflow_skill_ready"]) + self.assertFalse(result["blocked"]) + self.assertIn("gitea-workflow", result["canonical_names"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/thread_state_ledger_examples.py b/thread_state_ledger_examples.py new file mode 100644 index 0000000..9c17442 --- /dev/null +++ b/thread_state_ledger_examples.py @@ -0,0 +1,341 @@ +"""Worked examples for the two-comment workflow (#507).""" + +from __future__ import annotations + +HEAD_SHA = "08202f7eaa6d09b2eca8f2960126994a4b22646b" + +EXAMPLES: list[tuple[str, str, str]] = [] + + +def _example(name: str, handoff: str, ledger: str) -> tuple[str, str, str]: + return (name, handoff.strip(), ledger.strip()) + + +EXAMPLES.append( + _example( + "approved_review_posted", + f""" +[CONTROLLER HANDOFF] PR #487 / Issue #485 — review approved + +Server-side mutation ledger: +- gitea_submit_pr_review → APPROVED review posted to Gitea (comment id 6566) + +Blockers: +- none +""", + f""" +[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: APPROVED review posted to Gitea +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- APPROVED review posted to Gitea at pinned head + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: merger +- Required action: merge on explicit operator command +- Do not do: re-post APPROVE +- Resume from: PR #487 review feedback +""", + ) +) + +EXAMPLES.append( + _example( + "approve_blocked_before_posting", + f""" +[CONTROLLER HANDOFF] PR #487 / Issue #485 — approve blocked + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- process/rule blocker: MCP process root still control checkout +""", + f""" +[THREAD STATE LEDGER] PR #487 — APPROVE verdict prepared locally; post blocked + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: no server-side state changed +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- Review validation completed locally; APPROVE not posted + +What is blocked: +- Blocker classification: process/rule blocker + +Who/what acts next: +- Next actor: operator +- Required action: reconnect reviewer MCP to branches worktree +- Do not do: retry APPROVE from root-bound session +- Resume from: lease + mark_final_review_decision + submit +""", + ) +) + +EXAMPLES.append( + _example( + "request_changes_posted", + f""" +[CONTROLLER HANDOFF] PR #200 — request changes + +Server-side mutation ledger: +- gitea_submit_pr_review → REQUEST_CHANGES posted to Gitea + +Blockers: +- none +""", + """ +[THREAD STATE LEDGER] PR #200 — REQUEST_CHANGES posted to Gitea + +What is true now: +- PR state: open +- Server-side decision state: REQUEST_CHANGES posted to Gitea +- Local verdict/state: REQUEST_CHANGES prepared locally +- Latest known validation: tests passed locally + +What changed: +- REQUEST_CHANGES posted to Gitea + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: author +- Required action: address review feedback and push fixes +- Do not do: merge +- Resume from: PR review comments +""", + ) +) + +EXAMPLES.append( + _example( + "merge_completed", + f""" +[CONTROLLER HANDOFF] PR #487 — merge performed + +Server-side mutation ledger: +- gitea_merge_pr → merge performed + +Blockers: +- none +""", + f""" +[THREAD STATE LEDGER] PR #487 — merge performed + +What is true now: +- PR state: merged +- Current head SHA: {HEAD_SHA} +- Server-side decision state: merge performed +- Local verdict/state: merge not attempted locally before MCP merge +- Latest known validation: full suite not rerun + +What changed: +- merge performed via gitea_merge_pr + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: reconciler +- Required action: verify linked issue closure policy +- Do not do: re-merge +- Resume from: post-merge cleanup checklist +""", + ) +) + +EXAMPLES.append( + _example( + "merge_blocked", + f""" +[CONTROLLER HANDOFF] PR #487 — merge not performed + +Server-side mutation ledger: +- gitea_merge_pr attempted → merge not performed (stale approval gate) + +Blockers: +- stale head blocker: approval not at current head +""", + f""" +[THREAD STATE LEDGER] PR #487 — merge not performed + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: merge not performed +- Local verdict/state: merge not performed +- Latest known validation: tests passed locally + +What changed: +- merge attempt blocked by stale-head gate + +What is blocked: +- Blocker classification: stale head + +Who/what acts next: +- Next actor: reviewer +- Required action: re-review at current head +- Do not do: merge until approval_at_current_head is true +- Resume from: gitea_get_pr_review_feedback +""", + ) +) + +EXAMPLES.append( + _example( + "reconciler_closure", + f""" +[CONTROLLER HANDOFF] PR #99 — reconciler closure + +Server-side mutation ledger: +- gitea_reconcile_already_landed_pr → PR closed on Gitea + +Blockers: +- none +""", + """ +[THREAD STATE LEDGER] PR #99 — reconciler closure complete + +What is true now: +- PR state: closed +- Server-side decision state: server-side state changed +- Local verdict/state: no server-side state changed locally before MCP close +- Latest known validation: ancestor proof passed + +What changed: +- superseded PR closed by reconciler + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: controller +- Required action: verify queue has no duplicate open PR +- Do not do: reopen without canonical PR proof +- Resume from: queue inventory +""", + ) +) + +EXAMPLES.append( + _example( + "environment_tooling_blocker", + f""" +[CONTROLLER HANDOFF] PR #487 — tooling blocker + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- environment/tooling blocker: gitea-reviewer MCP process root mismatch +""", + f""" +[THREAD STATE LEDGER] PR #487 — lease attempt blocked + +What is true now: +- PR state: open +- Current head SHA: {HEAD_SHA} +- Server-side decision state: no server-side state changed +- Local verdict/state: APPROVE verdict prepared locally +- Latest known validation: tests passed locally + +What changed: +- lease attempt blocked by workspace guard + +What is blocked: +- Blocker classification: environment/tooling blocker + +Who/what acts next: +- Next actor: operator +- Required action: set GITEA_AUTHOR_WORKTREE and /mcp reconnect +- Do not do: acquire lease from control checkout +- Resume from: gitea_get_runtime_context +""", + ) +) + +EXAMPLES.append( + _example( + "stale_head_blocker", + f""" +[CONTROLLER HANDOFF] PR #300 — stale head + +Server-side mutation ledger: +- none — no server-side state changed + +Blockers: +- stale head blocker: live head differs from pinned reviewed head +""", + """ +[THREAD STATE LEDGER] PR #300 — stale approval + +What is true now: +- PR state: open +- Server-side decision state: no server-side state changed +- Local verdict/state: approval_at_current_head is false (stale head) +- Latest known validation: full suite not rerun + +What changed: +- author pushed after approval + +What is blocked: +- Blocker classification: stale head + +Who/what acts next: +- Next actor: reviewer +- Required action: fresh review at current head +- Do not do: merge with stale approval +- Resume from: gitea_get_pr_review_feedback +""", + ) +) + +EXAMPLES.append( + _example( + "duplicate_canonicalization_blocker", + f""" +[CONTROLLER HANDOFF] Issue filing — duplicate found + +Server-side mutation ledger: +- gitea_create_issue_comment on #505 with missing acceptance criteria + +Blockers: +- duplicate/canonicalization blocker: #505 tracks CTH umbrella +""", + """ +[THREAD STATE LEDGER] Issue #507 — filed as focused split from #505 + +What is true now: +- Issue state: open +- Server-side decision state: server-side state changed +- Local verdict/state: no server-side state changed before issue create +- Latest known validation: duplicate search completed + +What changed: +- new issue #507 created after duplicate assessment + +What is blocked: +- Blocker classification: no blocker + +Who/what acts next: +- Next actor: author +- Required action: implement #507 two-comment validator +- Do not do: recreate duplicate CTH issue +- Resume from: issue #507 body +""", + ) +) \ No newline at end of file diff --git a/thread_state_ledger_validator.py b/thread_state_ledger_validator.py new file mode 100644 index 0000000..5dde2b1 --- /dev/null +++ b/thread_state_ledger_validator.py @@ -0,0 +1,399 @@ +"""Two-comment workflow validation for Controller Handoff + Thread State Ledger (#507). + +Validates the paired reporting pattern without replacing the CTH umbrella (#505) +or the broader lifecycle ledger (#494/#495). +""" + +from __future__ import annotations + +import re +from typing import Any + +CONTROLLER_HANDOFF_TAG = "[CONTROLLER HANDOFF]" +THREAD_STATE_LEDGER_TAG = "[THREAD STATE LEDGER]" + +BLOCKER_CLASSIFICATIONS = frozenset({ + "code blocker", + "test blocker", + "merge conflict", + "stale head", + "permission/capability blocker", + "environment/tooling blocker", + "process/rule blocker", + "queue/lease blocker", + "duplicate/canonicalization blocker", + "no blocker", +}) + +_PRECISE_APPROVE_PHRASES = ( + "approve verdict prepared locally", + "approved review posted to gitea", + "request_changes posted to gitea", +) + +_PRECISE_MERGE_PHRASES = ( + "merge performed", + "merge not performed", +) + +_PRECISE_SERVER_PHRASES = ( + "server-side state changed", + "no server-side state changed", +) + +_AMBIGUOUS_STANDALONE_TERMS = ( + "approved", + "ready", + "queued", + "blocked", + "done", + "merged", + "submitted", + "validated", +) + +_HANDOFF_TAG_RE = re.compile(r"\[CONTROLLER\s+HANDOFF\]", re.IGNORECASE) +_LEDGER_TAG_RE = re.compile(r"\[THREAD\s+STATE\s+LEDGER\]", re.IGNORECASE) +_LEGACY_HANDOFF_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M) + +_MUTATION_LEDGER_RE = re.compile( + r"server-side mutation ledger\s*:", + re.IGNORECASE, +) +_BLOCKER_CLASS_RE = re.compile( + r"blocker\s+classification\s*:\s*(.+)", + re.IGNORECASE, +) + +_LEDGER_REQUIRED_SECTIONS = ( + "what is true now", + "what changed", + "what is blocked", + "who/what acts next", +) + +_LEDGER_REQUIRED_FIELDS = ( + "server-side decision state", + "local verdict/state", + "next actor", + "required action", +) + + +def _lower(text: str) -> str: + return (text or "").lower() + + +def has_controller_handoff_marker(text: str) -> bool: + return bool(_HANDOFF_TAG_RE.search(text or "")) + + +def has_thread_state_ledger_marker(text: str) -> bool: + return bool(_LEDGER_TAG_RE.search(text or "")) + + +def _has_tagged_handoff(text: str) -> bool: + return has_controller_handoff_marker(text) + + +def _has_tagged_ledger(text: str) -> bool: + return has_thread_state_ledger_marker(text) + + +def _has_legacy_handoff(text: str) -> bool: + return bool(_LEGACY_HANDOFF_RE.search(text or "")) + + +def _section_after_tag(text: str, tag_pattern: re.Pattern[str]) -> str | None: + text = text or "" + match = tag_pattern.search(text) + if not match: + return None + start = match.start() + other_tags = ( + _HANDOFF_TAG_RE, + _LEDGER_TAG_RE, + ) + end = len(text) + for other in other_tags: + if other.pattern == tag_pattern.pattern: + continue + later = other.search(text, match.end()) + if later: + end = min(end, later.start()) + return text[start:end] + + +def _extract_mutation_ledger(text: str) -> str: + lines = (text or "").splitlines() + capture = False + chunks: list[str] = [] + for line in lines: + stripped = line.strip().lower() + if "server-side mutation ledger" in stripped: + capture = True + continue + if capture: + if stripped.startswith("local-only") or stripped.startswith("blockers:"): + break + chunks.append(line) + return "\n".join(chunks) + + +def _ambiguous_term_violations(text: str, *, scoped: bool) -> list[str]: + if not scoped: + return [] + reasons: list[str] = [] + lower = _lower(text) + for term in _AMBIGUOUS_STANDALONE_TERMS: + if not re.search(rf"\b{re.escape(term)}\b", lower): + continue + if term == "approved": + if any(p in lower for p in _PRECISE_APPROVE_PHRASES): + continue + if "review decision:" in lower and "approve" in lower: + continue + if "approval_at_current_head" in lower: + continue + elif term == "merged": + if any(p in lower for p in _PRECISE_MERGE_PHRASES): + continue + if "merge result:" in lower: + continue + elif term == "blocked": + if any(c in lower for c in BLOCKER_CLASSIFICATIONS): + continue + if "blocker classification:" in lower: + continue + reasons.append( + f"ambiguous standalone term '{term}' without precise state language" + ) + return reasons + + +def _mutation_contradiction_reasons(handoff_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(handoff_text) + ledger = _lower(_extract_mutation_ledger(handoff_text)) + no_server = ( + "no server-side state changed" in ledger + or "none — no server-side" in ledger + or re.search(r"\bnone\b", ledger) + ) + claims_posted = "approved review posted to gitea" in lower + claims_merge = "merge performed" in lower or ( + re.search(r"\bmerged\b", lower) and "merge not performed" not in lower + ) + if claims_posted and no_server: + reasons.append( + "narrative claims APPROVED review posted but mutation ledger " + "shows no server-side state changed" + ) + if claims_merge and no_server: + reasons.append( + "narrative claims merge but mutation ledger lacks merge proof" + ) + if ( + "approved review posted to gitea" in lower + and "no server-side state changed" in ledger + ): + reasons.append( + "mutation ledger contradicts APPROVED review posted claim" + ) + return reasons + + +def _blocked_without_gate_reasons(handoff_text: str, ledger_text: str) -> list[str]: + reasons: list[str] = [] + combined = _lower(handoff_text) + "\n" + _lower(ledger_text) + if not re.search(r"\bblocked\b", combined): + return reasons + has_classification = bool(_BLOCKER_CLASS_RE.search(ledger_text or "")) + has_gate = any( + marker in combined + for marker in ( + "exact gate", + "failing gate", + "blocker classification:", + "process/rule blocker", + "environment/tooling blocker", + "permission/capability blocker", + ) + ) + if not has_classification and not has_gate: + reasons.append( + "handoff or ledger says blocked but does not identify exact " + "failing gate or blocker classification" + ) + return reasons + + +def _local_server_separation_reasons(ledger_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(ledger_text) + server_line = "" + local_line = "" + for line in (ledger_text or "").splitlines(): + stripped = line.strip().lower() + if stripped.startswith("- server-side decision state:"): + server_line = stripped + if stripped.startswith("- local verdict/state:"): + local_line = stripped + if not server_line or not local_line: + return reasons + if "approve verdict prepared locally" in server_line: + reasons.append( + "local-only verdict incorrectly listed under server-side " + "decision state" + ) + if "approved review posted to gitea" in local_line: + reasons.append( + "server-side decision incorrectly listed under local verdict/state" + ) + return reasons + + +def _ledger_field_reasons(ledger_text: str) -> list[str]: + reasons: list[str] = [] + lower = _lower(ledger_text) + for section in _LEDGER_REQUIRED_SECTIONS: + if section not in lower: + reasons.append(f"thread state ledger missing section '{section}'") + for field in _LEDGER_REQUIRED_FIELDS: + if field not in lower: + reasons.append(f"thread state ledger missing field '{field}'") + match = _BLOCKER_CLASS_RE.search(ledger_text or "") + if not match: + reasons.append("thread state ledger missing blocker classification") + else: + value = match.group(1).strip().lower().rstrip(".") + if value not in BLOCKER_CLASSIFICATIONS: + reasons.append( + f"blocker classification '{value}' is not a recognized value" + ) + if "do not do:" not in lower: + reasons.append( + "thread state ledger missing explicit 'Do not do' guidance" + ) + return reasons + + +def _assessment( + reasons: list[str], + *, + performed: bool = True, +) -> dict[str, Any]: + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "performed": performed, + } + + +def assess_controller_handoff_comment(body: str) -> dict[str, Any]: + """Validate a standalone ``[CONTROLLER HANDOFF]`` comment body.""" + text = body or "" + if not _has_tagged_handoff(text): + return _assessment([], performed=False) + reasons: list[str] = [] + if not _MUTATION_LEDGER_RE.search(text): + reasons.append( + "controller handoff missing 'Server-side mutation ledger' section" + ) + reasons.extend(_ambiguous_term_violations(text, scoped=True)) + reasons.extend(_mutation_contradiction_reasons(text)) + return _assessment(reasons) + + +def assess_thread_state_ledger_comment(body: str) -> dict[str, Any]: + """Validate a standalone ``[THREAD STATE LEDGER]`` comment body.""" + text = body or "" + if not _has_tagged_ledger(text): + return _assessment([], performed=False) + reasons = _ledger_field_reasons(text) + reasons.extend(_ambiguous_term_violations(text, scoped=True)) + reasons.extend(_local_server_separation_reasons(text)) + return _assessment(reasons) + + +def assess_two_comment_pair( + handoff_body: str, + ledger_body: str, +) -> dict[str, Any]: + """Validate paired Gitea comments (handoff then ledger).""" + reasons: list[str] = [] + handoff = handoff_body or "" + ledger = ledger_body or "" + if _has_tagged_handoff(handoff) and not _has_tagged_ledger(ledger): + reasons.append( + "controller handoff posted without follow-up THREAD STATE LEDGER" + ) + handoff_result = assess_controller_handoff_comment(handoff) + ledger_result = assess_thread_state_ledger_comment(ledger) + reasons.extend(handoff_result.get("reasons") or []) + reasons.extend(ledger_result.get("reasons") or []) + reasons.extend(_blocked_without_gate_reasons(handoff, ledger)) + if handoff_result.get("performed") or ledger_result.get("performed"): + performed = True + else: + performed = False + return _assessment(reasons, performed=performed) + + +def assess_two_comment_workflow(report_text: str) -> dict[str, Any]: + """Validate combined final-report text containing both comment types.""" + text = report_text or "" + if not (_has_tagged_handoff(text) or _has_tagged_ledger(text)): + if _has_legacy_handoff(text): + return _assessment([], performed=False) + return _assessment([], performed=False) + + reasons: list[str] = [] + if _has_tagged_handoff(text) and not _has_tagged_ledger(text): + reasons.append( + "tagged controller handoff present without THREAD STATE LEDGER" + ) + + handoff_section = _section_after_tag(text, _HANDOFF_TAG_RE) or text + ledger_section = _section_after_tag(text, _LEDGER_TAG_RE) or "" + + reasons.extend( + (assess_controller_handoff_comment(handoff_section).get("reasons") or []) + ) + if ledger_section: + reasons.extend( + (assess_thread_state_ledger_comment(ledger_section).get("reasons") or []) + ) + reasons.extend(_blocked_without_gate_reasons(handoff_section, ledger_section)) + reasons.extend(_mutation_contradiction_reasons(handoff_section)) + reasons.extend(_local_server_separation_reasons(ledger_section)) + + return _assessment(reasons) + + +def findings_for_final_report(report_text: str) -> list[dict[str, str]]: + """Return final_report_validator findings for the two-comment workflow.""" + from final_report_validator import validator_finding + + if not (_has_tagged_handoff(report_text) or _has_tagged_ledger(report_text)): + return [] + + result = assess_two_comment_workflow(report_text) + if not result.get("reasons"): + return [] + + findings: list[dict[str, str]] = [] + for reason in result.get("reasons") or []: + severity = "block" if result.get("block") else "downgrade" + findings.append( + validator_finding( + "shared.two_comment_workflow", + severity, + "Thread State Ledger", + reason, + "add a [THREAD STATE LEDGER] after [CONTROLLER HANDOFF] " + "with precise server-side vs local state language", + ) + ) + return findings \ No newline at end of file diff --git a/webui/app.py b/webui/app.py index 99e3786..4ab38ed 100644 --- a/webui/app.py +++ b/webui/app.py @@ -14,16 +14,23 @@ from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page +from final_report_validator import FINAL_REPORT_TASK_KINDS + from webui.gated_actions import attempt_action, load_action_registry, preview_action from webui.gated_action_views import render_actions_page +from webui.audit_validator import audit_report, audit_to_dict +from webui.audit_views import render_audit_page from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict from webui.lease_views import render_leases_page from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict from webui.queue_views import render_queue_page +from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict +from webui.worktree_views import render_worktrees_page from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict from webui.runtime_views import render_runtime_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) +_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"}) def _stub_page(title: str, description: str) -> HTMLResponse: @@ -135,18 +142,56 @@ async def api_runtime(_request: Request) -> JSONResponse: return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot())) -async def audit(_request: Request) -> HTMLResponse: - return _stub_page( - "Audit", - "Report audit will accept pasted final reports and run validator previews.", +async def _parse_audit_form(request: Request) -> tuple[str, str | None]: + if request.method == "GET": + return "", None + content_type = (request.headers.get("content-type") or "").lower() + if "application/json" in content_type: + payload = await request.json() + if not isinstance(payload, dict): + return "", None + report_text = str(payload.get("report_text") or "") + task_kind = payload.get("task_kind") + return report_text, str(task_kind) if task_kind else None + form = await request.form() + report_text = str(form.get("report_text") or "") + task_kind = form.get("task_kind") + return report_text, str(task_kind) if task_kind else None + + +async def audit(request: Request) -> HTMLResponse: + report_text, task_kind = await _parse_audit_form(request) + result = None + if request.method == "POST" and report_text.strip(): + result = audit_report(report_text, task_kind=task_kind) + return HTMLResponse( + render_audit_page( + report_text=report_text, + task_kind=task_kind, + result=result, + ) ) +async def api_audit(request: Request) -> JSONResponse: + if request.method == "GET": + return JSONResponse({ + "usage": "POST JSON with report_text and optional task_kind", + "task_kinds": sorted(FINAL_REPORT_TASK_KINDS), + }) + report_text, task_kind = await _parse_audit_form(request) + if not report_text.strip(): + return JSONResponse({"error": "report_text required"}, status_code=400) + return JSONResponse(audit_to_dict(audit_report(report_text, task_kind=task_kind))) + + async def worktrees(_request: Request) -> HTMLResponse: - return _stub_page( - "Worktrees", - "Worktree hygiene will summarize branches/ session folders and cleanup risk.", - ) + snapshot = load_hygiene_snapshot() + return HTMLResponse(render_worktrees_page(snapshot)) + + +async def api_worktrees(_request: Request) -> JSONResponse: + return JSONResponse(worktree_snapshot_to_dict(load_hygiene_snapshot())) async def leases(_request: Request) -> HTMLResponse: @@ -194,6 +239,9 @@ async def api_action_attempt(request: Request) -> JSONResponse: async def method_not_allowed(request: Request, _exc: Exception) -> Response: + path = request.url.path + if path in _AUDIT_MUTATION_PATHS and request.method == "POST": + return JSONResponse({"error": "not_found"}, status_code=404) if request.method not in _READ_ONLY_METHODS: return JSONResponse( {"error": "read-only-mvp", "detail": f"{request.method} not permitted"}, @@ -219,8 +267,10 @@ def create_app() -> Starlette: Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), Route("/api/runtime", api_runtime, methods=["GET"]), - Route("/audit", audit, methods=["GET"]), + Route("/audit", audit, methods=["GET", "POST"]), + Route("/api/audit", api_audit, methods=["GET", "POST"]), Route("/worktrees", worktrees, methods=["GET"]), + Route("/api/worktrees", api_worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), Route("/actions", actions, methods=["GET"]), Route("/api/actions", api_actions, methods=["GET"]), diff --git a/webui/audit_validator.py b/webui/audit_validator.py new file mode 100644 index 0000000..d19e7ff --- /dev/null +++ b/webui/audit_validator.py @@ -0,0 +1,176 @@ +"""Final-report audit preview for the web UI (#431).""" + +from __future__ import annotations + +import re +from typing import Any + +from final_report_validator import FINAL_REPORT_TASK_KINDS, assess_final_report_validator + +_TASK_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("review_pr", re.compile(r"\b(?:review\s+pr|task\s*:\s*review|review-merge-pr)\b", re.I)), + ( + "reconcile_already_landed", + re.compile( + r"\b(?:reconcile\s+already[- ]landed|already[- ]landed\s+pr|" + r"reconcile-landed-pr)\b", + re.I, + ), + ), + ("work_issue", re.compile(r"\b(?:work[- ]issue|task\s*:\s*work|selected\s+issue\s*:)\b", re.I)), + ("issue_filing", re.compile(r"\b(?:issue\s+filing|create\s+issue|task\s*:\s*file)\b", re.I)), + ("issue_selection", re.compile(r"\b(?:issue\s+selection|select\s+next\s+issue)\b", re.I)), + ("inventory", re.compile(r"\b(?:issue\s+inventory|queue\s+inventory)\b", re.I)), +) + +_SUGGESTED_PROMPTS: dict[str, str] = { + "review_pr": ( + "Review the next eligible open PR in this project. Merge it only if every " + "gate passes. Post a final report matching review-merge-pr schema." + ), + "work_issue": ( + "Find the next eligible issue in this project, work on it only if all gates " + "pass, and create a PR when complete." + ), + "reconcile_already_landed": ( + "Reconcile the oldest eligible already-landed open PR. Post read-only audit " + "first; authorize cleanup only when required." + ), + "issue_filing": "File a new Gitea issue using the canonical issue-filing workflow.", + "issue_selection": "Build a complete open-issue inventory and select the next eligible issue.", + "inventory": "Produce a proof-backed inventory of open PRs or issues without mutating state.", +} + + +def infer_task_kind(report_text: str, explicit: str | None = None) -> str: + """Infer workflow task kind from explicit selection or report content.""" + if explicit: + normalized = explicit.strip().lower().replace(" ", "_") + if normalized in FINAL_REPORT_TASK_KINDS: + return normalized + text = report_text or "" + for kind, pattern in _TASK_KIND_PATTERNS: + if pattern.search(text): + return kind + if re.search(r"\brole\s*:\s*reviewer\b", text, re.I): + return "review_pr" + if re.search(r"\brole\s*:\s*author\b", text, re.I): + return "work_issue" + return "review_pr" + + +def _merge_findings(*result_sets: dict[str, Any]) -> list[dict[str, str]]: + seen: set[tuple[str, str, str]] = set() + merged: list[dict[str, str]] = [] + for result in result_sets: + for finding in result.get("findings") or []: + key = ( + str(finding.get("rule_id", "")), + str(finding.get("field", "")), + str(finding.get("reason", "")), + ) + if key in seen: + continue + seen.add(key) + merged.append(finding) + return merged + + +def _aggregate_result(task_kind: str, findings: list[dict[str, str]], base: dict[str, Any]) -> dict[str, Any]: + blocked = any(f.get("severity") == "block" for f in findings) + downgraded = any(f.get("severity") == "downgrade" for f in findings) + warning = any(f.get("severity") == "warning" for f in findings) + if blocked: + grade = "blocked" + elif downgraded: + grade = "downgraded" + elif warning: + grade = "warning" + else: + grade = base.get("grade") or "A" + safe_next_action = base.get("safe_next_action") or "proceed" + for finding in findings: + if finding.get("severity") in {"block", "downgrade"}: + safe_next_action = finding.get("safe_next_action") or safe_next_action + break + return { + "task_kind": task_kind, + "inferred_task_kind": task_kind, + "grade": grade, + "blocked": blocked or bool(base.get("blocked")), + "downgraded": downgraded or bool(base.get("downgraded")), + "findings": findings, + "reasons": [f"{f.get('rule_id')}: {f.get('reason')}" for f in findings], + "safe_next_action": safe_next_action, + "complete": grade == "A" and not findings, + "suggested_next_prompt": _SUGGESTED_PROMPTS.get(task_kind, ""), + "suggested_issue_comment": _build_issue_comment(findings, safe_next_action), + } + + +def _build_issue_comment(findings: list[dict[str, str]], safe_next_action: str) -> str: + if not findings: + return "" + lines = [ + "## Report audit preview (local validator)", + "", + "Automated findings from pasted final report:", + "", + ] + for finding in findings[:12]: + severity = finding.get("severity", "info") + rule_id = finding.get("rule_id", "unknown") + reason = finding.get("reason", "") + lines.append(f"- **{severity}** `{rule_id}` — {reason}") + if len(findings) > 12: + lines.append(f"- …and {len(findings) - 12} more finding(s)") + lines.extend(["", f"Safe next action: {safe_next_action}", ""]) + return "\n".join(lines) + + +def audit_report(report_text: str, *, task_kind: str | None = None) -> dict[str, Any]: + """Run composable final-report validation on pasted text.""" + text = (report_text or "").strip() + if not text: + return { + "task_kind": task_kind or "review_pr", + "inferred_task_kind": task_kind or "review_pr", + "grade": "blocked", + "blocked": True, + "downgraded": False, + "findings": [], + "reasons": ["empty report text"], + "safe_next_action": "paste a non-empty final report", + "complete": False, + "suggested_next_prompt": "", + "suggested_issue_comment": "", + } + + resolved_kind = infer_task_kind(text, task_kind) + base = assess_final_report_validator(text, resolved_kind) + findings = list(base.get("findings") or []) + + if resolved_kind == "review_pr": + from review_final_report_schema import assess_review_final_report_schema + + schema = assess_review_final_report_schema(text) + findings = _merge_findings(base, schema) + + return _aggregate_result(resolved_kind, findings, base) + + +def audit_to_dict(result: dict[str, Any]) -> dict[str, Any]: + """JSON-safe export for /api/audit.""" + return { + "task_kind": result.get("task_kind"), + "inferred_task_kind": result.get("inferred_task_kind"), + "grade": result.get("grade"), + "blocked": result.get("blocked"), + "downgraded": result.get("downgraded"), + "complete": result.get("complete"), + "safe_next_action": result.get("safe_next_action"), + "findings": result.get("findings") or [], + "reasons": result.get("reasons") or [], + "suggested_next_prompt": result.get("suggested_next_prompt") or "", + "suggested_issue_comment": result.get("suggested_issue_comment") or "", + } \ No newline at end of file diff --git a/webui/audit_views.py b/webui/audit_views.py new file mode 100644 index 0000000..1316eea --- /dev/null +++ b/webui/audit_views.py @@ -0,0 +1,139 @@ +"""HTML views for final-report audit paste (#431).""" + +from __future__ import annotations + +import html + +from final_report_validator import FINAL_REPORT_TASK_KINDS +from webui.audit_validator import audit_report +from webui.layout import render_page + +_TASK_KIND_OPTIONS = sorted(FINAL_REPORT_TASK_KINDS) + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def _render_findings(result: dict) -> str: + findings = result.get("findings") or [] + if not findings: + return '<p class="muted">No validator findings — report structure looks complete for the selected task kind.</p>' + rows = [] + for finding in findings: + rows.append( + "<tr>" + f"<td><code>{_escape(str(finding.get('rule_id', '')))}</code></td>" + f"<td>{_escape(str(finding.get('severity', '')))}</td>" + f"<td>{_escape(str(finding.get('field', '')))}</td>" + f"<td>{_escape(str(finding.get('reason', '')))}</td>" + f"<td>{_escape(str(finding.get('safe_next_action', '')))}</td>" + "</tr>" + ) + return ( + '<table class="detail audit-findings">' + "<thead><tr><th>Rule</th><th>Severity</th><th>Field</th>" + "<th>Reason</th><th>Safe next action</th></tr></thead>" + f"<tbody>{''.join(rows)}</tbody></table>" + ) + + +def _render_task_kind_select(selected: str | None) -> str: + options = ['<option value="">Auto-detect from report</option>'] + for kind in _TASK_KIND_OPTIONS: + sel = ' selected' if selected == kind else "" + options.append(f'<option value="{_escape(kind)}"{sel}>{_escape(kind)}</option>') + return f'<select id="task_kind" name="task_kind">{"".join(options)}</select>' + + +def render_audit_page( + *, + report_text: str = "", + task_kind: str | None = None, + result: dict | None = None, +) -> str: + result_html = "" + if result is not None: + grade = _escape(str(result.get("grade", ""))) + inferred = _escape(str(result.get("inferred_task_kind", ""))) + safe_next = _escape(str(result.get("safe_next_action", ""))) + suggested_prompt = result.get("suggested_next_prompt") or "" + suggested_comment = result.get("suggested_issue_comment") or "" + result_html = ( + '<section class="audit-result">' + f"<h3>Validator preview</h3>" + f'<p class="meta">Task kind: <code>{inferred}</code> · ' + f'Grade: <strong>{grade}</strong> · ' + f"Blocked: <code>{result.get('blocked')}</code> · " + f"Downgraded: <code>{result.get('downgraded')}</code></p>" + f"<p><strong>Safe next action:</strong> {safe_next}</p>" + f"{_render_findings(result)}" + ) + if suggested_prompt: + result_html += ( + "<h4>Suggested next prompt</h4>" + f'<pre class="prompt-text">{_escape(suggested_prompt)}</pre>' + ) + if suggested_comment: + result_html += ( + "<h4>Suggested issue/PR comment</h4>" + f'<pre class="prompt-text">{_escape(suggested_comment)}</pre>' + ) + result_html += "</section>" + + body = ( + "<h2>Report audit</h2>" + "<p>Paste an LLM final report for local validator preview. " + "Uses <code>final_report_validator</code> and review schema checks — " + "does not post to Gitea or store reports server-side.</p>" + '<form method="post" action="/audit" class="audit-form">' + "<label for=\"task_kind\">Task kind</label>" + f"{_render_task_kind_select(task_kind)}" + '<label for="report_text">Final report</label>' + f'<textarea id="report_text" name="report_text" rows="18" ' + f'placeholder="Paste final report markdown here…">{_escape(report_text)}</textarea>' + '<button type="submit" class="copy-btn">Run validator preview</button>' + "</form>" + f"{result_html}" + "<p><a href=\"/api/audit\">JSON API</a> (POST <code>report_text</code>, " + "optional <code>task_kind</code>)</p>" + ) + return render_page(title="Audit", body_html=body, extra_head=AUDIT_PAGE_STYLES) + + +AUDIT_PAGE_STYLES = """ +<style> + .audit-form label { + display: block; + margin: 0.75rem 0 0.35rem; + color: var(--muted); + font-size: 0.9rem; + } + .audit-form select, + .audit-form textarea { + width: 100%; + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.55rem 0.65rem; + } + .audit-form textarea { + font-family: ui-monospace, monospace; + font-size: 0.85rem; + line-height: 1.45; + resize: vertical; + } + .audit-result { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + } + table.audit-findings td:nth-child(4), + table.audit-findings td:nth-child(5) { + font-size: 0.85rem; + color: var(--muted); + } +</style> +""" \ No newline at end of file diff --git a/webui/worktree_scanner.py b/webui/worktree_scanner.py new file mode 100644 index 0000000..cf16887 --- /dev/null +++ b/webui/worktree_scanner.py @@ -0,0 +1,307 @@ +"""Local branches/ worktree hygiene scan for the web UI (#432).""" + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from typing import Any, Callable + +from issue_lock_provenance import ISSUE_LOCK_FILE +from merged_cleanup_reconcile import ( + branch_worktree_folder, + read_issue_lock, + read_local_worktree_state, +) + +_REVIEW_WORKTREE_RE = re.compile( + r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)", + re.IGNORECASE, +) + +CLASSIFICATIONS = frozenset({ + "active-pr", + "active-issue", + "dirty", + "stale-clean", + "detached-review", + "unsafe-unknown", + "orphan", +}) + +CLEANUP_PROMPT = ( + "Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. " + "Confirm the merge on remote master before any deletion; never " + "force-remove a dirty worktree. Full steps: " + "skills/llm-project-workflow/templates/worktree-cleanup.md" +) + + +@dataclass(frozen=True) +class WorktreeEntry: + rel_path: str + folder_name: str + classification: str + branch: str | None + head_sha: str | None + dirty_tracked: int + dirty_untracked: bool + detached: bool + registered_worktree: bool + notes: str + + +@dataclass(frozen=True) +class HygieneSnapshot: + repo_root: str + entries: tuple[WorktreeEntry, ...] + anomalies: tuple[str, ...] + cleanup_prompt: str + scan_error: str | None = None + + +def _repo_root() -> str: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return os.path.realpath(override) + return os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) + + +def _relative_branches_path(project_root: str, path: str) -> str: + root = os.path.normpath(project_root) + normalized = os.path.normpath(path) + if normalized.startswith(root + os.sep): + return normalized[len(root) + 1 :].replace("\\", "/") + return normalized.replace("\\", "/") + + +def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for raw in (porcelain or "").splitlines(): + line = raw.strip() + if not line: + if current: + entries.append(current) + current = {} + continue + if line.startswith("worktree "): + if current: + entries.append(current) + current = {"path": line.split(" ", 1)[1].strip()} + elif line.startswith("HEAD "): + current["head_sha"] = line.split(" ", 1)[1].strip() + elif line.startswith("branch "): + current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/") + elif line == "detached": + current["detached"] = True + if current: + entries.append(current) + return entries + + +def list_branches_directories(project_root: str) -> list[str]: + branches_root = os.path.join(project_root, "branches") + if not os.path.isdir(branches_root): + return [] + names: list[str] = [] + for entry in sorted(os.listdir(branches_root)): + if entry.startswith("."): + continue + full = os.path.join(branches_root, entry) + if os.path.isdir(full): + names.append(f"branches/{entry}") + return names + + +def _untracked_dirty(porcelain: str) -> bool: + return any(line.startswith("??") for line in (porcelain or "").splitlines()) + + +def classify_entry( + *, + rel_path: str, + worktree_record: dict[str, Any] | None, + state: dict[str, Any], + open_pr_branches: set[str], + active_lock_branch: str | None, +) -> tuple[str, str]: + record = worktree_record or {} + branch_name = (record.get("branch") or state.get("current_branch") or "").strip() + folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path + + if branch_name in open_pr_branches: + return "active-pr", "Head branch matches an open PR" + if active_lock_branch and branch_name == active_lock_branch: + return "active-issue", "Matches active issue lock branch" + + dirty_tracked = len(state.get("dirty_files") or []) + dirty_untracked = bool(state.get("dirty_untracked")) + if dirty_tracked or dirty_untracked: + return "dirty", "Local tracked or untracked changes present" + + if not record and not state.get("exists"): + return "orphan", "Directory missing on disk" + + if not record: + return "orphan", "Directory exists but is not a registered git worktree" + + if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path): + return "detached-review", "Detached HEAD in reviewer/simulation worktree" + + if state.get("exists") and state.get("clean"): + return "stale-clean", "Registered worktree with clean working tree" + + return "unsafe-unknown", "Could not classify safely — inspect manually before cleanup" + + +def _missing_preserved_anomalies( + project_root: str, + lock: dict[str, Any] | None, + entries_by_path: dict[str, WorktreeEntry], +) -> tuple[str, ...]: + anomalies: list[str] = [] + if not lock: + return tuple(anomalies) + + branch = (lock.get("branch_name") or "").strip() + issue_number = lock.get("issue_number") + worktree_path = (lock.get("worktree_path") or "").strip() + expected_rel = _relative_branches_path(project_root, worktree_path) if worktree_path else "" + if worktree_path and not os.path.isdir(worktree_path): + anomalies.append( + f"Missing preserved worktree for issue #{issue_number}: " + f"lock path {worktree_path!r} not found on disk (#404)" + ) + elif expected_rel.startswith("branches/") and expected_rel not in entries_by_path: + anomalies.append( + f"Missing preserved worktree folder {expected_rel} for locked branch {branch!r}" + ) + elif branch: + folder = f"branches/{branch_worktree_folder(branch)}" + entry = entries_by_path.get(folder) + if entry and entry.classification in {"orphan", "unsafe-unknown"}: + anomalies.append( + f"Locked branch {branch!r} maps to {folder} but classification is " + f"{entry.classification}" + ) + return tuple(anomalies) + + +def load_hygiene_snapshot( + *, + project_root: str | None = None, + worktree_porcelain: str | None = None, + open_pr_branches: set[str] | None = None, + issue_lock_path: str | None = None, + run_git: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None, +) -> HygieneSnapshot: + root = project_root or _repo_root() + lock_path = issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE + lock = read_issue_lock(lock_path) + active_lock_branch = (lock.get("branch_name") or "").strip() if lock else None + + git_run = run_git or ( + lambda args: subprocess.run(args, capture_output=True, text=True, check=False) + ) + + scan_error: str | None = None + porcelain = worktree_porcelain + if porcelain is None: + result = git_run(["git", "-C", root, "worktree", "list", "--porcelain"]) + if result.returncode != 0: + scan_error = (result.stderr or result.stdout or "git worktree list failed").strip() + porcelain = "" + else: + porcelain = result.stdout or "" + + worktrees = parse_worktree_list_porcelain(porcelain) + worktree_by_rel: dict[str, dict[str, Any]] = {} + for wt in worktrees: + rel = _relative_branches_path(root, wt.get("path") or "") + if rel.startswith("branches/"): + worktree_by_rel[rel] = wt + + open_heads = set(open_pr_branches or ()) + if not open_heads: + try: + from webui.queue_loader import load_queue_snapshot + + snapshot = load_queue_snapshot() + open_heads = { + item.extra.get("branch", "") + for item in snapshot.prs + if item.extra.get("branch") + } + except Exception: + open_heads = set() + + entries: list[WorktreeEntry] = [] + for rel_path in list_branches_directories(root): + abs_path = os.path.join(root, rel_path) + record = worktree_by_rel.get(rel_path) + state = read_local_worktree_state(abs_path) + if state.get("exists"): + status = git_run(["git", "-C", abs_path, "status", "--porcelain"]) + state = { + **state, + "dirty_untracked": _untracked_dirty(status.stdout or ""), + } + classification, note = classify_entry( + rel_path=rel_path, + worktree_record=record, + state=state, + open_pr_branches=open_heads, + active_lock_branch=active_lock_branch, + ) + entries.append( + WorktreeEntry( + rel_path=rel_path, + folder_name=rel_path.split("/", 1)[-1], + classification=classification, + branch=(record or {}).get("branch") or state.get("current_branch"), + head_sha=(record or {}).get("head_sha") or state.get("head_sha"), + dirty_tracked=len(state.get("dirty_files") or []), + dirty_untracked=bool(state.get("dirty_untracked")), + detached=bool((record or {}).get("detached")), + registered_worktree=record is not None, + notes=note, + ) + ) + + entries_by_path = {entry.rel_path: entry for entry in entries} + anomalies = _missing_preserved_anomalies(root, lock, entries_by_path) + + return HygieneSnapshot( + repo_root=root, + entries=tuple(entries), + anomalies=anomalies, + cleanup_prompt=CLEANUP_PROMPT, + scan_error=scan_error, + ) + + +def snapshot_to_dict(snapshot: HygieneSnapshot) -> dict[str, Any]: + return { + "repo_root": snapshot.repo_root, + "count": len(snapshot.entries), + "cleanup_prompt": snapshot.cleanup_prompt, + "anomalies": list(snapshot.anomalies), + "scan_error": snapshot.scan_error, + "entries": [ + { + "rel_path": entry.rel_path, + "folder_name": entry.folder_name, + "classification": entry.classification, + "branch": entry.branch, + "head_sha": entry.head_sha, + "dirty_tracked": entry.dirty_tracked, + "dirty_untracked": entry.dirty_untracked, + "detached": entry.detached, + "registered_worktree": entry.registered_worktree, + "notes": entry.notes, + } + for entry in snapshot.entries + ], + } \ No newline at end of file diff --git a/webui/worktree_views.py b/webui/worktree_views.py new file mode 100644 index 0000000..77bd23a --- /dev/null +++ b/webui/worktree_views.py @@ -0,0 +1,130 @@ +"""HTML views for branches/ worktree hygiene dashboard (#432).""" + +from __future__ import annotations + +import html + +from webui.layout import render_page +from webui.worktree_scanner import HygieneSnapshot, WorktreeEntry + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def _badge(classification: str) -> str: + return ( + f'<span class="badge badge-{_escape(classification)}">' + f"{_escape(classification)}</span>" + ) + + +def _entry_row(entry: WorktreeEntry) -> str: + branch = entry.branch or "—" + head = entry.head_sha[:12] if entry.head_sha else "—" + dirty = ( + f"{entry.dirty_tracked} tracked" + + (" + untracked" if entry.dirty_untracked else "") + if entry.dirty_tracked or entry.dirty_untracked + else "clean" + ) + return ( + "<tr>" + f"<td><code>{_escape(entry.rel_path)}</code></td>" + f"<td>{_badge(entry.classification)}</td>" + f"<td><code>{_escape(branch)}</code></td>" + f"<td><code>{_escape(head)}</code></td>" + f"<td>{_escape(dirty)}</td>" + f"<td>{'yes' if entry.detached else 'no'}</td>" + f"<td class='muted'>{_escape(entry.notes)}</td>" + "</tr>" + ) + + +WORKTREE_PAGE_SCRIPT = """ +<script> +document.querySelectorAll('.copy-cleanup-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + const node = document.getElementById('cleanup-prompt'); + if (!node) return; + try { + await navigator.clipboard.writeText(node.textContent || ''); + const prior = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(() => { btn.textContent = prior; }, 1200); + } catch (_err) { + btn.textContent = 'Copy failed'; + } + }); +}); +</script> +""" + +WORKTREE_PAGE_STYLES = """ +<style> + table.worktrees td:nth-child(7) { font-size: 0.85rem; } + .anomaly { + margin: 1rem 0; + padding: 0.75rem 0.9rem; + border: 1px solid #7a3b3b; + border-radius: 8px; + background: rgba(122, 59, 59, 0.15); + color: #f0c4c4; + } + .badge-active-pr { color: #9ec8f0; border-color: #3d5f7a; } + .badge-active-issue { color: #8fd19e; border-color: #3d6b4a; } + .badge-dirty { color: #e0c27a; border-color: #6b5730; } + .badge-stale-clean { color: #c9b8e8; border-color: #5a4a78; } + .badge-detached-review { color: #9ec8f0; border-color: #3d5f7a; } + .badge-unsafe-unknown { color: #f0a8a8; border-color: #7a3b3b; } + .badge-orphan { color: #f0a8a8; border-color: #7a3b3b; } +</style> +""" + + +def render_worktrees_page(snapshot: HygieneSnapshot) -> str: + error_block = "" + if snapshot.scan_error: + error_block = ( + '<div class="stub"><p><strong>Partial scan:</strong> ' + f"{_escape(snapshot.scan_error)}</p></div>" + ) + + anomaly_block = "" + if snapshot.anomalies: + items = "".join(f"<li>{_escape(text)}</li>" for text in snapshot.anomalies) + anomaly_block = ( + '<section class="anomaly"><h3>Missing preserved worktree anomalies</h3>' + f"<ul>{items}</ul></section>" + ) + + if snapshot.entries: + rows = "".join(_entry_row(entry) for entry in snapshot.entries) + table = ( + "<table class='registry worktrees'>" + "<thead><tr>" + "<th>Path</th><th>Class</th><th>Branch</th><th>HEAD</th>" + "<th>Dirty</th><th>Detached</th><th>Notes</th>" + "</tr></thead>" + f"<tbody>{rows}</tbody></table>" + ) + else: + table = "<p class='muted'>No directories under <code>branches/</code>.</p>" + + body = ( + "<h2>Worktree hygiene</h2>" + "<p>Read-only scan of local <code>branches/</code> worktrees. " + "No deletion actions — copy the canonical cleanup prompt when merge is confirmed.</p>" + f"{error_block}" + f"{anomaly_block}" + f"<p class='meta'>Repo root: <code>{_escape(snapshot.repo_root)}</code> · " + f"entries: {len(snapshot.entries)}</p>" + f"{table}" + "<h3>Canonical cleanup prompt</h3>" + f'<pre class="prompt-text" id="cleanup-prompt">{_escape(snapshot.cleanup_prompt)}</pre>' + '<button type="button" class="copy-btn copy-cleanup-btn">Copy cleanup prompt</button>' + "<p><a href=\"/api/worktrees\">JSON API</a></p>" + f"{WORKTREE_PAGE_STYLES}" + f"{WORKTREE_PAGE_SCRIPT}" + ) + return render_page(title="Worktrees", body_html=body) \ No newline at end of file diff --git a/workflow_skill.py b/workflow_skill.py new file mode 100644 index 0000000..22e1551 --- /dev/null +++ b/workflow_skill.py @@ -0,0 +1,160 @@ +"""Canonical workflow skill naming and preflight for multi-runtime agents (#551). + +Controller prompts and Claude historically require ``gitea-workflow``. The +portable in-repo skill is ``skills/llm-project-workflow``. Codex must resolve +the same canonical names so sessions do not proceed without the workflow wall. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +# Canonical names that all runtimes (Claude, Codex, Gemini, MCP inventory) +# must treat as the same workflow router skill. +CANONICAL_WORKFLOW_SKILL_NAMES = ( + "gitea-workflow", + "llm-project-workflow", + "git-pr-workflows", +) + +# Primary checked-in skill path (portable source of truth). +REPO_SKILL_REL_PATH = "skills/llm-project-workflow/SKILL.md" +# In-repo alias directory so scanners that look for skills/gitea-workflow work. +REPO_ALIAS_SKILL_REL_PATH = "skills/gitea-workflow/SKILL.md" + +CODEX_SKILLS_ENV = "CODEX_HOME" +DEFAULT_CODEX_SKILLS = os.path.expanduser("~/.codex/skills") + + +def default_codex_skills_dir() -> str: + home = (os.environ.get(CODEX_SKILLS_ENV) or "").strip() + if home: + return os.path.join(os.path.expanduser(home), "skills") + return DEFAULT_CODEX_SKILLS + + +def resolve_repo_skill_path(project_root: str) -> Path: + return Path(project_root) / REPO_SKILL_REL_PATH + + +def resolve_repo_alias_skill_path(project_root: str) -> Path: + return Path(project_root) / REPO_ALIAS_SKILL_REL_PATH + + +def normalize_skill_name(name: str | None) -> str: + return (name or "").strip().lower().replace("_", "-") + + +def is_canonical_workflow_skill_name(name: str | None) -> bool: + return normalize_skill_name(name) in CANONICAL_WORKFLOW_SKILL_NAMES + + +def assess_workflow_skill_presence( + project_root: str, + *, + codex_skills_dir: str | None = None, +) -> dict[str, Any]: + """Return presence proof for the canonical workflow skill. + + Fail-closed when the in-repo skill file is missing. Codex mount is advisory + in the report (operators install via script) but ``codex_skill_mounted`` is + explicit so preflight can BLOCK when required. + """ + root = (project_root or "").strip() + reasons: list[str] = [] + repo_path = resolve_repo_skill_path(root) if root else None + alias_path = resolve_repo_alias_skill_path(root) if root else None + repo_present = bool(repo_path and repo_path.is_file()) + alias_present = bool(alias_path and alias_path.is_file()) + + if not root: + reasons.append("project_root not provided (fail closed)") + elif not repo_present: + reasons.append( + f"canonical workflow skill missing at {REPO_SKILL_REL_PATH} " + "(fail closed — do not mutate without workflow wall)" + ) + + codex_dir = Path(codex_skills_dir or default_codex_skills_dir()) + codex_mounts: dict[str, bool] = {} + for name in CANONICAL_WORKFLOW_SKILL_NAMES: + skill_md = codex_dir / name / "SKILL.md" + codex_mounts[name] = skill_md.is_file() + codex_any = any(codex_mounts.values()) + + blocked = bool(reasons) + return { + "canonical_names": list(CANONICAL_WORKFLOW_SKILL_NAMES), + "primary_name": "gitea-workflow", + "portable_name": "llm-project-workflow", + "repo_skill_rel_path": REPO_SKILL_REL_PATH, + "repo_skill_present": repo_present, + "repo_alias_rel_path": REPO_ALIAS_SKILL_REL_PATH, + "repo_alias_present": alias_present, + "codex_skills_dir": str(codex_dir), + "codex_skill_mounts": codex_mounts, + "codex_skill_mounted": codex_any, + "workflow_skill_ready": repo_present, + "blocked": blocked, + "block_reason": reasons[0] if reasons else None, + "reasons": reasons, + "safe_next_action": ( + "Install or repair skills/llm-project-workflow/SKILL.md in the " + "repo; run scripts/install-codex-workflow-skill.sh for Codex; " + "call mcp_list_project_skills and load gitea-workflow / " + "llm-project-workflow before any git/Gitea mutation." + if blocked or not codex_any + else "Load gitea-workflow or llm-project-workflow, then proceed." + ), + } + + +def workflow_skill_registry_entries() -> dict[str, dict[str, Any]]: + """Shared skill metadata for mcp_list_project_skills / guides.""" + shared_steps = [ + "Call mcp_list_project_skills and confirm gitea-workflow (or " + "llm-project-workflow) is listed.", + "Call mcp_check_workflow_skill_preflight and stop if blocked.", + "Load skills/llm-project-workflow/SKILL.md (or the gitea-workflow " + "alias) and route to the matching workflow file for the task mode.", + "Do not perform git or Gitea mutations until the workflow is loaded.", + "Codex: ensure ~/.codex/skills/gitea-workflow -> repo skill via " + "scripts/install-codex-workflow-skill.sh.", + ] + notes = ( + f"Canonical names: {', '.join(CANONICAL_WORKFLOW_SKILL_NAMES)}. " + f"Portable source: {REPO_SKILL_REL_PATH}. " + "Controller prompts may say gitea-workflow; Codex and MCP inventory " + "must resolve the same skill." + ) + base = { + "description": ( + "Canonical Gitea/LLM project workflow router: pick task mode, " + "load the matching workflow, enforce isolation, emit the final " + "report schema." + ), + "when_to_use": ( + "At the start of every author, review, merge, reconcile, or " + "issue-filing session — before any mutation." + ), + "required_operations": ["gitea.read"], + "status": "available", + "notes": notes, + "steps": shared_steps, + "repo_path": REPO_SKILL_REL_PATH, + "canonical": True, + } + return { + "gitea-workflow": dict(base), + "llm-project-workflow": dict(base), + "git-pr-workflows": { + **dict(base), + "description": ( + "Legacy alias for the canonical Gitea/LLM project workflow " + "router (same as gitea-workflow / llm-project-workflow)." + ), + "notes": notes + " Prefer gitea-workflow in new prompts.", + }, + }