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/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 1e0366b..f5d8736 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 @@ -408,6 +413,13 @@ 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. +## 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 +646,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 +704,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.` @@ -738,6 +755,27 @@ merger handoff. - **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.` - **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.` +### Post-merge merged cleanup ownership (#523) + +Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not +author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run +`gitea_reconcile_merged_cleanups`. + +- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` / + `reconciliation_cleanup`). +- **Namespace:** reconciler MCP server; stable control checkout is allowed for + this role (branches-only author guard does not apply). +- **Steps:** + 1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`. + 2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`. + 3. Execute only after audit/authorization gates when remote branch delete or + worktree removal is required (`dry_run=False`, `execute_confirmed=True`, + and `gitea.branch.delete` when deleting remotes). +- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged + closed PRs must not be cleaned. +- **Reports:** label cleanup actions as reconciler cleanup (not author mutation). +- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.` + ### Stop on blocker - **Any profile.** If a required gate cannot be satisfied — identity @@ -758,7 +796,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 | @@ -808,6 +846,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, @@ -840,6 +899,55 @@ 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. +## 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, @@ -951,6 +1059,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. @@ -961,6 +1070,21 @@ When posting a Canonical Thread Handoff after a binding blocker: - [`credential-isolation.md`](credential-isolation.md) — credential handling. - [`release-workflows.md`](release-workflows.md) — release/merge workflow. - [`../README.md`](../README.md) — canonical config, thin launchers, the menu. +- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494). + +## Canonical state handoff ledger (#494) + +Gitea comments and final reports must make continuation obvious without chat +history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for: + +- discussion → issue → PR → review → merge → reconcile lifecycle +- templates for discussion, issue, PR, and queue-controller state comments +- final-report requirements: Current status, Next actor, Next action, Next prompt +- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial + exceptions) + +Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired +through `assess_final_report_validator`. ## PR-only queue cleanup mode (#390) 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/mcp-menu.md b/docs/mcp-menu.md index 0b028f9..e028a40 100644 --- a/docs/mcp-menu.md +++ b/docs/mcp-menu.md @@ -41,7 +41,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with |--------|-------------| | Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | | Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | -| Reviewer workflow prompts | PR review prompt (review-only; no merge). | +| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). | | Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | | Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. | | Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. | 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/state-handoff-ledger.md b/docs/state-handoff-ledger.md new file mode 100644 index 0000000..fa0ce76 --- /dev/null +++ b/docs/state-handoff-ledger.md @@ -0,0 +1,86 @@ +# Canonical state handoff ledger (#494) + +Gitea is the system of record for continuation. Every discussion, issue, and PR +should answer: current state, last proof, who acts next, and the exact prompt for +the next role. + +## Lifecycle + +1. Controller opens a discussion. +2. Discussion accumulates substantive comments (default minimum: five). +3. Controller posts a discussion summary when ready. +4. Controller creates linked issues from the summary. +5. Author locks issue, implements, opens PR. +6. Reviewer reviews at current head. +7. Merger merges after formal approval. +8. Reconciler closes superseded or already-landed PRs. +9. Issue/PR/discussion state comments make the next action obvious at every step. + +## Discussion rules + +- Do **not** convert a discussion into issues until it has at least **five + substantive comments**, unless the discussion state comment marks + `URGENCY: urgent` or `URGENCY: trivial`. +- Substantive comment types: proposal, risk/concern, acceptance criteria, + implementation approach, dependency/sequence, summary. +- Before issue creation, post a **discussion summary** with decision, issues to + create, non-goals, unresolved questions, and next prompt. +- Created issues must link back to the discussion; the discussion must link + forward to created issues. + +## State comment templates + +Use `state_handoff_ledger.py` helpers or copy the canonical blocks: + +- `render_discussion_state_comment(...)` +- `render_discussion_summary_comment(...)` +- `render_issue_state_comment(...)` +- `render_pr_state_comment(...)` +- `render_queue_controller_report(...)` + +Post state comments as the **latest canonical update** on the object. Do not +bury state inside PR bodies only. + +## Final report requirements + +Every final report must include a `Controller Handoff` section with: + +- **Current status** — live state after this session +- **Next actor** — `author`, `reviewer`, `merger`, `reconciler`, or `controller` +- **Next action** — one imperative step +- **Next prompt** — ready-to-paste prompt for the next role + +`assess_final_report_next_action_handoff` and +`assess_contradictory_state_handoff` enforce these fields and reject +contradictory claims (for example, ready-to-merge without approval, issue done +without PR proof, discussion complete without summary). + +## Queue controller selection (priority order) + +1. Merge clean approved PRs at current head. +2. Review PRs needing review. +3. Reconcile superseded or already-landed duplicates. +4. Continue blocked-but-now-unblocked issues. +5. Create issues from completed discussions. +6. Start new author work only when higher-priority queue items are clear. + +For each candidate object, read the **latest canonical state comment** before +choosing an action. Output the exact next-role prompt in the controller report. + +## Example workflow states + +| State | Next actor | Typical next action | +|-------|------------|---------------------| +| Discussion needs more comments | controller | Facilitate discussion until five substantive comments or urgent/trivial exception | +| Issue ready for author | author | Lock issue and implement in `branches/` worktree | +| PR needs review | reviewer | Review at pinned head in reviewer worktree | +| PR approved at head | merger | Merge with merger profile after eligibility proof | +| PR superseded | reconciler | Close duplicate/already-landed PR with proof | +| Issue blocked | controller | Post issue state comment with blockers and next prompt | + +## Non-goals + +- Chat history is not the source of truth. +- Do not weaken author/reviewer/merger/reconciler separation. +- Do not replace Gitea issues, PRs, or canonical workflow files under + `skills/llm-project-workflow/`. \ No newline at end of file diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 658a840..bf87247 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -43,7 +43,8 @@ Optional environment variables: | `/api/projects` | JSON registry export | | `/prompts` | Prompt library with per-prompt copy buttons (#428) | | `/api/prompts` | JSON prompt export with workflow hashes | -| `/runtime` | Stub — MCP runtime health (#430) | +| `/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` | Lease and collision visibility (#433) | @@ -111,8 +112,17 @@ in-progress claim inventory (#268), reviewer PR lease comments when present and duplicate local branches per issue. Links to collision-history backend issues (#267, #268, #400, #407) are included. No lease acquire/release from UI. +## Runtime health (#430) + +`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry +project: active profile and role kind, authenticated identity (when credentials +are available), config model/mode, local vs remote `master` SHA sync, shell +health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the +checkout is behind merged safety-gate changes. Restart guidance links to #420; +no tokens or MCP restart actions are exposed. + ## Tests ```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_lease_visibility.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/final_report_validator.py b/final_report_validator.py index a807ff9..31f847c 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -13,6 +13,7 @@ from typing import Any, Callable import issue_acceptance_gate import issue_lock_provenance +import reviewer_handoff_consistency 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 ( @@ -24,10 +25,12 @@ from review_proofs import ( assess_review_mutation_final_report, assess_validation_report, ) +from state_handoff_ledger import assess_state_handoff_ledger_report 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", @@ -39,6 +42,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", @@ -47,6 +52,7 @@ _TASK_KIND_ALIASES = { _HANDOFF_ROLE_BY_TASK = { "review_pr": "review", + "merge_pr": "merger", "reconcile_already_landed": None, "author_issue": "author", "work_issue": "author", @@ -194,6 +200,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, @@ -335,6 +358,38 @@ def _rule_shared_controller_handoff( ) +def _rule_shared_state_handoff_next_action(report_text: str) -> list[dict[str, str]]: + result = assess_state_handoff_ledger_report(report_text) + if result.get("complete"): + return [] + findings: list[dict[str, str]] = [] + next_action = result.get("next_action") or {} + contradictions = result.get("contradictions") or {} + if next_action.get("block"): + findings.extend( + _findings_from_reasons( + "shared.state_handoff_next_action", + next_action.get("reasons") or [], + field="Next action handoff", + severity="block", + safe_next_action=next_action.get("safe_next_action") + or "add next-action handoff fields to Controller Handoff", + ) + ) + if contradictions.get("block"): + findings.extend( + _findings_from_reasons( + "shared.state_handoff_contradiction", + contradictions.get("reasons") or [], + field="State handoff", + severity="block", + safe_next_action=contradictions.get("safe_next_action") + or "resolve contradictory state claims before submission", + ) + ) + return findings + + def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]: result = assess_email_disclosure(report_text) if result.get("proven"): @@ -348,6 +403,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, *, @@ -565,6 +657,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 @@ -715,6 +828,25 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st ] +def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, str]]: + from premerge_baseline_proof import assess_premerge_baseline_proof + + result = assess_premerge_baseline_proof(report_text) + if not result.get("block"): + return [] + return _findings_from_reasons( + "reviewer.premerge_baseline_proof", + result.get("reasons") or [], + field="Pre-merge baseline proof", + severity="block", + safe_next_action=result.get("safe_next_action") + or ( + "prove the failure on the PR pre-merge base commit or a known-failure " + "record predating the PR; current-master reproduction is not baseline proof" + ), + ) + + def _rule_reviewer_validation_status_vocabulary( report_text: str, *, @@ -1059,6 +1191,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, *, @@ -1225,6 +1376,22 @@ def _rule_reviewer_review_mutation( ) +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 @@ -1273,16 +1440,23 @@ _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_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_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, @@ -1294,12 +1468,14 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_reviewer_validation_structured, _rule_reviewer_linked_issue, _rule_reviewer_baseline_on_failure, + _rule_reviewer_premerge_baseline_proof, _rule_reviewer_validation_status_vocabulary, _rule_reviewer_main_checkout_baseline, _rule_reviewer_main_checkout_path, _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, @@ -1308,9 +1484,22 @@ _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_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, *_SHARED_CLEANUP_PROOF_RULES, _rule_reconcile_stale_author_fields, @@ -1318,6 +1507,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_reconcile_linked_issue_live, _rule_reconcile_close_proof, _rule_reconcile_pagination_proof, + _rule_reviewer_premerge_baseline_proof, _rule_reviewer_git_fetch_readonly, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, @@ -1325,33 +1515,44 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { ], "author_issue": [ _rule_shared_controller_handoff, + _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_vague_mutations_none, ], "work_issue": [ _rule_shared_controller_handoff, + _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_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, ], "issue_filing": [ _rule_shared_controller_handoff, + _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], "inventory": [ _rule_shared_controller_handoff, + _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reconcile_pagination_proof, ], "issue_selection": [ _rule_shared_controller_handoff, + _rule_shared_state_handoff_next_action, _rule_shared_email_disclosure, + *_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 ed650b5..98d656a 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)" ) @@ -562,7 +562,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 +818,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,8 +828,17 @@ 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 conflict_fix_classification # noqa: E402 import native_mcp_preference # noqa: E402 +import master_parity_gate # noqa: E402 + +# Master-parity baseline (#420): the commit this server process started at. +# Captured once so that, at mutation time, we can detect when the on-disk +# master has advanced past the running code and fail closed until restart. +# 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 @@ -1168,6 +1178,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 {} @@ -1500,6 +1612,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, @@ -1513,6 +1630,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. @@ -1544,6 +1667,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 @@ -1578,7 +1735,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]: @@ -1987,7 +2166,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: @@ -2002,7 +2197,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: @@ -2272,13 +2497,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 " @@ -2302,21 +2537,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 @@ -3268,6 +3505,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( @@ -4032,6 +4276,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 = { @@ -4048,6 +4300,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: @@ -4217,7 +4473,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)) @@ -4737,6 +4993,34 @@ def gitea_reconcile_merged_cleanups( ) delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete") + + # #534: discover reviewer scratch trees and active leases for those PRs. + scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees( + PROJECT_ROOT + ) + active_reviewer_leases: dict[int, bool] = {} + pr_states: dict[int, dict] = {} + for scratch in scratch_candidates: + pr_num = int(scratch["pr_number"]) + if pr_num in active_reviewer_leases: + continue + try: + comments = api_get_all(f"{base}/issues/{pr_num}/comments", auth) + except Exception: + comments = [] + lease = reviewer_pr_lease.find_active_reviewer_lease( + comments, pr_number=pr_num + ) + active_reviewer_leases[pr_num] = bool(lease) + try: + pr_live = api_request("GET", f"{base}/pulls/{pr_num}", auth) + except Exception: + pr_live = {} + pr_states[pr_num] = { + "merged": bool((pr_live or {}).get("merged") or (pr_live or {}).get("merged_at")), + "closed": (pr_live or {}).get("state") == "closed", + } + report = merged_cleanup_reconcile.build_reconciliation_report( project_root=PROJECT_ROOT, closed_prs=merged_closed, @@ -4744,6 +5028,9 @@ 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, ) if dry_run: @@ -4783,10 +5070,20 @@ 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}) + for scratch in report.get("reviewer_scratch_entries") or []: + if not scratch.get("safe_to_remove_worktree"): + continue + result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree( + PROJECT_ROOT, scratch.get("worktree_path") or "" + ) + actions.append({"action": "remove_reviewer_scratch_worktree", **result}) + report["dry_run"] = False report["executed"] = True report["actions"] = actions @@ -5207,6 +5504,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", @@ -5547,15 +5850,41 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool: return False +def _current_master_parity() -> dict: + """Assess this process's code against the on-disk master HEAD (#420).""" + current_head = master_parity_gate.read_git_head(PROJECT_ROOT) + return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head) + + +def _master_parity_block(op: str) -> list[str]: + """Fail-closed staleness reasons for a mutation *op* (#420). + + Read-only operations (``gitea.read``) are never blocked: a stale server can + still be inspected. Any other (mutating) operation is refused when the + on-disk master has definitively advanced past the running code, since newly + merged capability gates would not yet be loaded in memory. + """ + if op == "gitea.read": + return [] + return master_parity_gate.parity_block_reasons(_current_master_parity()) + + def _profile_operation_gate(op: str) -> list[str]: - """Profile permission check for a single gated operation (#126, #216). + """Profile permission check for a single gated operation (#126, #216, #420). Issue discussion comments are gated separately from the gitea.pr.* review/merge family: listing requires ``gitea.read``, creating requires ``gitea.issue.comment``. Closing a PR requires the distinct ``gitea.pr.close`` (#216). Returns a list of block reasons (empty = allowed); an unreadable profile fails closed. + + A mutating operation is additionally refused when the server code is stale + relative to master (#420), so a long-running process cannot bypass a + capability gate that has since been merged. """ + stale_reasons = _master_parity_block(op) + if stale_reasons: + return stale_reasons try: profile = get_profile() except Exception as exc: @@ -6444,6 +6773,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" @@ -6472,26 +6810,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" @@ -7009,12 +7349,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 " @@ -7659,6 +8007,24 @@ def gitea_get_runtime_context( PROJECT_ROOT), } + parity = _current_master_parity() + result["master_parity"] = { + "in_parity": parity["in_parity"], + "stale": parity["stale"], + "restart_required": parity["restart_required"], + "startup_head": parity["startup_head"], + "current_head": parity["current_head"], + "summary": master_parity_gate.format_parity(parity), + "mutation_gate_enforced": not master_parity_gate.gate_disabled(), + } + if parity["stale"] and not master_parity_gate.gate_disabled(): + safe_next_action = ( + "Server code is stale relative to master; restart the Gitea MCP " + "server to load current capability gates before mutating. " + f"({master_parity_gate.format_parity(parity)})" + ) + result["safe_next_action"] = safe_next_action + if reveal and h: result["server"] = gitea_url(h, "").rstrip("/") @@ -7666,6 +8032,43 @@ def gitea_get_runtime_context( @mcp.tool() +def gitea_assess_master_parity( + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Read-only: is the running server code in parity with the on-disk master? + + The MCP server loads its capability-gate code into memory at startup; + ``master`` advancing (e.g. a newly merged security gate) does not take + effect until the process restarts. This tool compares the commit the + process started at against the current workspace ``HEAD`` and reports + whether a restart is required before mutations are safe again (#420). + + Never mutates and makes no network calls. Read-only operations are never + blocked by staleness; only mutating operations fail closed while stale. + + Returns: + dict with 'in_parity', 'stale', 'restart_required', 'startup_head', + 'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a + 'report' recovery payload when stale. + """ + parity = _current_master_parity() + enforced = not master_parity_gate.gate_disabled() + out = { + "in_parity": parity["in_parity"], + "stale": parity["stale"], + "restart_required": parity["restart_required"], + "determinable": parity["determinable"], + "startup_head": parity["startup_head"], + "current_head": parity["current_head"], + "mutation_gate_enforced": enforced, + "summary": master_parity_gate.format_parity(parity), + "reasons": parity["reasons"], + "process_root": PROJECT_ROOT, + } + if parity["stale"] and enforced: + out["report"] = master_parity_gate.parity_report(parity) + return out def gitea_record_pre_review_command( command: str, cwd: str | None = None, @@ -7973,6 +8376,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, @@ -7982,8 +8413,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" @@ -8048,15 +8489,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." @@ -8064,10 +8498,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( @@ -8092,8 +8535,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"}): @@ -8209,6 +8654,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, @@ -8476,6 +8955,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. @@ -8487,11 +8967,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) @@ -8517,6 +9002,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. @@ -8527,6 +9013,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. @@ -8535,7 +9022,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) @@ -9079,6 +9566,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/master_parity_gate.py b/master_parity_gate.py new file mode 100644 index 0000000..efb7314 --- /dev/null +++ b/master_parity_gate.py @@ -0,0 +1,168 @@ +"""Master-parity staleness gate (#420). + +The Gitea MCP server loads its capability-gate code and workflow logic into +memory when the process starts. When ``master`` advances -- for example a newly +merged security gate such as the branch-delete capability gate (#408/#410) -- +the running process keeps executing the *old* code until it is restarted. A +stale server can therefore still perform a mutation that the updated codebase +would forbid. + +Runtime profile/config data is already read live from disk on every call +(``gitea_config.load_config`` re-reads the JSON file each time), so profile +``allowed_operations`` changes take effect immediately without a restart. The +gap this module closes is *code* parity: it captures the server process's +source-tree commit at startup and detects, at mutation time, when the on-disk +``master`` HEAD has advanced past it. Detected staleness fails closed with a +restart-required recovery report, while read-only operations stay allowed so a +stale server can still be inspected. + +The core assessment is pure -- callers inject the observed HEAD SHAs -- so the +logic is fully unit-testable without a git checkout. +""" + +from __future__ import annotations + +import os +import subprocess + +# Environment escape hatches (ops + tests): +# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open). +# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests. +ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE" +ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD" + + +def read_git_head(root: str) -> str | None: + """Return the current ``HEAD`` commit SHA of *root*, or ``None``. + + ``None`` means the SHA could not be determined (not a git checkout, git + unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD`` + takes precedence so the gate can be exercised deterministically. + """ + forced = os.environ.get(ENV_TEST_CURRENT_HEAD) + if forced is not None: + return forced.strip() or None + if not root: + return None + try: + res = subprocess.run( + ["git", "-C", root, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + except Exception: + return None + if res.returncode != 0: + return None + return (res.stdout or "").strip() or None + + +def capture_startup_parity(root: str, head: str | None = None) -> dict: + """Capture the process source-tree baseline once at server startup. + + *head* may be injected (tests); otherwise it is read from *root*. The result + is an opaque baseline handed back to :func:`assess_master_parity`. + """ + startup_head = head if head is not None else read_git_head(root) + return {"root": root, "startup_head": startup_head} + + +def _short(sha: str | None) -> str: + return sha[:12] if sha else "unknown" + + +def assess_master_parity(startup: dict | None, current_head: str | None) -> dict: + """Compare the startup baseline against the current on-disk ``HEAD``. + + Pure: both HEADs are supplied by the caller. Returns a structured result: + + - ``in_parity`` -- server code matches the on-disk master (or parity + could not be determined, which is not treated as stale). + - ``stale`` -- the on-disk master has definitively advanced past the + running process. + - ``restart_required`` -- alias of ``stale``; the recovery action. + - ``determinable`` -- whether both HEADs were known well enough to compare. + - ``startup_head`` / ``current_head`` / ``reasons``. + """ + startup_head = (startup or {}).get("startup_head") + reasons: list[str] = [] + + if startup_head is None: + reasons.append( + "startup commit was not captured; code parity cannot be enforced") + return _result(True, False, False, startup_head, current_head, reasons) + + if current_head is None: + reasons.append( + "current workspace HEAD could not be read; code parity cannot be " + "enforced") + return _result(True, False, False, startup_head, current_head, reasons) + + if startup_head == current_head: + return _result(True, False, True, startup_head, current_head, reasons) + + reasons.append( + f"MCP server started at commit {_short(startup_head)} but the workspace " + f"master is now {_short(current_head)}; restart the server to load the " + f"current capability gates") + return _result(False, True, True, startup_head, current_head, reasons) + + +def _result(in_parity, stale, determinable, startup_head, current_head, reasons): + return { + "in_parity": in_parity, + "stale": stale, + "restart_required": stale, + "determinable": determinable, + "startup_head": startup_head, + "current_head": current_head, + "reasons": list(reasons), + } + + +def gate_disabled() -> bool: + """Whether the parity gate is disabled by env escape hatch.""" + return bool((os.environ.get(ENV_DISABLE) or "").strip()) + + +def parity_block_reasons(assessment: dict) -> list[str]: + """Block reasons for a mutation gate (empty when the mutation may proceed). + + A disabled gate or an in-parity / non-determinable assessment yields no + reasons; only a definitively stale server blocks. + """ + if gate_disabled(): + return [] + if assessment.get("stale"): + return list(assessment.get("reasons") or + ["server code is stale relative to master (fail closed)"]) + return [] + + +def parity_report(assessment: dict) -> dict: + """Structured stale-server report for permission-block payloads.""" + return { + "kind": "server_stale", + "restart_required": True, + "startup_head": assessment.get("startup_head"), + "current_head": assessment.get("current_head"), + "reasons": list(assessment.get("reasons") or []), + "recovery": [ + "The running MCP server is executing code older than the current " + "master and may not enforce newly merged capability gates.", + "Restart the Gitea MCP server so it reloads master's capability " + "gates and execution profiles before retrying the mutation.", + ], + } + + +def format_parity(assessment: dict) -> str: + """One-line human summary for logs / runtime context.""" + if assessment.get("stale"): + return (f"STALE: started {_short(assessment.get('startup_head'))}, " + f"master now {_short(assessment.get('current_head'))} " + f"(restart required)") + if not assessment.get("determinable"): + return "parity indeterminate (baseline or current HEAD unknown)" + return f"in parity at {_short(assessment.get('current_head'))}" diff --git a/mcp-menu.sh b/mcp-menu.sh index 4d124f2..215f017 100755 --- a/mcp-menu.sh +++ b/mcp-menu.sh @@ -115,7 +115,15 @@ Workflow: } show_reviewer_prompts() { - print_prompt_block "Reviewer — PR review" \ + while true; do + printf '\n--- Reviewer workflow prompts ---\n' + printf ' 1) Standard PR review\n' + printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n' + printf ' 0) Back\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) + print_prompt_block "Reviewer — PR review" \ "You are the REVIEWER session for <org>/<repo>. Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode. @@ -126,6 +134,35 @@ Workflow: 3. gitea_view_pr, validate scope, run required checks in the correct worktree. 4. gitea_review_pr with approve, request-changes, or comment as warranted. 5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs." + ;; + 2) + print_prompt_block "Reviewer — skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" \ +"You are the REVIEWER session for <org>/<repo>. + +Goal: review PR #<P> only far enough to determine whether the current head already has a non-stale REQUEST_CHANGES verdict. If it does, do NOT submit another terminal review mutation — produce an author handoff and stop. + +Workflow: +1. gitea_view_pr; pin the current head SHA. +2. Load prior reviews; find the latest REQUEST_CHANGES and the head SHA it was bound to. +3. If that REQUEST_CHANGES is still bound to the current head (non-stale), do not submit another terminal review mutation. Confirm the binding and summarize the blockers. +4. Run only the diagnostics needed to produce a useful author handoff — no full re-review. +5. Duplicate/supersession check: confirm no newer canonical PR or superseding head changes the decision. +6. Stop — no new review mutation. + +Return: +- selected PR and issue +- current head SHA +- prior REQUEST_CHANGES proof (review id + bound head SHA) +- duplicate/supersession analysis +- validation summary +- why no new review mutation was submitted +- corrected mutation ledger, including local worktree create/remove if used +- author-ready fix prompt" + ;; + 0) return ;; + *) printf 'Invalid choice.\n' ;; + esac + done } show_merger_prompts() { 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 bf81258..4a7299a 100644 --- a/merged_cleanup_reconcile.py +++ b/merged_cleanup_reconcile.py @@ -17,6 +17,12 @@ 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)?$", + re.IGNORECASE, +) def extract_linked_issue(title: str, body: str) -> int | None: @@ -36,6 +42,302 @@ 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( + ["git", "-C", project_root, "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if res.returncode != 0: + return [] + + records: list[dict[str, str | None]] = [] + current: dict[str, str | None] = {} + for line in res.stdout.splitlines(): + line = line.strip() + if not line: + if current.get("worktree"): + records.append(current) + current = {} + continue + if line.startswith("worktree "): + if current.get("worktree"): + records.append(current) + current = {"worktree": line[9:].strip(), "branch": None, "head": None} + elif line.startswith("branch ") and current: + ref = line[7:].strip() + current["branch"] = ( + ref[11:] if ref.startswith("refs/heads/") else ref + ) + elif line.startswith("HEAD ") and current: + current["head"] = line[5:].strip() + elif line == "detached" and current: + current["branch"] = None + if current.get("worktree"): + records.append(current) + return records + + +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 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()) + if not match: + return None + return int(match.group("pr")) + + +def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]: + """Discover detached reviewer scratch worktrees under ``branches/`` (#534). + + Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only. + These are never treated as author worktree paths. + """ + root = os.path.realpath(project_root) + branches_root = os.path.realpath(os.path.join(root, "branches")) + found: list[dict[str, Any]] = [] + for record in _git_worktree_porcelain(project_root): + path = record.get("worktree") + if not path: + continue + real = os.path.realpath(path) + parent = os.path.dirname(real) + if parent != branches_root: + continue + folder = os.path.basename(real) + pr_number = parse_reviewer_scratch_folder(folder) + if pr_number is None: + continue + found.append( + { + "pr_number": pr_number, + "worktree_path": real, + "folder_name": folder, + "current_branch": record.get("branch"), + "head_sha": record.get("head"), + "worktree_kind": "reviewer_scratch", + } + ) + return found + + +def assess_reviewer_scratch_cleanup( + *, + pr_number: int, + worktree_path: str, + folder_name: str, + pr_merged: bool, + pr_closed: bool, + worktree_state: dict[str, Any], + active_reviewer_lease: bool, +) -> dict[str, Any]: + """Assess whether a reviewer scratch worktree is safe to remove (#534).""" + reasons: list[str] = [] + exists = bool(worktree_state.get("exists")) + if not (pr_merged or pr_closed): + reasons.append("associated PR is still open") + if not exists: + reasons.append("reviewer scratch worktree not present") + if exists and worktree_state.get("clean") is False: + dirty = worktree_state.get("dirty_files") or [] + reasons.append( + "reviewer scratch worktree has tracked edits" + + (f" ({', '.join(dirty)})" if dirty else "") + ) + if active_reviewer_lease: + reasons.append("active reviewer lease still requires this worktree") + current_branch = worktree_state.get("current_branch") + if current_branch: + # Scratch trees are expected detached; a named branch is ambiguous ownership. + reasons.append( + f"reviewer scratch worktree is on branch '{current_branch}' " + "(expected detached HEAD for review-pr scratch trees)" + ) + + safe = exists and not reasons + return { + "pr_number": pr_number, + "worktree_path": worktree_path, + "folder_name": folder_name, + "worktree_kind": "reviewer_scratch", + "worktree_exists": exists, + "worktree_clean": worktree_state.get("clean"), + "safe_to_remove_worktree": safe, + "block_reasons": reasons, + "recommended_action": ( + "remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree" + ), + # Never treat as author worktree cleanup. + "author_worktree_cleanup": False, + } + + def read_issue_lock(path: str | None = None) -> dict[str, Any] | None: if path: return issue_lock_store.read_lock_file(path.strip()) @@ -176,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: @@ -186,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}'" ) @@ -198,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, @@ -216,6 +527,7 @@ def build_pr_cleanup_entry( delete_capability_allowed: bool, issue_lock_path: str | None = None, protected_branches: frozenset[str] | None = None, + target_ref: str | None = None, ) -> dict[str, Any]: pr_number = int(pr["number"]) head_branch = pr.get("head") or "" @@ -224,9 +536,16 @@ def build_pr_cleanup_entry( title = pr.get("title") or "" body = pr.get("body") or "" merged = bool(pr.get("merged_at")) - worktree_path = resolve_worktree_path(project_root, head_branch) - 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( @@ -249,7 +568,7 @@ def build_pr_cleanup_entry( ) 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"), @@ -270,6 +589,9 @@ 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) entries: list[dict[str, Any]] = [] @@ -290,19 +612,65 @@ def build_reconciliation_report( delete_capability_allowed=delete_capability_allowed, issue_lock_path=issue_lock_path, protected_branches=protected_branches, + target_ref=target_ref, ) ) + + # #534: reviewer scratch worktrees are reported separately from author cleanup. + lease_map = active_reviewer_leases or {} + state_map = pr_states or {} + open_pr_numbers = { + int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None + } + closed_by_number = { + int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None + } + scratch_entries: list[dict[str, Any]] = [] + for scratch in discover_reviewer_scratch_worktrees(project_root): + pr_number = int(scratch["pr_number"]) + state_info = state_map.get(pr_number) or {} + closed_pr = closed_by_number.get(pr_number) + if closed_pr is not None: + pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged")) + pr_closed = True + elif pr_number in open_pr_numbers: + pr_merged = False + pr_closed = False + else: + pr_merged = bool(state_info.get("merged")) + pr_closed = bool(state_info.get("closed") or state_info.get("merged")) + worktree_path = scratch["worktree_path"] + worktree_state = read_local_worktree_state(worktree_path) + worktree_state["worktree_path"] = worktree_path + # Prefer live branch from state (git) over porcelain branch field. + assessment = assess_reviewer_scratch_cleanup( + pr_number=pr_number, + worktree_path=worktree_path, + folder_name=scratch["folder_name"], + pr_merged=pr_merged, + pr_closed=pr_closed, + worktree_state=worktree_state, + active_reviewer_lease=bool(lease_map.get(pr_number)), + ) + scratch_entries.append(assessment) + return { "project_root": os.path.realpath(project_root), "merged_pr_count": len(entries), "entries": entries, + "reviewer_scratch_entries": scratch_entries, + "reviewer_scratch_count": len(scratch_entries), "dry_run": True, "executed": False, } -def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]: - worktree_path = 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, @@ -326,4 +694,65 @@ def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]: "performed": True, "message": f"removed worktree {worktree_path}", "worktree_path": worktree_path, - } \ No newline at end of file + } + + +def remove_reviewer_scratch_worktree( + project_root: str, + worktree_path: str, +) -> dict[str, Any]: + """Remove a reviewer scratch worktree by absolute path (#534). + + Fail closed unless the path is a direct ``branches/review-pr*`` child of + *project_root*. Never routes through author branch-name derivation. + """ + root = os.path.realpath(project_root) + branches_root = os.path.realpath(os.path.join(root, "branches")) + real = os.path.realpath((worktree_path or "").strip()) + folder = os.path.basename(real) + if os.path.dirname(real) != branches_root: + return { + "success": False, + "performed": False, + "worktree_kind": "reviewer_scratch", + "message": ( + "reviewer scratch path must be a direct child of " + f"{branches_root}; got {real}" + ), + } + if parse_reviewer_scratch_folder(folder) is None: + return { + "success": False, + "performed": False, + "worktree_kind": "reviewer_scratch", + "message": f"path is not a reviewer scratch folder: {folder}", + } + if not os.path.isdir(real): + return { + "success": False, + "performed": False, + "worktree_kind": "reviewer_scratch", + "message": f"worktree not found: {real}", + } + res = subprocess.run( + ["git", "-C", project_root, "worktree", "remove", real], + capture_output=True, + text=True, + check=False, + ) + if res.returncode != 0: + return { + "success": False, + "performed": False, + "worktree_kind": "reviewer_scratch", + "message": (res.stderr or res.stdout or "worktree remove failed").strip(), + "worktree_path": real, + } + return { + "success": True, + "performed": True, + "worktree_kind": "reviewer_scratch", + "message": f"removed reviewer scratch worktree {real}", + "worktree_path": real, + "author_worktree_cleanup": False, + } diff --git a/pr_queue_cleanup.py b/pr_queue_cleanup.py index c1237aa..d6091ca 100644 --- a/pr_queue_cleanup.py +++ b/pr_queue_cleanup.py @@ -132,6 +132,13 @@ def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]: _SELECTED_PR_RE = re.compile( r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE ) +_SELECTED_PR_NONE_RE = re.compile( + r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE +) +_EMPTY_QUEUE_RE = re.compile( + r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b", + re.IGNORECASE, +) _NEXT_SUGGESTED_RE = re.compile( r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE ) @@ -176,7 +183,11 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict: selected = _SELECTED_PR_RE.findall(text) if len(selected) == 0: - reasons.append("report must name exactly one Selected PR") + if _SELECTED_PR_NONE_RE.search(text): + if not _EMPTY_QUEUE_RE.search(text): + reasons.append("Selected PR is 'none' but no valid empty-queue claim found") + else: + reasons.append("report must name exactly one Selected PR") elif len(set(selected)) > 1: reasons.append( "cleanup run selected multiple PRs " @@ -184,6 +195,9 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict: "PR per run" ) + if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text): + reasons.append("report contains both a selected PR and a claim of none selected") + terminal = _TERMINAL_MUTATION_RE.findall(text) if len(terminal) > 1: reasons.append( diff --git a/premerge_baseline_proof.py b/premerge_baseline_proof.py new file mode 100644 index 0000000..cf9b971 --- /dev/null +++ b/premerge_baseline_proof.py @@ -0,0 +1,176 @@ +"""Pre-merge baseline proof verifier for non-zero validation exits (#533). + +A controller/reviewer may reproduce a failing test on *current* master and +describe it as proof of a "known baseline failure". Reproducing a failure on +post-merge master only proves the failure exists on current master — it does +NOT prove the failure pre-existed the PR under review. A PR can introduce or +preserve a regression, then once merged the failure appears on master and gets +mislabeled "baseline", weakening validation gates. + +This module distinguishes four canonical validation outcomes: + +- ``CLEAN_PASS`` — the validation command exited zero. +- ``CURRENT_MASTER_FAILURE_REPRODUCED`` — the same failure reproduces on + current (post-merge) master. Not sufficient as baseline proof. +- ``PREMERGE_BASELINE_PROVEN_FAILURE`` — the failure is proven on the PR's + pre-merge base commit, or by a documented known-failure record that predates + the PR. +- ``UNRESOLVED_REGRESSION_RISK`` — a non-zero exit that is neither a clean pass + nor proven pre-existing. + +A report may only call a non-zero validation exit a "baseline"/"pre-existing" +failure when it carries pre-merge proof. +""" + +from __future__ import annotations + +import re +from typing import Any + +CLEAN_PASS = "clean pass" +CURRENT_MASTER_FAILURE_REPRODUCED = "current-master failure reproduced" +PREMERGE_BASELINE_PROVEN_FAILURE = "pre-merge baseline-proven failure" +UNRESOLVED_REGRESSION_RISK = "unresolved regression risk" + +# A non-zero validation exit is claimed somewhere in the report. +_NONZERO_EXIT_RE = re.compile( + r"(?:exit(?:\s*(?:status|code))?\s*[:=]?\s*(?:[1-9]\d*)" + r"|\b\d+\s+failed\b" + r"|\bnon[- ]zero\s+(?:exit|validation))", + re.IGNORECASE, +) +# The report claims the failure is pre-existing / a baseline failure. +_BASELINE_CLAIM_RE = re.compile( + r"(?:pre[- ]?existing(?:\s+(?:baseline|failure))?" + r"|baseline(?:[- ]proven)?\s+failure" + r"|known[- ]baseline" + r"|failure\s+is\s+baseline" + r"|already\s+fail(?:s|ing)\s+on\s+master)", + re.IGNORECASE, +) +# Only-current-master reproduction language (insufficient on its own). +_CURRENT_MASTER_RE = re.compile( + r"(?:current[- ]master\s+(?:reproduc|failure)" + r"|reproduc\w*\s+on\s+(?:current\s+)?master" + r"|post[- ]merge\s+master" + r"|fails\s+on\s+(?:current\s+)?master\s+(?:now|too|as\s+well))", + re.IGNORECASE, +) +# Pre-merge base proof: a base commit tested before the PR landed. +_PREMERGE_BASE_RE = re.compile( + r"(?:pre[- ]merge\s+base(?:\s+commit)?" + r"|pr\s+base\s+commit" + r"|merge[- ]base(?:\s+commit)?" + r"|base\s+commit\s+before\s+(?:the\s+)?pr)", + re.IGNORECASE, +) +# Documented known-failure record predating the PR. +_KNOWN_FAILURE_RECORD_RE = re.compile( + r"known[- ]failure\s+(?:record|reference|ledger)\b.{0,80}?" + r"(?:predat|before\s+(?:the\s+)?pr|pre[- ]existing|prior\s+to\s+(?:the\s+)?pr)", + re.IGNORECASE | re.DOTALL, +) + +# Required proof fields for a pre-merge baseline claim. +_FIELD_PATTERNS: dict[str, re.Pattern[str]] = { + "base commit": re.compile( + r"(?:pre[- ]merge\s+)?base\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE + ), + "tested commit": re.compile( + r"tested\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE + ), + "command": re.compile(r"command\s*[:=]\s*\S", re.IGNORECASE), + "exit status": re.compile(r"exit\s*(?:status|code)?\s*[:=]\s*\d+", re.IGNORECASE), + "failure signature": re.compile( + r"failure\s+signature\s*[:=]\s*\S", re.IGNORECASE + ), +} + + +def assess_premerge_baseline_proof(report_text: str) -> dict[str, Any]: + """Assess whether a claimed baseline failure carries pre-merge proof. + + Returns a dict with ``proven``, ``block``, ``label``, ``reasons``, + ``skipped`` and ``safe_next_action``. Only blocks when the report claims a + non-zero validation exit is baseline/pre-existing without valid pre-merge + proof. + """ + text = report_text or "" + + has_failure = bool(_NONZERO_EXIT_RE.search(text)) + claims_baseline = bool(_BASELINE_CLAIM_RE.search(text)) + + # No failure claimed, or no baseline assertion — nothing to enforce here. + if not has_failure and not claims_baseline: + return { + "proven": True, + "block": False, + "label": CLEAN_PASS, + "reasons": [], + "skipped": True, + "safe_next_action": "", + } + + if not claims_baseline: + # A non-zero exit not asserted as baseline — surface as regression risk, + # but do not block (the report never claimed a clean/baseline pass). + return { + "proven": True, + "block": False, + "label": UNRESOLVED_REGRESSION_RISK, + "reasons": [], + "skipped": False, + "safe_next_action": "", + } + + has_premerge_base = bool(_PREMERGE_BASE_RE.search(text)) + has_known_record = bool(_KNOWN_FAILURE_RECORD_RE.search(text)) + only_current_master = bool(_CURRENT_MASTER_RE.search(text)) + + reasons: list[str] = [] + + if not (has_premerge_base or has_known_record): + if only_current_master: + reasons.append( + "baseline failure claimed from current-master reproduction only; " + "that proves the failure on current master, not that it pre-existed " + "the PR — provide pre-merge base proof or a known-failure record" + ) + else: + reasons.append( + "baseline/pre-existing failure claimed without pre-merge proof " + "(no pre-merge base commit and no documented known-failure record " + "predating the PR)" + ) + + # Require the concrete proof fields regardless of proof source. + missing = [name for name, pat in _FIELD_PATTERNS.items() if not pat.search(text)] + if missing: + reasons.append( + "pre-merge baseline claim missing required proof field(s): " + + ", ".join(missing) + ) + + if reasons: + return { + "proven": False, + "block": True, + "label": UNRESOLVED_REGRESSION_RISK, + "reasons": reasons, + "skipped": False, + "safe_next_action": ( + "prove the failure on the PR pre-merge base commit (or cite a " + "known-failure record predating the PR) and state base commit, " + "tested commit, command, exit status, and failure signature; " + "current-master reproduction alone is not baseline proof" + ), + } + + return { + "proven": True, + "block": False, + "label": PREMERGE_BASELINE_PROVEN_FAILURE, + "reasons": [], + "skipped": False, + "safe_next_action": "", + } 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/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index 73b9f8d..66133a2 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -167,6 +167,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 @@ -195,4 +196,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-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..5c4ce88 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,16 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- 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 +67,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/pr-queue-cleanup.md b/skills/llm-project-workflow/templates/pr-queue-cleanup.md index d3913f1..1031a2a 100644 --- a/skills/llm-project-workflow/templates/pr-queue-cleanup.md +++ b/skills/llm-project-workflow/templates/pr-queue-cleanup.md @@ -16,7 +16,7 @@ Rules: reviewing a second PR after a terminal mutation in this run. - After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if operator explicitly authorized merge for this PR in this run. -- Report Next suggested PR without continuing to it. +- Report Next suggested PR without continuing to it. If the PR queue is empty, look at approvals, or issues next. Operator PR list (optional): <pr numbers or "oldest eligible from inventory"> Merge authorized for selected PR in this run: <true|false> diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index a959634..bf854dc 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -28,13 +28,17 @@ Repo name disambiguation (Gitea-Tools blind review hardening): `pr_inventory_trust_gate.status`, trust-gate reasons, corroboration, remote/owner/repo/state filter, and the inventory MCP profile. A recent merge commit is not valid corroboration. Author-bound sessions must not present - reviewer queue inventory as a reviewer decision. + reviewer queue inventory as a reviewer decision. If the PR queue is empty, + look at approvals, or issues next. Load the canonical workflow first: `skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr). Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- 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 @@ -109,8 +113,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: @@ -126,4 +131,16 @@ including the review/merge role fields: Selected PR, Reviewer eligibility, 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 +documented known-failure record predating the PR. Reproducing on current +(post-merge) master is "current-master failure reproduced", NOT baseline proof. +State: base commit, tested commit, command, exit status, failure signature. +Use one label: clean pass / current-master failure reproduced / pre-merge +baseline-proven failure / unresolved regression risk +(final_report_validator: reviewer.premerge_baseline_proof). ``` diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index be09053..572b556 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -10,6 +10,9 @@ 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): +- 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/pr-queue-cleanup.md b/skills/llm-project-workflow/workflows/pr-queue-cleanup.md index 8a1be98..f43b968 100644 --- a/skills/llm-project-workflow/workflows/pr-queue-cleanup.md +++ b/skills/llm-project-workflow/workflows/pr-queue-cleanup.md @@ -55,6 +55,10 @@ claim. Select exactly one PR according to project queue ordering rules PRs only with live per-PR proof. No multi-PR validation and no batch report may substitute for per-PR proof. +If the open PR queue is empty: +* First, look at **Approvals** next: check if there are open PRs with pending/completed approvals requiring attention or merge. +* Next, look at **Issues** next: check if there are unresolved open issues requiring action/fixes. + ## 4. Terminal mutation chain `pr_queue_cleanup.resolve_cleanup_run_state` is the authority: diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 5a90ea5..b830e8b 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -26,6 +26,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: @@ -272,6 +278,10 @@ If queue ordering cannot be proven, stop and produce a recovery handoff. ## 10. Select the next actionable PR using project rules +If the open PR queue is empty: +* First, look at approvals next to see if there are pending approvals or approved PRs that need attention/merge. +* Next, look at issues next to see if there are open issues requiring action/fixes. + Do not review your own PR. Do not review stale, draft, blocked, duplicate, already-owned, dependency-blocked, already-landed, already-requested-changes work unless the rules explicitly allow it. diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index c7f5747..41afd5d 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -26,6 +26,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 +621,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/state_handoff_ledger.py b/state_handoff_ledger.py new file mode 100644 index 0000000..809043e --- /dev/null +++ b/state_handoff_ledger.py @@ -0,0 +1,374 @@ +"""Canonical state handoff ledger for discussions, issues, and PRs (#494).""" + +from __future__ import annotations + +import re +from typing import Any + +HANDOFF_HEADING = "Controller Handoff" + +ISSUE_STATE_FIELDS = ( + "STATE", + "NEXT_ACTOR", + "NEXT_ACTION", + "NEXT_PROMPT", + "STATUS", + "RELATED_PRS", + "BRANCH", + "HEAD_SHA", + "VALIDATION", + "BLOCKERS", + "DUPLICATES_OR_SUPERSEDES", + "LAST_UPDATED_BY", +) + +PR_STATE_FIELDS = ( + "STATE", + "NEXT_ACTOR", + "NEXT_ACTION", + "NEXT_PROMPT", + "ISSUE", + "BASE", + "HEAD", + "HEAD_SHA", + "REVIEW_STATUS", + "VALIDATION", + "BLOCKERS", + "SUPERSEDES", + "SUPERSEDED_BY", + "MERGE_READY", + "LAST_UPDATED_BY", +) + +DISCUSSION_STATE_FIELDS = ( + "STATE", + "NEXT_ACTOR", + "NEXT_ACTION", + "NEXT_PROMPT", + "COMMENT_COUNT", + "SUBSTANTIVE_COMMENTS", + "URGENCY", + "BLOCKERS", + "LAST_UPDATED_BY", +) + +DISCUSSION_SUMMARY_FIELDS = ( + "DECISION", + "ISSUES_TO_CREATE", + "NON_GOALS", + "UNRESOLVED_QUESTIONS", + "NEXT_PROMPT", + "LAST_UPDATED_BY", +) + +QUEUE_CONTROLLER_FIELDS = ( + "QUEUE_STATE", + "SELECTED_OBJECT", + "SELECTED_OBJECT_TYPE", + "NEXT_ACTOR", + "NEXT_ACTION", + "NEXT_PROMPT", + "EVIDENCE", + "LAST_UPDATED_BY", +) + +FINAL_REPORT_NEXT_ACTION_FIELDS = ( + ("Current status", ("current status",)), + ("Next actor", ("next actor", "next_actor")), + ("Next action", ("next action", "next_action")), + ("Next prompt", ("next prompt", "next_prompt")), +) + +STATE_COMMENT_KINDS = frozenset({ + "issue_state", + "pr_state", + "discussion_state", + "discussion_summary", + "queue_controller", +}) + +_FIELDS_BY_KIND = { + "issue_state": ISSUE_STATE_FIELDS, + "pr_state": PR_STATE_FIELDS, + "discussion_state": DISCUSSION_STATE_FIELDS, + "discussion_summary": DISCUSSION_SUMMARY_FIELDS, + "queue_controller": QUEUE_CONTROLLER_FIELDS, +} + +_FIELD_LINE_RE = re.compile( + r"^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$", + re.MULTILINE, +) +_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M) +_READY_TO_MERGE_RE = re.compile( + r"\b(?:ready[_ ]to[_ ]merge|merge[_ ]ready\s*:\s*(?:yes|true))\b", + re.IGNORECASE, +) +_APPROVAL_PROOF_RE = re.compile( + r"\b(?:review decision\s*:\s*approve|approved at|approval at current head|" + r"formal approval|review_status\s*:\s*approved)\b", + re.IGNORECASE, +) +_EXTERNAL_NONE_RE = re.compile( + r"^\s*[-*]?\s*external[- ]state mutations\s*:\s*none\s*$", + re.IGNORECASE | re.MULTILINE, +) +_LOCK_MUTATION_RE = re.compile( + r"\b(?:gitea_lock_issue|issue-locks/|lock file edited)\b", + re.IGNORECASE, +) +_ISSUE_DONE_RE = re.compile( + r"\b(?:issue (?:done|closed)\b|current status\s*:\s*(?:done|closed)\b|" + r"current status\s*:\s*issue complete\b)", + re.IGNORECASE, +) +_PR_MERGE_PROOF_RE = re.compile( + r"\b(?:pr (?:merged|number)|merge result\s*:\s*merged|pr mutations\s*:\s*created)\b", + re.IGNORECASE, +) +_DISCUSSION_COMPLETE_RE = re.compile( + r"\b(?:discussion complete|discussion ready for issue creation)\b", + re.IGNORECASE, +) +_DISCUSSION_SUMMARY_PROOF_RE = re.compile( + r"\b(?:discussion summary|issues to create|issues created|linked issues)\b", + re.IGNORECASE, +) + +MIN_DISCUSSION_COMMENTS_DEFAULT = 5 + + +def _render_fields(fields: tuple[str, ...], values: dict[str, Any]) -> str: + lines = ["```text"] + for name in fields: + raw = values.get(name, values.get(name.lower(), "none")) + text = str(raw).strip() if raw is not None else "none" + lines.append(f"{name}: {text or 'none'}") + lines.append("```") + return "\n".join(lines) + + +def render_issue_state_comment(**values: Any) -> str: + """Render canonical issue state comment body.""" + return _render_fields(ISSUE_STATE_FIELDS, values) + + +def render_pr_state_comment(**values: Any) -> str: + """Render canonical PR state comment body.""" + return _render_fields(PR_STATE_FIELDS, values) + + +def render_discussion_state_comment(**values: Any) -> str: + """Render canonical discussion state comment body.""" + return _render_fields(DISCUSSION_STATE_FIELDS, values) + + +def render_discussion_summary_comment(**values: Any) -> str: + """Render discussion-to-issue conversion summary comment.""" + return _render_fields(DISCUSSION_SUMMARY_FIELDS, values) + + +def render_queue_controller_report(**values: Any) -> str: + """Render queue-controller selection report.""" + return _render_fields(QUEUE_CONTROLLER_FIELDS, values) + + +def parse_state_comment(text: str) -> dict[str, str]: + """Parse KEY: value lines from a canonical state comment.""" + parsed: dict[str, str] = {} + for match in _FIELD_LINE_RE.finditer(text or ""): + parsed[match.group(1)] = match.group(2).strip() + return parsed + + +def _handoff_field_map(report_text: str) -> dict[str, str]: + section_match = _HANDOFF_SECTION_RE.search(report_text or "") + if not section_match: + return {} + section = (report_text or "")[section_match.end():] + fields: dict[str, str] = {} + for line in section.splitlines(): + stripped = line.strip().lstrip("-*").strip() + if ":" not in stripped: + continue + key, value = stripped.split(":", 1) + fields[key.strip().lower()] = value.strip() + return fields + + +def assess_state_comment(text: str, *, kind: str) -> dict[str, Any]: + """Validate a canonical Gitea state comment for *kind*.""" + normalized = (kind or "").strip().lower() + if normalized not in STATE_COMMENT_KINDS: + return { + "complete": False, + "block": True, + "missing_fields": [], + "reasons": [f"unknown state comment kind '{kind}'"], + "safe_next_action": "use a supported state comment kind", + } + + required = _FIELDS_BY_KIND[normalized] + parsed = parse_state_comment(text) + missing = [name for name in required if name not in parsed or not parsed[name].strip()] + reasons = [f"state comment missing field: {name}" for name in missing] + + if normalized == "discussion_state": + urgency = parsed.get("URGENCY", "").strip().lower() + count_raw = parsed.get("COMMENT_COUNT", "").strip() + try: + count = int(count_raw) + except (TypeError, ValueError): + count = -1 + if ( + urgency not in {"urgent", "trivial"} + and count >= 0 + and count < MIN_DISCUSSION_COMMENTS_DEFAULT + ): + reasons.append( + f"discussion has {count} comments; need at least " + f"{MIN_DISCUSSION_COMMENTS_DEFAULT} substantive comments " + "or URGENCY: urgent|trivial" + ) + + block = bool(missing or reasons) + return { + "complete": not block, + "block": block, + "missing_fields": missing, + "parsed_fields": parsed, + "reasons": reasons, + "safe_next_action": ( + "fill all required state comment fields before posting" + if block + else "proceed" + ), + } + + +def assess_final_report_next_action_handoff(report_text: str) -> dict[str, Any]: + """Require current status plus next actor/action/prompt in Controller Handoff.""" + fields = _handoff_field_map(report_text) + if not fields: + return { + "complete": False, + "block": True, + "missing_fields": [name for name, _ in FINAL_REPORT_NEXT_ACTION_FIELDS], + "reasons": [f"final report has no section titled exactly '{HANDOFF_HEADING}'"], + "safe_next_action": f"add a complete {HANDOFF_HEADING} section", + } + + missing = [] + for name, aliases in FINAL_REPORT_NEXT_ACTION_FIELDS: + value = "" + for alias in aliases: + if alias in fields: + value = fields[alias] + break + if not value or value.lower() in {"none", "n/a", "not verified in this session", ""}: + missing.append(name) + + reasons = [f"handoff missing next-action field: {name}" for name in missing] + block = bool(missing) + return { + "complete": not block, + "block": block, + "missing_fields": missing, + "reasons": reasons, + "safe_next_action": ( + "add Current status, Next actor, Next action, and Next prompt to Controller Handoff" + if block + else "proceed" + ), + } + + +def assess_contradictory_state_handoff(report_text: str) -> dict[str, Any]: + """Fail closed on contradictory queue/state claims in final reports.""" + text = report_text or "" + fields = _handoff_field_map(text) + reasons: list[str] = [] + + review_decision = fields.get("review decision", fields.get("review status", "")) + has_approval = ( + review_decision.lower() in {"approve", "approved"} + or bool(_APPROVAL_PROOF_RE.search(text)) + ) + if _READY_TO_MERGE_RE.search(text) and not has_approval: + reasons.append( + "report claims ready to merge without approval-at-current-head proof" + ) + + lock_val = fields.get("external-state mutations", "") + mutation_lines = " ".join( + fields.get(key, "") + for key in ( + "issue mutations", + "mcp/gitea mutations", + "external-state mutations", + "claim/lock state", + ) + ) + if ( + (_EXTERNAL_NONE_RE.search(text) or lock_val.lower() == "none") + and _LOCK_MUTATION_RE.search(mutation_lines) + ): + reasons.append( + "report claims External-state mutations: none while lock " + "activity is documented in mutation fields" + ) + + if _ISSUE_DONE_RE.search(text) and not _PR_MERGE_PROOF_RE.search(text): + reasons.append( + "report claims issue done/complete without PR or merge proof" + ) + + if _DISCUSSION_COMPLETE_RE.search(text) and not _DISCUSSION_SUMMARY_PROOF_RE.search(text): + reasons.append( + "report claims discussion complete without summary or issue-creation proof" + ) + + review_status = fields.get("review status", fields.get("review decision", "")) + merge_ready = fields.get("merge ready", "") + if ( + merge_ready.lower() in {"yes", "true", "ready"} + and review_status.lower() not in {"approve", "approved"} + and not _APPROVAL_PROOF_RE.search(text) + ): + reasons.append( + "MERGE_READY or merge-ready claim without approved review status" + ) + + block = bool(reasons) + return { + "complete": not block, + "block": block, + "reasons": reasons, + "safe_next_action": ( + "correct contradictory state claims or add missing proof" + if block + else "proceed" + ), + } + + +def assess_state_handoff_ledger_report(report_text: str) -> dict[str, Any]: + """Composite #494 assessment for final reports.""" + next_action = assess_final_report_next_action_handoff(report_text) + contradictions = assess_contradictory_state_handoff(report_text) + reasons = list(next_action.get("reasons") or []) + list(contradictions.get("reasons") or []) + block = bool(next_action.get("block") or contradictions.get("block")) + return { + "complete": not block, + "block": block, + "next_action": next_action, + "contradictions": contradictions, + "reasons": reasons, + "safe_next_action": ( + next_action.get("safe_next_action") + if next_action.get("block") + else contradictions.get("safe_next_action") + if contradictions.get("block") + else "proceed" + ), + } \ No newline at end of file diff --git a/task_capability_map.py b/task_capability_map.py index c86ce2a..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", @@ -106,11 +110,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { }, "reconcile_merged_cleanups": { "permission": "gitea.read", - "role": "author", + "role": "reconciler", }, "reconciliation_cleanup": { "permission": "gitea.branch.delete", - "role": "author", + "role": "reconciler", }, "work_issue": { "permission": "gitea.pr.create", @@ -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/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py index a117fab..e429ddf 100644 --- a/tests/test_audit_reconciliation_mode.py +++ b/tests/test_audit_reconciliation_mode.py @@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase): required_permission("reconciliation_cleanup"), "gitea.branch.delete", ) - self.assertEqual(required_role("reconciliation_cleanup"), "author") + self.assertEqual(required_role("reconciliation_cleanup"), "reconciler") 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 70121f4..fd7a62f 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -35,6 +35,9 @@ def _review_handoff(**overrides): "- External-state mutations: none", "- Read-only diagnostics: issue and PR metadata inspected", "- Current status: PR open", + "- Next actor: author", + "- Next action: address review feedback", + "- Next prompt: Fix PR #203 per reviewer request_changes and push updated head", "- Blockers: none", "- Next: await author", "- Safety: no self-review; no self-merge", @@ -92,6 +95,9 @@ def _reconcile_handoff(drop=(), **overrides): "- Read-only diagnostics: gitea_view_pr, gitea_view_issue", "- Reconciliation mutations: PR comment posted", "- Current status: reconciliation complete", + "- Next actor: reconciler", + "- Next action: close PR #99 after capability proof", + "- Next prompt: Reconcile already-landed PR #99 with prgs-reconciler profile", "- Blocker: missing gitea.pr.close capability", "- Safe next action: hand off to a profile with gitea.pr.close", "- No review/merge confirmation: confirmed", @@ -345,6 +351,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.""" @@ -573,6 +665,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") @@ -588,8 +705,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_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_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_master_parity_gate.py b/tests/test_master_parity_gate.py new file mode 100644 index 0000000..33086f0 --- /dev/null +++ b/tests/test_master_parity_gate.py @@ -0,0 +1,152 @@ +"""Tests for the master-parity staleness gate (#420). + +Covers the pure assessment logic, the mutation-block helper, the env escape +hatches, and the server-side wiring: reads are never blocked by staleness, a +mutation is refused when the on-disk master has advanced, and the read-only +gitea_assess_master_parity tool reports the stale state. +""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import master_parity_gate as mp # noqa: E402 + + +SHA_A = "a" * 40 +SHA_B = "b" * 40 + + +class TestAssessMasterParity(unittest.TestCase): + def test_in_parity_when_heads_match(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A) + self.assertTrue(res["in_parity"]) + self.assertFalse(res["stale"]) + self.assertFalse(res["restart_required"]) + self.assertTrue(res["determinable"]) + + def test_stale_when_master_advanced(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + self.assertFalse(res["in_parity"]) + self.assertTrue(res["stale"]) + self.assertTrue(res["restart_required"]) + self.assertTrue(res["determinable"]) + self.assertTrue(any("restart" in r for r in res["reasons"])) + + def test_missing_startup_head_not_determinable(self): + res = mp.assess_master_parity({"startup_head": None}, SHA_B) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + self.assertTrue(res["in_parity"]) + + def test_missing_current_head_not_determinable(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, None) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + self.assertTrue(res["in_parity"]) + + def test_none_startup_baseline(self): + res = mp.assess_master_parity(None, SHA_A) + self.assertFalse(res["determinable"]) + self.assertFalse(res["stale"]) + + +class TestBlockReasonsAndReport(unittest.TestCase): + def test_stale_produces_block_reasons(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + self.assertTrue(mp.parity_block_reasons(res)) + + def test_in_parity_no_block_reasons(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A) + self.assertEqual(mp.parity_block_reasons(res), []) + + def test_disable_env_suppresses_block(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}): + self.assertTrue(mp.gate_disabled()) + self.assertEqual(mp.parity_block_reasons(res), []) + + def test_report_shape_when_stale(self): + res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B) + report = mp.parity_report(res) + self.assertEqual(report["kind"], "server_stale") + self.assertTrue(report["restart_required"]) + self.assertEqual(report["startup_head"], SHA_A) + self.assertEqual(report["current_head"], SHA_B) + self.assertTrue(report["recovery"]) + + +class TestReadGitHead(unittest.TestCase): + def test_test_override_takes_precedence(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B) + + def test_blank_override_is_none(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}): + self.assertIsNone(mp.read_git_head("/nonexistent")) + + def test_empty_root_is_none(self): + # No override set; empty root cannot resolve a HEAD. + env = {k: v for k, v in os.environ.items() + if k != mp.ENV_TEST_CURRENT_HEAD} + with patch.dict(os.environ, env, clear=True): + self.assertIsNone(mp.read_git_head("")) + + +class TestServerWiring(unittest.TestCase): + """Integration with the gate choke point in the server namespace.""" + + def setUp(self): + import mcp_server + self.srv = mcp_server + # Pin a known startup baseline so the current-HEAD override can diverge. + self._saved = self.srv._STARTUP_PARITY + self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT, + "startup_head": SHA_A} + + def tearDown(self): + self.srv._STARTUP_PARITY = self._saved + + def test_reads_not_blocked_when_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + self.assertEqual(self.srv._master_parity_block("gitea.read"), []) + + def test_mutation_blocked_when_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + reasons = self.srv._master_parity_block("gitea.pr.create") + self.assertTrue(reasons) + # The profile-operation choke point surfaces the same block. + self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create")) + + def test_mutation_allowed_when_in_parity(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}): + self.assertEqual( + self.srv._master_parity_block("gitea.pr.create"), []) + + def test_disable_env_lets_mutation_through(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B, + mp.ENV_DISABLE: "1"}): + self.assertEqual( + self.srv._master_parity_block("gitea.pr.create"), []) + + def test_assess_tool_reports_stale(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}): + out = self.srv.gitea_assess_master_parity(remote="prgs") + self.assertTrue(out["stale"]) + self.assertTrue(out["restart_required"]) + self.assertEqual(out["startup_head"], SHA_A) + self.assertEqual(out["current_head"], SHA_B) + self.assertIn("report", out) + + def test_assess_tool_reports_in_parity(self): + with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}): + out = self.srv.gitea_assess_master_parity(remote="prgs") + self.assertFalse(out["stale"]) + self.assertTrue(out["in_parity"]) + self.assertNotIn("report", out) + + +if __name__ == "__main__": + unittest.main() 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_menu_script.py b/tests/test_mcp_menu_script.py index a447648..b52ecad 100644 --- a/tests/test_mcp_menu_script.py +++ b/tests/test_mcp_menu_script.py @@ -105,6 +105,19 @@ class TestMcpMenuScript(unittest.TestCase): self.assertIn("./mcp-menu.sh", docs_text) self.assertIn("placeholder", docs_text.lower()) + def test_reviewer_skip_stale_request_changes_prompt_discoverable(self): + # #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt + # must be reachable from the reviewer menu and documented. + label = "Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" + reviewer_fn = self._extract_function("show_reviewer_prompts") + self.assertIn(label, reviewer_fn, "new reviewer prompt label must appear in the reviewer menu") + # The prompt must instruct: no duplicate terminal review mutation for a + # non-stale REQUEST_CHANGES head, and must carry the author handoff. + self.assertIn("do NOT submit another terminal review mutation", reviewer_fn) + self.assertIn("author-ready fix prompt", reviewer_fn) + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("REQUEST_CHANGES", docs_text) + def _extract_function(self, name: str) -> str: marker = f"{name}() {{" start = self.content.index(marker) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 503e879..bb1ffc8 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 ---------------------------------------------- @@ -3102,6 +3205,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 +3898,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 +3930,7 @@ class TestIssueLocking(unittest.TestCase): worktree_path=scratch, ) self.assertEqual(res["number"], 250) + self.assertTrue(res["issue_status_transition"]["performed"]) # --------------------------------------------------------------------------- diff --git a/tests/test_merged_cleanup_reconcile.py b/tests/test_merged_cleanup_reconcile.py index 779df64..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 +from unittest.mock import MagicMock, Mock, patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) @@ -154,6 +154,460 @@ class TestMergedCleanupReport(unittest.TestCase): finally: os.remove(lock_path) + @patch("subprocess.run") + def test_discover_local_worktrees(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" + ) + 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"}) + + @patch("subprocess.run") + @patch("merged_cleanup_reconcile.read_local_worktree_state") + def test_build_report_discovers_non_canonical_worktree_by_branch(self, mock_state, 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" + ) + 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", + closed_prs=[ + { + "number": 11, + "title": "merged", + "body": "Closes #11", + "head": {"ref": "feat/issue-11-x"}, + "merged_at": "2026-07-06T12:00:00Z", + "merge_commit_sha": "abc123", + } + ], + open_prs=[], + remote_branch_exists={"feat/issue-11-x": True}, + head_on_master={11: True}, + delete_capability_allowed=True, + ) + self.assertEqual(report["merged_pr_count"], 1) + entry = report["entries"][0] + local = entry["local_worktree"] + 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_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" + ), + ) + 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": [], + }, + ] + + 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.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, + ) + + +class TestReviewerScratchCleanup(unittest.TestCase): + """#534: authorized cleanup path for reviewer scratch worktrees.""" + + def test_parse_reviewer_scratch_folder(self): + self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr512-submit"), 512) + self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr99"), 99) + self.assertIsNone(mcr.parse_reviewer_scratch_folder("feat-issue-11-x")) + self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-feat-issue-11")) + self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-pr512-extra-tail")) + + def test_assess_safe_clean_detached_after_merge(self): + assessment = mcr.assess_reviewer_scratch_cleanup( + pr_number=512, + worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", + folder_name="review-pr512-submit", + pr_merged=True, + pr_closed=True, + worktree_state={ + "exists": True, + "clean": True, + "current_branch": None, + "head_sha": "abc", + "dirty_files": [], + }, + active_reviewer_lease=False, + ) + self.assertTrue(assessment["safe_to_remove_worktree"]) + self.assertFalse(assessment["author_worktree_cleanup"]) + self.assertEqual( + assessment["recommended_action"], "remove_reviewer_scratch_worktree" + ) + self.assertEqual(assessment["worktree_kind"], "reviewer_scratch") + + def test_assess_blocks_dirty_scratch(self): + assessment = mcr.assess_reviewer_scratch_cleanup( + pr_number=512, + worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", + folder_name="review-pr512-submit", + pr_merged=True, + pr_closed=True, + worktree_state={ + "exists": True, + "clean": False, + "current_branch": None, + "head_sha": "abc", + "dirty_files": ["foo.py"], + }, + active_reviewer_lease=False, + ) + self.assertFalse(assessment["safe_to_remove_worktree"]) + self.assertTrue( + any("tracked edits" in r for r in assessment["block_reasons"]) + ) + + def test_assess_blocks_active_lease(self): + assessment = mcr.assess_reviewer_scratch_cleanup( + pr_number=512, + worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", + folder_name="review-pr512-submit", + pr_merged=True, + pr_closed=True, + worktree_state={ + "exists": True, + "clean": True, + "current_branch": None, + "head_sha": "abc", + "dirty_files": [], + }, + active_reviewer_lease=True, + ) + self.assertFalse(assessment["safe_to_remove_worktree"]) + self.assertTrue( + any("active reviewer lease" in r for r in assessment["block_reasons"]) + ) + + def test_assess_blocks_open_pr(self): + assessment = mcr.assess_reviewer_scratch_cleanup( + pr_number=512, + worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit", + folder_name="review-pr512-submit", + pr_merged=False, + pr_closed=False, + worktree_state={ + "exists": True, + "clean": True, + "current_branch": None, + "head_sha": "abc", + "dirty_files": [], + }, + active_reviewer_lease=False, + ) + self.assertFalse(assessment["safe_to_remove_worktree"]) + self.assertTrue(any("still open" in r for r in assessment["block_reasons"])) + + @patch("subprocess.run") + @patch("merged_cleanup_reconcile.read_local_worktree_state") + def test_report_lists_scratch_separately_from_author(self, mock_state, mock_run): + mock_output = ( + "worktree /repo/Gitea-Tools\n" + "bare\n\n" + "worktree /repo/Gitea-Tools/branches/feat-issue-11-x\n" + "branch refs/heads/feat/issue-11-x\n" + "HEAD deadbeef\n\n" + "worktree /repo/Gitea-Tools/branches/review-pr512-submit\n" + "HEAD abcdef0\n" + "detached\n\n" + ) + mock_run.return_value = MagicMock(returncode=0, stdout=mock_output) + + def _state(path): + if "review-pr512" in path: + return { + "exists": True, + "clean": True, + "current_branch": None, + "head_sha": "abcdef0", + "dirty_files": [], + } + return { + "exists": True, + "clean": True, + "current_branch": "feat/issue-11-x", + "head_sha": "deadbeef", + "dirty_files": [], + } + + mock_state.side_effect = _state + report = mcr.build_reconciliation_report( + project_root="/repo/Gitea-Tools", + closed_prs=[ + { + "number": 11, + "title": "merged author", + "body": "Closes #11", + "head": {"ref": "feat/issue-11-x"}, + "merged_at": "2026-07-06T12:00:00Z", + }, + { + "number": 512, + "title": "merged reviewed", + "body": "Closes #512", + "head": {"ref": "feat/issue-512-x"}, + "merged_at": "2026-07-06T12:00:00Z", + }, + ], + open_prs=[], + remote_branch_exists={ + "feat/issue-11-x": False, + "feat/issue-512-x": False, + }, + head_on_master={11: True, 512: True}, + delete_capability_allowed=True, + active_reviewer_leases={512: False}, + ) + self.assertEqual(report["merged_pr_count"], 2) + self.assertEqual(report["reviewer_scratch_count"], 1) + scratch = report["reviewer_scratch_entries"][0] + self.assertEqual(scratch["pr_number"], 512) + self.assertEqual(scratch["worktree_kind"], "reviewer_scratch") + self.assertFalse(scratch["author_worktree_cleanup"]) + self.assertTrue(scratch["safe_to_remove_worktree"]) + # Author entries must not swallow the scratch path as local_worktree. + author_paths = { + (e.get("local_worktree") or {}).get("worktree_path") + for e in report["entries"] + } + self.assertNotIn( + "/repo/Gitea-Tools/branches/review-pr512-submit", author_paths + ) + + @patch("subprocess.run") + def test_remove_reviewer_scratch_worktree_path_guard(self, mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + with patch("os.path.isdir", return_value=True): + bad = mcr.remove_reviewer_scratch_worktree( + "/repo/Gitea-Tools", + "/repo/Gitea-Tools/branches/feat-issue-11-x", + ) + self.assertFalse(bad["performed"]) + good = mcr.remove_reviewer_scratch_worktree( + "/repo/Gitea-Tools", + "/repo/Gitea-Tools/branches/review-pr512-submit", + ) + self.assertTrue(good["success"]) + self.assertTrue(good["performed"]) + self.assertFalse(good["author_worktree_cleanup"]) + mock_run.assert_any_call( + [ + "git", + "-C", + "/repo/Gitea-Tools", + "worktree", + "remove", + "/repo/Gitea-Tools/branches/review-pr512-submit", + ], + capture_output=True, + text=True, + check=False, + ) + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_operator_guide.py b/tests/test_operator_guide.py index a5159c6..147a6b2 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 = [ @@ -88,6 +95,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_pr_queue_cleanup.py b/tests/test_pr_queue_cleanup.py index e26a026..caff6cc 100644 --- a/tests/test_pr_queue_cleanup.py +++ b/tests/test_pr_queue_cleanup.py @@ -265,5 +265,38 @@ class TestCleanupReportVerifier(unittest.TestCase): self.assertEqual(direct["proven"], wrapped["proven"]) +class TestCleanupEmptyQueue(unittest.TestCase): + def test_empty_queue_report_passes(self): + report = _clean_report( + selected="Selected PR: none", + decision="Review decision: comment", + next="Next suggested PR: approvals (queue empty)", + pagination="PR inventory pagination proof: inventory_complete=true, total_count 0", + ) + report += "\npr_inventory_trust_gate.status: trusted_empty" + result = assess_pr_queue_cleanup_report(report) + self.assertTrue(result["proven"], result["reasons"]) + + def test_empty_queue_without_evidence_fails(self): + report = _clean_report( + selected="Selected PR: none", + ) + result = assess_pr_queue_cleanup_report(report) + self.assertFalse(result["proven"]) + self.assertTrue( + any("no valid empty-queue claim" in r for r in result["reasons"]), + result["reasons"] + ) + + def test_contradictory_selected_pr_fails(self): + report = _clean_report() + "\nSelected PR: none" + result = assess_pr_queue_cleanup_report(report) + self.assertFalse(result["proven"]) + self.assertTrue( + any("contains both a selected PR and a claim of none selected" in r for r in result["reasons"]), + result["reasons"] + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_premerge_baseline_proof.py b/tests/test_premerge_baseline_proof.py new file mode 100644 index 0000000..865f093 --- /dev/null +++ b/tests/test_premerge_baseline_proof.py @@ -0,0 +1,112 @@ +import unittest + +from premerge_baseline_proof import ( + CLEAN_PASS, + PREMERGE_BASELINE_PROVEN_FAILURE, + UNRESOLVED_REGRESSION_RISK, + assess_premerge_baseline_proof, +) +from final_report_validator import assess_final_report_validator + +_FIELDS = ( + "Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n" + "Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n" + "Command: venv/bin/python -m pytest tests/test_foo.py -q\n" + "Exit status: 1\n" + "Failure signature: AssertionError: None is not true\n" +) + +# Current-master-only reproduction carries no pre-merge base proof (that is the +# whole defect #533 guards against): a command/exit/signature but no base commit. +_CURRENT_ONLY_FIELDS = ( + "Command: venv/bin/python -m pytest tests/test_foo.py -q\n" + "Exit status: 1\n" + "Failure signature: AssertionError: None is not true\n" +) + + +class TestPremergeBaselineProof(unittest.TestCase): + def test_clean_pass_not_blocked(self): + result = assess_premerge_baseline_proof( + "Validation: full suite passed, exit status 0. Clean pass." + ) + self.assertFalse(result["block"]) + self.assertTrue(result["skipped"]) + self.assertEqual(result["label"], CLEAN_PASS) + + def test_nonzero_exit_not_claimed_baseline_not_blocked(self): + result = assess_premerge_baseline_proof( + "Full suite: 1 failed. Investigating the regression; not yet classified." + ) + self.assertFalse(result["block"]) + self.assertEqual(result["label"], UNRESOLVED_REGRESSION_RISK) + + def test_current_master_only_reproduction_rejected(self): + report = ( + "Full suite: 1 failed. This is a pre-existing baseline failure — " + "reproduced on current master after the PR merged.\n" + _CURRENT_ONLY_FIELDS + ) + result = assess_premerge_baseline_proof(report) + self.assertTrue(result["block"]) + self.assertTrue( + any("current-master reproduction only" in r for r in result["reasons"]) + ) + + def test_baseline_claim_missing_fields_blocked(self): + report = ( + "Full suite: 1 failed. This failure is baseline / pre-existing. " + "Verified against the pre-merge base commit." + ) + result = assess_premerge_baseline_proof(report) + self.assertTrue(result["block"]) + self.assertTrue( + any("missing required proof field" in r for r in result["reasons"]) + ) + + def test_premerge_base_proof_accepted(self): + report = ( + "Full suite: 1 failed. This is a pre-existing baseline failure, proven " + "on the PR pre-merge base commit.\n" + _FIELDS + ) + result = assess_premerge_baseline_proof(report) + self.assertFalse(result["block"]) + self.assertTrue(result["proven"]) + self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE) + + def test_documented_known_failure_record_accepted(self): + report = ( + "Full suite: 1 failed. Baseline failure: cited known-failure record #529 " + "which predates the PR.\n" + _FIELDS + ) + result = assess_premerge_baseline_proof(report) + self.assertFalse(result["block"]) + self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE) + + +class TestPremergeBaselineProofValidatorIntegration(unittest.TestCase): + def _has_rule(self, report): + result = assess_final_report_validator(report, "review_pr") + return any( + f["rule_id"] == "reviewer.premerge_baseline_proof" + for f in result["findings"] + ) + + def test_review_report_current_master_only_flagged(self): + report = ( + "## Reviewer final report\n" + "Full suite: 1 failed. Pre-existing baseline failure — reproduced on " + "post-merge master.\n" + _CURRENT_ONLY_FIELDS + ) + self.assertTrue(self._has_rule(report)) + + def test_review_report_premerge_proof_clean(self): + report = ( + "## Reviewer final report\n" + "Full suite: 1 failed. Pre-existing baseline failure proven on the " + "pre-merge base commit.\n" + _FIELDS + ) + self.assertFalse(self._has_rule(report)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reconciler_cleanup_integration.py b/tests/test_reconciler_cleanup_integration.py new file mode 100644 index 0000000..7637602 --- /dev/null +++ b/tests/test_reconciler_cleanup_integration.py @@ -0,0 +1,263 @@ +"""Integration tests for reconciler merged cleanup (#523).""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import mcp_server +import task_capability_map +from audit_reconciliation_mode import clear_phase +from task_capability_map import required_role + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "context": "prgs", + "role": "reconciler", + "username": "sysadmin", + "base_url": "https://gitea.prgs.cc", + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + ], + "forbidden_operations": [], +} + +AUTHOR_PROFILE = { + "profile_name": "prgs-author", + "context": "prgs", + "role": "author", + "username": "jcwalker3", + "base_url": "https://gitea.prgs.cc", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "forbidden_operations": [], +} + +CONTROL_ROOT = "/Users/jasonwalker/Development/Gitea-Tools" + + +class TestReconcilerCleanupIntegration(unittest.TestCase): + def setUp(self): + clear_phase() + mcp_server._preflight_whoami_called = False + mcp_server._preflight_capability_called = False + mcp_server._preflight_resolved_role = None + mcp_server._preflight_resolved_task = None + mcp_server._preflight_whoami_violation = False + mcp_server._preflight_capability_violation = False + mcp_server._preflight_whoami_violation_files = [] + mcp_server._preflight_capability_violation_files = [] + mcp_server._preflight_capability_baseline_porcelain = "" + self.mock_api = patch("gitea_auth.api_request").start() + self.mock_auth = patch( + "mcp_server.get_auth_header", return_value="token test" + ).start() + + def tearDown(self): + patch.stopall() + clear_phase() + mcp_server._IDENTITY_CACHE.clear() + + def test_capability_map_routes_merged_cleanup_to_reconciler(self): + self.assertEqual(required_role("reconcile_merged_cleanups"), "reconciler") + self.assertEqual(required_role("reconciliation_cleanup"), "reconciler") + self.assertEqual( + task_capability_map.required_permission("reconcile_merged_cleanups"), + "gitea.read", + ) + + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) + + self.mock_api.side_effect = [ + [ # closed pulls page + { + "number": 100, + "title": "merged feature", + "body": "Closes #100", + "merged": True, + "merged_at": "2026-07-06T12:00:00Z", + "merge_commit_sha": "deadbeef", + "head": {"ref": "feat/issue-100", "sha": "cafebabe"}, + }, + { + # closed but NOT merged — must not become a cleanup entry + "number": 101, + "title": "abandoned", + "body": "Closes #101", + "merged": False, + "merged_at": None, + "head": {"ref": "feat/issue-101", "sha": "badcafe"}, + }, + ], + [], # open pulls + ] + + with patch("mcp_server._remote_branch_exists", return_value=True): + with patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ): + result = mcp_server.gitea_reconcile_merged_cleanups( + dry_run=True, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["dry_run"]) + entry_numbers = [e["issue_number"] for e in result["entries"]] + self.assertEqual(entry_numbers, [100]) + self.assertNotIn(101, entry_numbers) + + @patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "prgs-reconciler", + # Force preflight purity to evaluate (disable test short-circuit). + "GITEA_TEST_PORCELAIN": "", + }, + clear=True, + ) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) + + with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT): + with patch( + "mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value="abc123", + ): + with patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "abc123", + "porcelain_status": "", + }, + ): + try: + mcp_server.verify_preflight_purity( + remote="prgs", + task="reconcile_merged_cleanups", + ) + except RuntimeError as e: + self.fail( + "verify_preflight_purity raised RuntimeError " + f"unexpectedly for reconciler: {e}" + ) + + @patch.dict( + os.environ, + {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, + clear=True, + ) + @patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_role_remains_blocked_on_root_checkout(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="author", + resolved_task="reconcile_merged_cleanups", + ) + + with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT): + with patch( + "mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value="abc123", + ): + with patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "abc123", + "porcelain_status": "", + }, + ): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_preflight_purity( + remote="prgs", + task="reconcile_merged_cleanups", + ) + message = str(ctx.exception) + self.assertTrue( + "stable control checkout" in message + or "author mutation blocked" in message + or "branches/" in message, + msg=message, + ) + + @patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True) + @patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_open_unmerged_pr_heads_are_not_safe_cleanup_targets(self, _profile): + """AC5: active/unmerged author work must remain blocked from cleanup.""" + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", + resolved_role="reconciler", + resolved_task="reconcile_merged_cleanups", + ) + + open_pr = { + "number": 200, + "title": "active work", + "body": "Closes #200", + "merged": False, + "state": "open", + "head": {"ref": "feat/issue-200", "sha": "livehead1"}, + } + # Same head branch still open on another PR while a closed/merged PR + # used the same branch name historically — open head must block delete. + closed_merged = { + "number": 199, + "title": "older merge same branch name", + "body": "Closes #199", + "merged": True, + "merged_at": "2026-07-01T12:00:00Z", + "merge_commit_sha": "deadbeef", + "head": {"ref": "feat/issue-200", "sha": "oldhead99"}, + } + self.mock_api.side_effect = [ + [closed_merged], # closed + [open_pr], # open + ] + + with patch("mcp_server._remote_branch_exists", return_value=True): + with patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ): + result = mcp_server.gitea_reconcile_merged_cleanups( + dry_run=True, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertEqual(len(result["entries"]), 1) + remote_assessment = result["entries"][0].get("remote_branch") or {} + self.assertFalse( + remote_assessment.get("safe_to_delete_remote"), + msg=f"open head must block remote delete: {remote_assessment}", + ) + + +if __name__ == "__main__": + unittest.main() 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_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_state_handoff_ledger.py b/tests/test_state_handoff_ledger.py new file mode 100644 index 0000000..08f5c5f --- /dev/null +++ b/tests/test_state_handoff_ledger.py @@ -0,0 +1,243 @@ +"""Tests for canonical state handoff ledger (#494).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from final_report_validator import assess_final_report_validator # noqa: E402 +from state_handoff_ledger import ( # noqa: E402 + ISSUE_STATE_FIELDS, + assess_contradictory_state_handoff, + assess_final_report_next_action_handoff, + assess_state_comment, + assess_state_handoff_ledger_report, + parse_state_comment, + render_issue_state_comment, + render_pr_state_comment, + render_queue_controller_report, +) + + +def _work_issue_handoff(**overrides): + lines = [ + "## Controller Handoff", + "", + "- Task: work-issue", + "- Repo: Scaled-Tech-Consulting/Gitea-Tools", + "- Role: author", + "- Identity: jcwalker3 / prgs-author", + "- Selected issue: #494", + "- Current status: implementation complete; PR opened awaiting review", + "- Next actor: reviewer", + "- Next action: review PR at pinned head", + "- Next prompt: Review PR #500 for issue #494 at current head in reviewer worktree", + "- Safe next action: switch to reviewer session", + "- External-state mutations: none", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + needle = f"- {key}:" + if needle in text: + prefix = text.split(needle)[0] + rest = text.split(needle, 1)[1] + suffix = rest.split("\n", 1)[1] if "\n" in rest else "" + text = f"{prefix}{needle} {value}\n{suffix}".rstrip() + else: + text += f"\n- {key}: {value}" + return text + + +class TestStateCommentTemplates(unittest.TestCase): + def test_render_issue_state_includes_all_fields(self): + body = render_issue_state_comment( + STATE="issue_ready_for_author", + NEXT_ACTOR="author", + NEXT_ACTION="lock and implement", + NEXT_PROMPT="Find issue #494 and open a PR", + LAST_UPDATED_BY="jcwalker3", + ) + parsed = parse_state_comment(body) + for field in ISSUE_STATE_FIELDS: + self.assertIn(field, parsed) + + def test_render_pr_state_parses(self): + body = render_pr_state_comment( + STATE="needs_review", + NEXT_ACTOR="reviewer", + NEXT_ACTION="review at head", + NEXT_PROMPT="Review PR #500", + ISSUE="#494", + HEAD_SHA="a" * 40, + LAST_UPDATED_BY="sysadmin", + ) + parsed = parse_state_comment(body) + self.assertEqual(parsed["STATE"], "needs_review") + self.assertEqual(parsed["NEXT_ACTOR"], "reviewer") + + def test_queue_controller_template(self): + body = render_queue_controller_report( + QUEUE_STATE="open_prs_need_review", + SELECTED_OBJECT="PR #500", + SELECTED_OBJECT_TYPE="pr", + NEXT_ACTOR="reviewer", + NEXT_ACTION="start review session", + NEXT_PROMPT="Review PR #500", + LAST_UPDATED_BY="controller", + ) + result = assess_state_comment(body, kind="queue_controller") + self.assertTrue(result["complete"]) + + +class TestStateCommentValidation(unittest.TestCase): + def test_complete_issue_state_comment(self): + body = render_issue_state_comment( + STATE="in_progress", + NEXT_ACTOR="author", + NEXT_ACTION="push branch", + NEXT_PROMPT="Continue issue #494", + STATUS="open", + RELATED_PRS="none", + BRANCH="feat/issue-494-state-handoff-ledger", + HEAD_SHA="b" * 40, + VALIDATION="pytest passed", + BLOCKERS="none", + DUPLICATES_OR_SUPERSEDES="none", + LAST_UPDATED_BY="jcwalker3", + ) + result = assess_state_comment(body, kind="issue_state") + self.assertTrue(result["complete"]) + self.assertFalse(result["block"]) + + def test_missing_fields_blocked(self): + body = "STATE: open\nNEXT_ACTOR: author" + result = assess_state_comment(body, kind="issue_state") + self.assertFalse(result["complete"]) + self.assertIn("NEXT_ACTION", result["missing_fields"]) + + def test_discussion_requires_five_comments_by_default(self): + body = render_issue_state_comment( + STATE="collecting_feedback", + NEXT_ACTOR="controller", + NEXT_ACTION="facilitate discussion", + NEXT_PROMPT="Add acceptance criteria", + STATUS="open", + RELATED_PRS="none", + BRANCH="none", + HEAD_SHA="none", + VALIDATION="none", + BLOCKERS="none", + DUPLICATES_OR_SUPERSEDES="none", + LAST_UPDATED_BY="controller", + ) + # Re-map to discussion fields manually for comment-count test + from state_handoff_ledger import render_discussion_state_comment + + disc = render_discussion_state_comment( + STATE="collecting_feedback", + NEXT_ACTOR="controller", + NEXT_ACTION="wait for comments", + NEXT_PROMPT="Continue discussion", + COMMENT_COUNT="2", + SUBSTANTIVE_COMMENTS="yes", + URGENCY="normal", + BLOCKERS="none", + LAST_UPDATED_BY="controller", + ) + result = assess_state_comment(disc, kind="discussion_state") + self.assertFalse(result["complete"]) + self.assertTrue(any("5" in reason for reason in result["reasons"])) + + def test_discussion_urgent_exception(self): + from state_handoff_ledger import render_discussion_state_comment + + disc = render_discussion_state_comment( + STATE="ready_for_summary", + NEXT_ACTOR="controller", + NEXT_ACTION="summarize", + NEXT_PROMPT="Post summary", + COMMENT_COUNT="2", + SUBSTANTIVE_COMMENTS="yes", + URGENCY="urgent", + BLOCKERS="none", + LAST_UPDATED_BY="controller", + ) + result = assess_state_comment(disc, kind="discussion_state") + self.assertTrue(result["complete"]) + + +class TestFinalReportNextAction(unittest.TestCase): + def test_complete_next_action_handoff(self): + report = _work_issue_handoff() + result = assess_final_report_next_action_handoff(report) + self.assertTrue(result["complete"]) + self.assertFalse(result["block"]) + + def test_missing_next_prompt_blocked(self): + report = _work_issue_handoff(**{"Next prompt": "none"}) + result = assess_final_report_next_action_handoff(report) + self.assertFalse(result["complete"]) + self.assertIn("Next prompt", result["missing_fields"]) + + def test_missing_handoff_section_blocked(self): + result = assess_final_report_next_action_handoff("no handoff here") + self.assertTrue(result["block"]) + + +class TestContradictoryState(unittest.TestCase): + def test_ready_to_merge_without_approval_blocked(self): + report = _work_issue_handoff( + **{ + "Current status": "PR ready to merge", + "Review decision": "not attempted", + } + ) + result = assess_contradictory_state_handoff(report) + self.assertTrue(result["block"]) + + def test_ready_to_merge_with_approval_allowed(self): + report = _work_issue_handoff( + **{ + "Current status": "PR ready to merge", + "Review decision": "approve", + } + ) + result = assess_contradictory_state_handoff(report) + self.assertFalse(result["block"]) + + def test_issue_done_without_pr_proof_blocked(self): + report = _work_issue_handoff(**{"Current status": "issue complete"}) + result = assess_contradictory_state_handoff(report) + self.assertTrue(result["block"]) + + def test_external_none_with_lock_activity_blocked(self): + report = _work_issue_handoff( + **{ + "External-state mutations": "none", + } + ) + report += "\n- Issue mutations: gitea_lock_issue adopted branch" + result = assess_contradictory_state_handoff(report) + self.assertTrue(result["block"]) + + def test_discussion_complete_without_summary_blocked(self): + report = _work_issue_handoff(**{"Current status": "discussion complete"}) + result = assess_contradictory_state_handoff(report) + self.assertTrue(result["block"]) + + +class TestFinalReportValidatorIntegration(unittest.TestCase): + def test_work_issue_validator_flags_missing_next_prompt(self): + report = _work_issue_handoff(**{"Next prompt": ""}) + result = assess_final_report_validator(report, "work_issue") + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertIn("shared.state_handoff_next_action", rule_ids) + + def test_work_issue_validator_accepts_complete_handoff(self): + report = _work_issue_handoff() + result = assess_state_handoff_ledger_report(report) + self.assertTrue(result["complete"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_runtime_health.py b/tests/test_webui_runtime_health.py new file mode 100644 index 0000000..9fda3ee --- /dev/null +++ b/tests/test_webui_runtime_health.py @@ -0,0 +1,88 @@ +"""Tests for web UI runtime health dashboard (#430).""" +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict + + +def _mock_identity(_host: str): + return "jcwalker3", None + + +def _mock_git_sync(_repo: Path, _remote: str, _branch: str): + return "localsha123456", "remoteshaabcdef", 2 + + +class TestRuntimeHealthLoader(unittest.TestCase): + def test_role_kind_author(self): + allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"] + forbidden = ["gitea.pr.merge", "gitea.pr.approve"] + self.assertEqual(_role_kind(allowed, forbidden), "author") + + def test_snapshot_with_mocks(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self.assertEqual(snapshot.project_id, "gitea-tools") + self.assertEqual(snapshot.authenticated_username, "jcwalker3") + self.assertEqual(snapshot.commits_behind_master, 2) + self.assertIsNotNone(snapshot.stale_runtime_warning) + self.assertGreaterEqual(len(snapshot.workflow_hashes), 3) + self.assertGreaterEqual(len(snapshot.schema_hashes), 2) + self.assertIn("shell_use_allowed", snapshot.shell_health) + + def test_snapshot_dict_export(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + data = snapshot_to_dict(snapshot) + self.assertEqual(data["authenticated_username"], "jcwalker3") + self.assertEqual(data["commits_behind_master"], 2) + self.assertTrue(data["stale_runtime_warning"]) + + +class TestRuntimeRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + self.snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self._patch = mock.patch( + "webui.app.load_runtime_snapshot", + return_value=self.snapshot, + ) + self._patch.start() + + def tearDown(self): + self._patch.stop() + + def test_runtime_page_renders_profile_and_warning(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + self.assertIn("Active profile", response.text) + self.assertIn("Stale runtime warning", response.text) + self.assertIn("Workflow hashes", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_runtime_json(self): + response = self.client.get("/api/runtime") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["project_id"], "gitea-tools") + self.assertEqual(data["role_kind"], self.snapshot.role_kind) + self.assertEqual(data["commits_behind_master"], 2) + + +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 953b368..e5b71b6 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Operator console", response.text) self.assertIn("Read-only MVP", response.text) + def test_runtime_is_implemented(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + def test_route_stubs_render(self): - for path in ("/runtime", "/audit"): + for path in ("/audit",): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) diff --git a/webui/app.py b/webui/app.py index 4f4cb0f..fb1aab2 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,8 +16,10 @@ from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_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 +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.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"}) @@ -63,7 +65,7 @@ async def queue(_request: Request) -> HTMLResponse: async def api_queue(_request: Request) -> JSONResponse: - return JSONResponse(snapshot_to_dict(load_queue_snapshot())) + return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot())) async def projects(_request: Request) -> HTMLResponse: @@ -122,10 +124,12 @@ async def api_prompts(_request: Request) -> JSONResponse: async def runtime(_request: Request) -> HTMLResponse: - return _stub_page( - "Runtime", - "Runtime health will report MCP profile, preflight, and stale-server signals.", - ) + snapshot = load_runtime_snapshot() + return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot))) + + +async def api_runtime(_request: Request) -> JSONResponse: + return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot())) async def audit(_request: Request) -> HTMLResponse: @@ -176,6 +180,7 @@ def create_app() -> Starlette: Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]), 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("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), diff --git a/webui/lease_loader.py b/webui/lease_loader.py index 717feef..318a46f 100644 --- a/webui/lease_loader.py +++ b/webui/lease_loader.py @@ -11,7 +11,8 @@ from urllib.parse import urlparse from gitea_auth import api_fetch_page, get_auth_header, repo_api_url from issue_claim_heartbeat import build_claim_inventory -from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock +from issue_lock_provenance import ISSUE_LOCK_FILE +from merged_cleanup_reconcile import read_issue_lock from webui.project_registry import ProjectRecord, load_registry from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs diff --git a/webui/runtime_health.py b/webui/runtime_health.py new file mode 100644 index 0000000..8f3b5d6 --- /dev/null +++ b/webui/runtime_health.py @@ -0,0 +1,320 @@ +"""Read-only MCP/runtime health snapshot for the web UI (#430).""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +import gitea_config +import reconciler_profile +from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url + +from native_mcp_preference import shell_health_status + +from webui.project_registry import ProjectRecord, load_registry + +_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload" +_HOT_RELOAD_ISSUE = "#420" + + +@dataclass(frozen=True) +class FileHash: + label: str + path: str + sha256: str | None + error: str | None = None + + +@dataclass(frozen=True) +class RuntimeSnapshot: + project_id: str + repo_root: str + remote: str + host: str + profile_name: str + role_kind: str + config_model: str + profile_mode: str + profile_source: str + authenticated_username: str | None + identity_error: str | None + repo_sha: str | None + remote_master_sha: str | None + commits_behind_master: int | None + stale_runtime_warning: str | None + shell_health: dict[str, Any] + workflow_hashes: tuple[FileHash, ...] + schema_hashes: tuple[FileHash, ...] + restart_guidance: str + fetch_error: str | None = None + + +def _repo_root() -> Path: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return Path(override).resolve() + return Path(__file__).resolve().parent.parent + + +def _host_from_url(remote_host: str) -> str: + parsed = urlparse(remote_host.strip()) + return parsed.netloc or remote_host.strip().rstrip("/") + + +def _remote_name_for_host(host: str) -> str: + for name, profile in REMOTES.items(): + if profile.get("host") == host: + return name + return "custom" + + +def _config_model(config: dict | None) -> str: + if config is None: + return "env-only" + version = config.get("version") + if version == 1: + return "v1" + if version == 2: + if "contexts" in config or config.get("shape") == "contexts": + return "v2-contexts" + return "v2-environments" + return f"unknown-version-{version}" + + +def _profile_source() -> str: + if os.environ.get("GITEA_PROFILE_NAME"): + return "env var" + if gitea_config.selected_profile_name(): + return "config file profile" + return "default" + + +def _role_kind(allowed: list[str], forbidden: list[str]) -> str: + if reconciler_profile.is_reconciler_profile(allowed, forbidden): + return "reconciler" + + def can(op: str) -> bool: + return gitea_config.check_operation(op, allowed, forbidden)[0] + + review = can("gitea.pr.approve") or can("gitea.pr.merge") + author = can("gitea.pr.create") or can("gitea.branch.push") + reconciler = can("gitea.pr.close") and not review and not author + if review and author: + return "mixed" + if review: + return "reviewer" + if reconciler: + return "reconciler" + if author: + return "author" + return "limited" + + +def _sha256_file(path: Path) -> FileHash: + label = path.name + try: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return FileHash(label=label, path=str(path), sha256=digest) + except OSError as exc: + return FileHash(label=label, path=str(path), sha256=None, error=str(exc)) + + +def _run_git(repo: Path, *args: str) -> str | None: + try: + completed = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]: + local_sha = _run_git(repo, "rev-parse", "HEAD") + remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}") + behind = None + if local_sha and remote_sha and local_sha != remote_sha: + count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}") + if count is not None and count.isdigit(): + behind = int(count) + return local_sha, remote_sha, behind + + +def _resolve_username(host: str) -> tuple[str | None, str | None]: + auth = get_auth_header(host) + if not auth: + return None, f"Gitea credentials unavailable for {host}" + try: + data = api_request("GET", gitea_url(host, "/api/v1/user"), auth) + except Exception as exc: # noqa: BLE001 — operator-visible identity errors + return None, f"Identity lookup failed: {exc}" + username = (data or {}).get("login") + if not username: + return None, "Authenticated identity could not be resolved" + return str(username), None + + +def _stale_warning(commits_behind: int | None) -> str | None: + if commits_behind is None or commits_behind <= 0: + return None + return ( + f"Local checkout is {commits_behind} commit(s) behind remote master. " + "Merged safety-gate changes may require an MCP server restart or profile " + f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}." + ) + + +def load_runtime_snapshot( + project_id: str | None = None, + *, + resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None, + git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None, +) -> RuntimeSnapshot: + """Build a read-only runtime health snapshot for the default registry project.""" + registry = load_registry() + project: ProjectRecord | None = None + if project_id: + for entry in registry.projects: + if entry.id == project_id: + project = entry + break + else: + project = registry.projects[0] if registry.projects else None + + if project is None: + return RuntimeSnapshot( + project_id=project_id or "", + repo_root=str(_repo_root()), + remote="", + host="", + profile_name="", + role_kind="", + config_model="", + profile_mode="", + profile_source="", + authenticated_username=None, + identity_error="project not found in registry", + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health={}, + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error="project not found in registry", + ) + + repo = _repo_root() + host = _host_from_url(project.remote_host) + remote = _remote_name_for_host(host) + profile = get_profile() + allowed = profile.get("allowed_operations") or [] + forbidden = profile.get("forbidden_operations") or [] + config = None + try: + config = gitea_config.load_config() + except gitea_config.ConfigError as exc: + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=profile.get("profile_name") or "", + role_kind=_role_kind(allowed, forbidden), + config_model="config-error", + profile_mode="unknown", + profile_source=_profile_source(), + authenticated_username=None, + identity_error=str(exc), + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health=shell_health_status(), + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error=str(exc), + ) + + identity_fn = resolve_username or _resolve_username + username, identity_error = identity_fn(host) + + git_fn = git_sync or _git_sync_status + remote_ref = "prgs" if remote == "prgs" else "origin" + local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch) + + workflow_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.workflow_paths.values() + ) + schema_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.schema_paths.values() + ) + + switching = gitea_config.is_runtime_switching_enabled() + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=str(profile.get("profile_name") or ""), + role_kind=_role_kind(allowed, forbidden), + config_model=_config_model(config), + profile_mode="dynamic-profile" if switching else "static-profile", + profile_source=_profile_source(), + authenticated_username=username, + identity_error=identity_error, + repo_sha=local_sha, + remote_master_sha=remote_sha, + commits_behind_master=behind, + stale_runtime_warning=_stale_warning(behind), + shell_health=shell_health_status(), + workflow_hashes=workflow_hashes, + schema_hashes=schema_hashes, + restart_guidance=_RESTART_DOCS, + ) + + +def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]: + def _hash_row(item: FileHash) -> dict[str, Any]: + return { + "label": item.label, + "path": item.path, + "sha256": item.sha256, + "error": item.error, + } + + return { + "project_id": snapshot.project_id, + "repo_root": snapshot.repo_root, + "remote": snapshot.remote, + "host": snapshot.host, + "profile_name": snapshot.profile_name, + "role_kind": snapshot.role_kind, + "config_model": snapshot.config_model, + "profile_mode": snapshot.profile_mode, + "profile_source": snapshot.profile_source, + "authenticated_username": snapshot.authenticated_username, + "identity_error": snapshot.identity_error, + "repo_sha": snapshot.repo_sha, + "remote_master_sha": snapshot.remote_master_sha, + "commits_behind_master": snapshot.commits_behind_master, + "stale_runtime_warning": snapshot.stale_runtime_warning, + "shell_health": snapshot.shell_health, + "workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes], + "schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes], + "restart_guidance": snapshot.restart_guidance, + "fetch_error": snapshot.fetch_error, + } \ No newline at end of file diff --git a/webui/runtime_views.py b/webui/runtime_views.py new file mode 100644 index 0000000..b057248 --- /dev/null +++ b/webui/runtime_views.py @@ -0,0 +1,92 @@ +"""HTML views for MCP runtime health (#430).""" + +from __future__ import annotations + +import html + +from webui.runtime_health import FileHash, RuntimeSnapshot + + +def _hash_rows(items: tuple[FileHash, ...]) -> str: + if not items: + return "<p class='muted'>No hashes available.</p>" + rows = [] + for item in items: + digest = item.sha256 or item.error or "unavailable" + rows.append( + "<tr>" + f"<td>{html.escape(item.label)}</td>" + f"<td><code>{html.escape(item.path)}</code></td>" + f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>" + "</tr>" + ) + return ( + "<table class='registry'><thead><tr>" + "<th>Artifact</th><th>Path</th><th>SHA-256</th>" + "</tr></thead><tbody>" + f"{''.join(rows)}</tbody></table>" + ) + + +def render_runtime_page(snapshot: RuntimeSnapshot) -> str: + error_block = "" + if snapshot.fetch_error: + error_block = ( + '<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> ' + f"{html.escape(snapshot.fetch_error)}</p></div>" + ) + + identity = snapshot.authenticated_username or "unresolved" + if snapshot.identity_error: + identity = f"unresolved ({snapshot.identity_error})" + + stale_block = "" + if snapshot.stale_runtime_warning: + stale_block = ( + '<div class="stub"><p><strong>Stale runtime warning:</strong> ' + f"{html.escape(snapshot.stale_runtime_warning)}</p></div>" + ) + + shell = snapshot.shell_health or {} + shell_summary = ( + f"shell_use_allowed={shell.get('shell_use_allowed')} · " + f"failures={shell.get('consecutive_spawn_failures')} · " + f"hard_stopped={shell.get('hard_stopped')}" + ) + + return ( + "<h2>Runtime health</h2>" + f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · " + f"host <code>{html.escape(snapshot.host)}</code> · " + f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>" + f"{error_block}" + f"{stale_block}" + "<h3>Active profile</h3>" + "<table class='detail'>" + f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>" + f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>" + f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>" + f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>" + f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} " + f"({html.escape(snapshot.profile_source)})</td></tr>" + "</table>" + "<h3>Server code sync</h3>" + "<table class='detail'>" + f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>" + f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>" + f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>" + f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>" + "</table>" + "<h3>Shell health</h3>" + f"<p class='meta'>{html.escape(shell_summary)}</p>" + f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>" + "<h3>Workflow hashes</h3>" + f"{_hash_rows(snapshot.workflow_hashes)}" + "<h3>Schema hashes</h3>" + f"{_hash_rows(snapshot.schema_hashes)}" + "<h3>Restart / reload</h3>" + "<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator " + "workflow. Related issue: <code>#420</code>. Guidance: " + f"<code>{html.escape(snapshot.restart_guidance)}</code></p>" + "<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>" + ) \ No newline at end of file