Merge remote-tracking branch 'prgs/master' into feat/issue-434-gated-actions

# Conflicts:
#	docs/webui-local-dev.md
#	tests/test_webui_skeleton.py
#	webui/app.py
This commit is contained in:
2026-07-09 09:38:20 -04:00
85 changed files with 7811 additions and 259 deletions
+1
View File
@@ -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 |
+408
View File
@@ -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 = (
"<!-- mcp-review-lease:v1 -->",
"<!-- mcp-conflict-fix-lease:v1 -->",
"<!-- gitea-issue-claim-heartbeat:v1 -->",
)
_WORKFLOW_TRIGGERS = re.compile(
r"\b(?:"
r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|"
r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|"
r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|"
r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|"
r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|"
r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|"
r"merge\s+ready|ready\s+for\s+merge"
r")\b",
re.IGNORECASE,
)
_CANONICAL_HEADINGS = (
"## Canonical Issue State",
"## Canonical PR State",
"## Canonical Discussion Summary",
)
_FIELD_RE = re.compile(
r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$",
re.MULTILINE,
)
_VAGUE_NEXT_ACTIONS = frozenset({
"continue",
"handle this",
"fix it",
"follow up",
"follow-up",
"see above",
"see review",
"tbd",
"todo",
"as needed",
"proceed",
"next steps",
"will check",
"investigate",
})
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
_PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE)
_APPROVAL_PROOF_RE = re.compile(
r"\b(?:approved|approval_at_current_head|APPROVE)\b",
re.IGNORECASE,
)
_UNBLOCK_RE = re.compile(
r"\b(?:unblock|until|after|once|when|requires?|must)\b",
re.IGNORECASE,
)
def _parse_fields(body: str) -> dict[str, str]:
"""Parse KEY: value fields, including values continued on following lines."""
fields: dict[str, str] = {}
current_key: str | None = None
current_lines: list[str] = []
def _flush() -> None:
nonlocal current_key, current_lines
if current_key is not None:
fields[current_key] = "\n".join(current_lines).strip()
current_key = None
current_lines = []
for line in (body or "").splitlines():
if line.startswith("## "):
_flush()
continue
match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line)
if match:
_flush()
current_key = match.group(1).strip().upper()
rest = match.group(2)
current_lines = [rest] if rest else []
elif current_key is not None:
current_lines.append(line)
_flush()
return fields
def _is_machine_generated(body: str) -> bool:
text = body or ""
return any(marker in text for marker in _MACHINE_MARKERS)
def _has_canonical_heading(body: str) -> bool:
return any(h in (body or "") for h in _CANONICAL_HEADINGS)
def is_workflow_changing_comment(body: str) -> bool:
"""True when comment text implies a workflow/state transition."""
text = (body or "").strip()
if not text:
return False
if _is_machine_generated(text):
return False
if _has_canonical_heading(text):
return True
if _WORKFLOW_TRIGGERS.search(text):
return True
fields = _parse_fields(text)
if "STATE" in fields or "WHO_IS_NEXT" in fields:
return True
return False
def infer_comment_context(body: str, *, explicit: str | None = None) -> str:
if explicit:
return explicit
text = body or ""
if "## Canonical Discussion Summary" in text:
return "discussion_summary"
if "## Canonical PR State" in text:
return "pr_comment"
if "## Canonical Issue State" in text:
return "issue_comment"
fields = _parse_fields(text)
if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"):
return "supersession"
if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")):
return "pr_comment"
return "issue_comment"
def _required_fields_for_context(context: str) -> tuple[str, ...]:
if context in ("pr_comment", "pr_review"):
return _PR_REQUIRED
if context == "supersession":
return _SUPERSESSION_REQUIRED
if context == "discussion_summary":
return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS")
return _ISSUE_REQUIRED
def _is_vague_next_action(value: str) -> bool:
normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
normalized = normalized.rstrip(".")
if not normalized:
return True
if normalized in _VAGUE_NEXT_ACTIONS:
return True
if len(normalized) < 12:
return True
return False
def _next_prompt_ok(value: str) -> bool:
text = (value or "").strip()
if len(text) < 40:
return False
if text.lower() in {"n/a", "none", "tbd", "todo"}:
return False
return True
def _state_value(fields: dict[str, str]) -> str:
return (fields.get("STATE") or "").strip().lower()
def _suggested_template(context: str) -> str:
if context == "pr_comment" or context == "pr_review":
return (
"## Canonical PR State\n\n"
"STATE:\n"
"WHO_IS_NEXT:\n"
"NEXT_ACTION:\n"
"NEXT_PROMPT:\n"
"```text\n<paste-ready prompt>\n```\n"
"WHAT_HAPPENED:\n"
"WHY:\n"
"ISSUE:\n"
"HEAD_SHA:\n"
"REVIEW_STATUS:\n"
"MERGE_READY:\n"
"BLOCKERS:\n"
"VALIDATION:\n"
"LAST_UPDATED_BY:\n"
)
if context == "supersession":
return (
"## Canonical PR State\n\n"
"STATE:\nsuperseded\n"
"WHO_IS_NEXT:\nreconciler\n"
"NEXT_ACTION:\n"
"NEXT_PROMPT:\n"
"WHY:\n"
"CANONICAL_ITEM:\n"
"SUPERSEDED_ITEM:\n"
"CLOSE_OR_KEEP_OPEN:\n"
)
if context == "discussion_summary":
return (
"## Canonical Discussion Summary\n\n"
"STATE:\n"
"WHO_IS_NEXT:\n"
"DECISION:\n"
"WHY:\n"
"SUBSTANTIVE_COMMENTS:\n"
"NEXT_ACTION:\n"
"NEXT_PROMPT:\n"
)
return (
"## Canonical Issue State\n\n"
"STATE:\n"
"WHO_IS_NEXT:\n"
"NEXT_ACTION:\n"
"NEXT_PROMPT:\n"
"```text\n<paste-ready prompt>\n```\n"
"WHAT_HAPPENED:\n"
"WHY:\n"
"RELATED_PRS:\n"
"BLOCKERS:\n"
"VALIDATION:\n"
"LAST_UPDATED_BY:\n"
)
def _build_correction_message(
*,
missing_fields: list[str],
vague_fields: list[str],
extra_reasons: list[str],
context: str,
) -> str:
parts = [
"Canonical comment validation failed (fail closed before posting).",
]
if missing_fields:
parts.append("Missing fields: " + ", ".join(missing_fields) + ".")
if vague_fields:
parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".")
parts.extend(extra_reasons)
parts.append("Fill the suggested template and retry.")
parts.append("Suggested template:\n" + _suggested_template(context))
return "\n".join(parts)
def assess_canonical_comment(
body: str,
*,
context: str | None = None,
force_workflow: bool = False,
) -> dict[str, Any]:
"""Validate outgoing comment text before a Gitea mutation."""
text = (body or "").strip()
ctx = infer_comment_context(text, explicit=context)
if not text:
return {
"allowed": True,
"is_workflow_comment": False,
"context": ctx,
"missing_fields": [],
"vague_fields": [],
"correction_message": "",
"suggested_template": "",
}
if _is_machine_generated(text):
return {
"allowed": True,
"is_workflow_comment": False,
"context": ctx,
"missing_fields": [],
"vague_fields": [],
"correction_message": "",
"suggested_template": "",
}
workflow = force_workflow or is_workflow_changing_comment(text)
if not workflow:
return {
"allowed": True,
"is_workflow_comment": False,
"context": ctx,
"missing_fields": [],
"vague_fields": [],
"correction_message": "",
"suggested_template": "",
}
fields = _parse_fields(text)
required = _required_fields_for_context(ctx)
missing = [name for name in required if not (fields.get(name) or "").strip()]
vague: list[str] = []
extra: list[str] = []
who = (fields.get("WHO_IS_NEXT") or "").strip().lower()
if who and who not in VALID_ROLES:
vague.append("WHO_IS_NEXT")
extra.append(
f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}."
)
next_action = fields.get("NEXT_ACTION") or ""
if next_action and _is_vague_next_action(next_action):
vague.append("NEXT_ACTION")
next_prompt = fields.get("NEXT_PROMPT") or ""
if not _next_prompt_ok(next_prompt):
if "NEXT_PROMPT" not in missing:
vague.append("NEXT_PROMPT")
state = _state_value(fields)
blockers = (fields.get("BLOCKERS") or "").strip()
if "blocked" in state:
if not blockers or blockers.lower() in {"none", "n/a"}:
missing.append("BLOCKERS (unblock condition)")
elif not _UNBLOCK_RE.search(blockers):
vague.append("BLOCKERS")
extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.")
if "superseded" in state:
canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip()
superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip()
if not canon and "CANONICAL_ITEM" not in missing:
missing.append("CANONICAL_ITEM")
if not superseded and "SUPERSEDED_ITEM" not in missing:
missing.append("SUPERSEDED_ITEM")
if "ready-to-merge" in state or "ready to merge" in state:
head_sha = fields.get("HEAD_SHA") or ""
merge_ready = fields.get("MERGE_READY") or ""
review_status = fields.get("REVIEW_STATUS") or ""
validation = fields.get("VALIDATION") or ""
proof_blob = " ".join((head_sha, merge_ready, review_status, validation))
has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob))
has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob))
if not has_sha or not has_approval:
extra.append(
"ready-to-merge STATE requires approval proof and HEAD_SHA in "
"REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION."
)
if ctx in ("pr_comment", "pr_review"):
head_sha = (fields.get("HEAD_SHA") or "").strip()
if not head_sha or not _SHORT_SHA_RE.search(head_sha):
if "HEAD_SHA" not in missing:
missing.append("HEAD_SHA")
if ctx == "issue_comment" and _PR_REF_RE.search(text):
related = (fields.get("RELATED_PRS") or "").strip()
if not related or related.lower() in {"none", "n/a", "-"}:
missing.append("RELATED_PRS")
allowed = not missing and not vague and not extra
correction = ""
if not allowed:
correction = _build_correction_message(
missing_fields=missing,
vague_fields=vague,
extra_reasons=extra,
context=ctx,
)
return {
"allowed": allowed,
"is_workflow_comment": True,
"context": ctx,
"missing_fields": missing,
"vague_fields": vague,
"extra_reasons": extra,
"correction_message": correction,
"suggested_template": _suggested_template(ctx) if not allowed else "",
}
+171
View File
@@ -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"],
}
+213
View File
@@ -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:v1 -->"
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",
)
+266
View File
@@ -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
),
}
+53 -1
View File
@@ -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)
+183
View File
@@ -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:
<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 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:
<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 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:
<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>
```
## 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.
+142
View File
@@ -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: <Type>
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
@@ -0,0 +1,43 @@
# Two-comment workflow examples (#507)
Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads.
See `thread_state_ledger_examples.py` for machine-checked fixtures.
## Approved review posted
**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with
`gitea_submit_pr_review → APPROVED review posted to Gitea`.
**Ledger** (concise):
```markdown
[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea
What is true now:
- PR state: open
- Server-side decision state: APPROVED review posted to Gitea
- Local verdict/state: APPROVE verdict prepared locally
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: merger
- Required action: merge on explicit operator command
- Do not do: re-post APPROVE
```
## Approve validated locally but blocked before posting
Ledger must show `no server-side state changed` under server-side decision state and
`APPROVE verdict prepared locally` under local verdict/state.
## Environment / tooling blocker
Ledger blocker classification: `environment/tooling blocker`. Mutation ledger:
`none — no server-side state changed`.
## Stale head blocker
Ledger: `approval_at_current_head is false`; classification `stale head`.
Do not do: merge with stale approval.
+12 -1
View File
@@ -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": "<path-to-profiles.json>",
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
}
},
"gitea-merger": {
"command": "<path-to>/venv/bin/python3",
"args": ["<path-to>/mcp_server.py"],
"env": {
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
"GITEA_MCP_PROFILE": "<merger-profile-name>"
}
}
}
}
+2 -1
View File
@@ -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
+88 -24
View File
@@ -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.
+158 -10
View File
@@ -7,6 +7,11 @@ package of the MCP Control Plane: creating issues, implementing them, opening
and reviewing pull requests, merging, and closing out — safely and
reproducibly.
Canonical state comments for durable issue/PR/discussion continuation are
documented in [`canonical-state-comments.md`](canonical-state-comments.md).
Use them when a workflow-changing comment needs to leave the next actor, next
action, and paste-ready prompt in Gitea.
> For the **project-agnostic** version of these operating rules (issue-first,
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
> that can be copied into any repository, see the reusable skill
@@ -28,6 +33,8 @@ audit logging). See [Related documents](#related-documents).
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
> for step-by-step instructions. This replaces long pasted operator prompts for
> the standard rules; operator prompts still control task-specific scope.
>
> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop.
> See issue #129 for the skill registry design.
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
@@ -408,6 +415,31 @@ The guard blocks when the **control checkout** (repository root) is:
`branches/...` directories are disposable role worktrees; the root checkout is
the stable orchestration surface only.
## Canonical workflow skill names (#551)
Controller prompts and sessions must load the **same** workflow skill wall
regardless of runtime (Claude, Codex, Gemini):
| Name | Role |
|------|------|
| `gitea-workflow` | Primary controller / Codex skill name |
| `llm-project-workflow` | Portable in-repo package |
| `git-pr-workflows` | Legacy alias |
- Inventory: `mcp_list_project_skills` lists all three.
- Preflight: `mcp_check_workflow_skill_preflight` before mutations.
- Codex install: `scripts/install-codex-workflow-skill.sh`
- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md)
If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate.
## No direct-import mutation path (#558)
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
keychain fill from a raw shell to bypass MCP preflight.
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
## Bootstrap Review Path for self-hosted MCP fixes (#557)
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
@@ -634,19 +666,24 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
- **Profile:** issue-manager or author (any profile allowed to create issues).
- **Steps:** create the parent/roadmap issue; create child issues; apply the
minimal label set; link children to the parent.
- **Labels:** new issues should carry one `type:*` label and one `status:*`
label. Discussion-only issues must carry `type:discussion`. See
[`label-taxonomy.md`](label-taxonomy.md).
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
<body>, then create child issues for <list> and link them to the parent.`
### Implement an issue and open a PR
- **Profile:** author.
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
- **Steps:** claim the issue (`status:in-progress`, replacing any old
`status:*` label); create an isolated branch worktree from latest `master`
under `branches/` (`feat/issue-<n>-...` /
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
update tests if behavior changes; run the full suite; commit with an
issue-linked message; open a PR to `master`. **Do not** review or merge your
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
issue-linked message; open a PR to `master`; move the issue to
`status:pr-open`. **Do not** review or merge your own PR. Include an
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
[`llm-agent-sha.md`](llm-agent-sha.md).
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
master. Do not self-review or self-merge.`
@@ -687,10 +724,10 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
### Merge a PR
- **Profile:** merger (allowed to merge; must **not** be the PR author).
- **Steps:** confirm eligibility; require explicit confirmation
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
- **Steps:**
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
@@ -779,7 +816,7 @@ an author.
|---|---|---|---|---|
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails |
| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails |
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
@@ -829,6 +866,27 @@ Never imply full-suite success unless the full-suite command itself passed
(`full_suite_passed: true`). A report that hides a failed or skipped check
is worse than a failing report.
## Canonical Thread Handoff (CTH)
**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff
comment in a Gitea issue or PR thread. See
[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types,
templates, and examples.
Before acting in an issue or PR thread:
1. **Find the latest CTH comment** before doing work.
2. Treat the **latest valid CTH** as the current handoff state.
3. **Post a new CTH** when you finish, block, skip, supersede, request
changes, approve, or hand off.
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
A CTH summarizes workflow state for the next session. Formal Gitea review
verdicts remain authoritative for merge gates — a CTH is not merge approval
by itself.
Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
## Controller Handoff (required, every task)
Every task — implementation, review, merge, triage, documentation,
@@ -861,6 +919,95 @@ touched release state names the exact tag/commit and why. Design debates
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
on the issue, create no branches/PRs, and end the comment with this handoff.
## Two-comment workflow reporting (#507)
After meaningful controller/workflow work, post **two separate Gitea comments**
(not one combined blob):
1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the
next LLM/controller (proof-heavy; may be long).
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
The ledger must answer: what is true now, what changed, what is blocked,
who/what acts next — and must **separate**:
- local verdict/state
- server-side Gitea state
- attempted-but-blocked mutations
- completed mutations
Use precise state phrases (`APPROVED review posted to Gitea`,
`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`,
`no server-side state changed`, `lease attempt blocked`) instead of ambiguous
standalone words (`approved`, `merged`, `ready`, `blocked`, `done`).
The ledger must include a **blocker classification** from:
`code blocker`, `test blocker`, `merge conflict`, `stale head`,
`permission/capability blocker`, `environment/tooling blocker`,
`process/rule blocker`, `queue/lease blocker`,
`duplicate/canonicalization blocker`, `no blocker`.
Templates: [`two-comment-workflow.md`](two-comment-workflow.md).
Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md).
Validation: `thread_state_ledger_validator.py` checks tagged comments at post
time (`gitea_create_issue_comment`) and tagged final reports via
`assess_final_report_validator`. Legacy `## Controller Handoff` final reports
remain valid during transition; the tagged pair is required for new workflow
comments.
Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger
consistency, #505 CTH umbrella, #496 workflow comment gate when merged.
## Canonical comment validation (#496)
Workflow-changing issue/PR/review comments must carry durable next-action
state. Casual discussion is still allowed.
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
**before** posting through:
- `gitea_create_issue_comment`
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
Detection examples:
- **Allowed:** `Thanks, I will check this.`
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
When validation fails, the tool returns `canonical_comment_validation` with
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
`suggested_template`. **No Gitea API call is made.**
Minimum workflow comment fields:
```text
STATE:
WHO_IS_NEXT:
NEXT_ACTION:
NEXT_PROMPT:
WHY:
```
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
`reconciler`, `user`.
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
Special states:
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
- `STATE: ready-to-merge` — requires approval proof and head SHA in
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
Final reports must not claim a comment was posted when
`canonical_comment_validation.allowed` is false (#496 AC14).
## Fail-closed behavior
Before any mutating action the workflow verifies identity, active profile,
@@ -972,6 +1119,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
## Related documents
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
+23
View File
@@ -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.
+35
View File
@@ -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
+123
View File
@@ -0,0 +1,123 @@
# Two-comment workflow: Controller Handoff + Thread State Ledger
After meaningful controller/workflow work, post **two separate Gitea comments**:
1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next
LLM/controller session (proof-heavy; may be long).
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
The ledger is the concise source of truth. The handoff is the detailed
continuation artifact. This complements CTH (#505) and lifecycle state
comments (#494/#495) without replacing them.
## Controller Handoff template
```markdown
[CONTROLLER HANDOFF] PR #___ / Issue #___ — <short title>
Purpose:
This comment is the operational handoff for the next controller/LLM session.
Identity/profile:
- Active profile:
- Authenticated identity:
- Role:
- Self-review / role-conflict proof:
Target:
- Repo:
- Issue:
- PR:
- Branch:
- Pinned head SHA:
- Worktree:
Work performed:
- <step 1>
- <step 2>
Files touched or reviewed:
- `<file>` — <why it matters>
Validation:
- `<command>` → <result>
- Full suite: <result or not run + reason>
Server-side mutation ledger:
- <mutation 1, including tool/action/comment id if available>
- Or: none — no server-side state changed
Local-only changes:
- <worktree created, files edited locally, etc.>
- Or: none
Blockers:
- <none>
- Or: <exact blocker, exact gate, exact reason>
Controller prompt for next session:
```markdown
<ready-to-paste prompt>
```
```
## Thread State Ledger template
```markdown
[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line>
What is true now:
- PR state:
- Issue state:
- Current head SHA:
- Server-side decision state:
- Local verdict/state:
- Latest known validation:
What changed:
- <short summary since prior ledger>
What is blocked:
- Blocker classification: <see list below>
Who/what acts next:
- Next actor:
- Required action:
- Do not do:
- Resume from:
```
### Blocker classifications
- code blocker
- test blocker
- merge conflict
- stale head
- permission/capability blocker
- environment/tooling blocker
- process/rule blocker
- queue/lease blocker
- duplicate/canonicalization blocker
- no blocker
### Precise state language
Prefer:
- `APPROVE verdict prepared locally`
- `APPROVED review posted to Gitea`
- `REQUEST_CHANGES posted to Gitea`
- `merge performed` / `merge not performed`
- `lease acquired` / `lease attempt blocked`
- `server-side state changed` / `no server-side state changed`
Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`,
`done`) without proof and server-side state separation.
## Validation
- `thread_state_ledger_validator.py` — comment and final-report checks
- `gitea_create_issue_comment` — fail-closed gate on tagged comments
- `assess_final_report_validator``shared.two_comment_workflow` rule
Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md)
+27 -5
View File
@@ -45,17 +45,27 @@ Optional environment variables:
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | MCP runtime health and stale detection (#430) |
| `/api/runtime` | JSON runtime health export |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) |
| `/audit` | Report audit paste + validator preview (#431) |
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
| `/worktrees` | Worktree hygiene dashboard (#432) |
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
| `/api/actions` | JSON action registry with capability metadata |
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
| `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export |
All routes are GET-only except registered POST handlers, which still return
`405` with `read-only-mvp` until write paths ship.
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
local validator preview only (no Gitea mutations, no server-side storage).
## Report audit (#431)
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
reuses `final_report_validator` and review schema checks to surface missing
proof fields, wrong validation vocabulary, mutation contradictions, and a
suggested next prompt or issue-comment draft. Task kind can be auto-detected
or selected explicitly.
## Project registry (#427)
@@ -96,6 +106,17 @@ permission, and profile role from `task_capability_map.py` — aligned with
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
MCP tools.
## Worktree hygiene (#432)
`/worktrees` scans local `branches/` directories and registered git worktrees.
Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`,
`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees
referenced by the issue lock file are flagged as anomalies (#404). The page
includes a copy/paste canonical cleanup prompt only — no deletion actions.
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file,
@@ -117,5 +138,6 @@ no tokens or MCP restart actions are exposed.
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
```
+12 -3
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
# Workflow skill mount across runtimes (#551)
## Problem
Controller prompts require **`gitea-workflow`**, but:
- Claude may load `~/.claude/skills/gitea-workflow`
- Codex often has **no** `~/.codex/skills/gitea-workflow`
- The portable package in-repo is `skills/llm-project-workflow`
- `mcp_list_project_skills` historically listed operational guides only, not
the workflow router
Sessions then either **block** incorrectly or **proceed without** the workflow
wall.
## Canonical names (must resolve to the same skill)
| Name | Use |
|------|-----|
| `gitea-workflow` | **Primary** controller / Codex skill name |
| `llm-project-workflow` | Portable in-repo package name |
| `git-pr-workflows` | Legacy alias |
Source of truth: `skills/llm-project-workflow/SKILL.md`
In-repo alias stub: `skills/gitea-workflow/SKILL.md`
## Codex install
From a `branches/` worktree (or any clone of the repo):
```bash
./scripts/install-codex-workflow-skill.sh
# optional:
./scripts/install-codex-workflow-skill.sh --dry-run
./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills"
```
This symlinks the portable package under all three names. **Restart Codex**
after install.
## MCP discovery
- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`,
and `git-pr-workflows`.
- `mcp_get_skill_guide("<name>")` returns the same router steps for each.
- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and
reports Codex mount status.
## Preflight rule
Before any git or Gitea mutation:
1. Call `mcp_check_workflow_skill_preflight`.
2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do
not mutate.
3. Load the skill by **any** canonical name and follow the router.
Missing Codex mount alone does not block if the in-repo skill is present and
loaded via MCP/docs; operators should still install the Codex symlink so
prompt names resolve natively.
## Controller prompts
Prefer:
> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`).
Do not require a name that is only available on one runtime.
+158
View File
@@ -13,6 +13,8 @@ from typing import Any, Callable
import issue_acceptance_gate
import issue_lock_provenance
import reviewer_handoff_consistency
import thread_state_ledger_validator
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
@@ -29,6 +31,7 @@ from validation_status_vocabulary import assess_validation_status_vocabulary
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
"merge_pr",
"reconcile_already_landed",
"author_issue",
"work_issue",
@@ -40,6 +43,8 @@ FINAL_REPORT_TASK_KINDS = frozenset({
_TASK_KIND_ALIASES = {
"review": "review_pr",
"review-merge-pr": "review_pr",
"merge": "merge_pr",
"merge_pr": "merge_pr",
"reconcile-landed-pr": "reconcile_already_landed",
"reconcile_already_landed": "reconcile_already_landed",
"work-issue": "work_issue",
@@ -48,6 +53,7 @@ _TASK_KIND_ALIASES = {
_HANDOFF_ROLE_BY_TASK = {
"review_pr": "review",
"merge_pr": "merger",
"reconcile_already_landed": None,
"author_issue": "author",
"work_issue": "author",
@@ -195,6 +201,23 @@ _PAGINATION_PROOF_RE = re.compile(
re.IGNORECASE,
)
_GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE)
_CANONICAL_VALIDATION_REJECTED_RE = re.compile(
r"canonical comment validation failed|"
r"canonical_comment_validation|"
r'"allowed"\s*:\s*false',
re.IGNORECASE,
)
_COMMENT_POSTED_CLAIM_RE = re.compile(
r"(?:issue comments posted\s*:\s+(?!none\b)\S|"
r"pr comments posted\s*:\s+(?!none\b)\S|"
r"comment_id\s*[:=]\s*\d+|"
r"gitea comment (?:was )?posted|"
r"posted (?:issue|pr|canonical) (?:state )?comment|"
r"comment posted successfully|"
r"mcp/gitea mutations\s*:\s*[^;\n]*comment posted|"
r"reconciliation mutations\s*:\s*[^;\n]*comment posted)",
re.IGNORECASE,
)
_READONLY_DIAG_RE = re.compile(
r"read[- ]only diagnostics\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
@@ -381,6 +404,43 @@ def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
)
def _rule_shared_canonical_comment_post_claim(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
"""#496: reports must not claim comment posted when validator rejected."""
text = report_text or ""
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
rejected_in_log = False
if action_log:
for entry in action_log:
validation = entry.get("canonical_comment_validation") or {}
if validation.get("allowed") is False:
rejected_in_log = True
break
result = entry.get("result") or {}
nested = result.get("canonical_comment_validation") or {}
if nested.get("allowed") is False:
rejected_in_log = True
break
if not (rejected_in_report or rejected_in_log):
return []
if not _COMMENT_POSTED_CLAIM_RE.search(text):
return []
return [
validator_finding(
"shared.canonical_comment_post_claim",
"block",
"MCP/Gitea mutations",
"report claims a Gitea comment was posted while canonical comment "
"validation rejected the workflow comment",
"do not claim comment posted when validator fail-closed; repair "
"the canonical comment body and retry posting",
)
]
def _rule_reviewer_legacy_workspace_mutations(
report_text: str,
*,
@@ -598,6 +658,27 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
)
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
from conflict_fix_classification import (
assess_conflict_fix_classification_final_report,
)
text = report_text or ""
result = assess_conflict_fix_classification_final_report(text)
if result.get("proven"):
return []
return _findings_from_reasons(
"author.conflict_fix_classification_proof",
result.get("reasons") or [],
field="Conflict-fix classification",
severity="block",
safe_next_action=(
"call gitea_assess_conflict_fix_classification, state the live head "
"SHA, and state the classification before creating a conflict-fix worktree"
),
)
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
from pr_work_lease import assess_conflict_fix_final_report
@@ -1111,6 +1192,25 @@ def _rule_reviewer_validation_structured(
)
def _rule_reviewer_handoff_consistency(report_text: str) -> list[dict[str, str]]:
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(
report_text
)
if result.get("proven"):
return []
return _findings_from_reasons(
"reviewer.handoff_consistency",
result.get("reasons") or [],
field="Review mutations",
severity="block",
safe_next_action=(
"rewrite reviewer handoff so narrative claims match the mutation "
"ledger; use the blocked-review handoff template when submission "
"was rejected"
),
)
def _rule_reviewer_mutation_ledger(
report_text: str,
*,
@@ -1277,6 +1377,29 @@ def _rule_reviewer_review_mutation(
)
def _rule_shared_two_comment_workflow(report_text: str) -> list[dict[str, str]]:
"""#507: tagged Controller Handoff must pair with Thread State Ledger."""
return thread_state_ledger_validator.findings_for_final_report(report_text)
def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]:
from canonical_state_comments import validate_final_report_state_update
result = validate_final_report_state_update(report_text)
if not result.get("applicable") or result.get("valid"):
return []
return [
validator_finding(
"shared.canonical_state_update",
"block",
"Canonical state update",
reason,
"include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT",
)
for reason in (result.get("reasons") or ["invalid canonical state update"])
]
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
@@ -1325,17 +1448,27 @@ _SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
_rule_shared_author_reviewer_same_run,
_rule_shared_canonical_state_update,
)
_SHARED_TWO_COMMENT_RULES = (
_rule_shared_two_comment_workflow,
)
_SHARED_CLEANUP_PROOF_RULES = (
_rule_shared_mcp_native_cleanup_proof,
)
_SHARED_CANONICAL_COMMENT_RULES = (
_rule_shared_canonical_comment_post_claim,
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
@@ -1354,6 +1487,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_already_landed_eligible,
_rule_reviewer_already_landed_state,
_rule_reviewer_target_branch_freshness,
_rule_reviewer_handoff_consistency,
_rule_reviewer_workflow_load_boundary,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
@@ -1362,10 +1496,23 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reviewer_stale_head_proof,
],
"merge_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_linked_issue,
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reconcile_stale_author_fields,
@@ -1383,6 +1530,8 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
@@ -1390,9 +1539,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_shared_issue_acceptance_gate,
_rule_reviewer_vague_mutations_none,
_rule_conflict_fix_classification_proof,
_rule_conflict_fix_push_proof,
_rule_worktree_cleanup_audit_proof,
],
@@ -1400,12 +1552,16 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
@@ -1413,6 +1569,8 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_TWO_COMMENT_RULES,
*_SHARED_CANONICAL_COMMENT_RULES,
*_SHARED_ISSUE_LOCK_RULES,
],
}
+15 -3
View File
@@ -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": {
+11
View File
@@ -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
+468 -45
View File
@@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None,
)
# Reviewer/author role pivot boundary: only an authorized pivot
# (recorded by gitea_activate_profile) may cross author reviewer.
if (required_role == "reviewer"
# (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
if (required_role in ("reviewer", "merger")
and "author" in str(data.get("initial_profile")).lower()
and "reviewer" in str(active_profile).lower()):
and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())):
if not data.get("role_pivot_authorized"):
raise RuntimeError(
"Attempted reviewer mutation from author session without "
f"Attempted {required_role} mutation from author session without "
"authorized role pivot (fail closed)"
)
@@ -477,7 +477,7 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
def _clear_preflight_capability_state() -> None:
"""Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
global _preflight_capability_called, _preflight_capability_violation
global _preflight_resolved_task
global _preflight_resolved_task, _preflight_resolved_role
global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
@@ -486,6 +486,7 @@ def _clear_preflight_capability_state() -> None:
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_resolved_task = None
_preflight_resolved_role = None
_preflight_reviewer_violation_files = []
@@ -562,7 +563,7 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
(#468). Non-author namespaces use dedicated workspace env vars (#510).
(#468, #483). Non-author namespaces use dedicated workspace env vars (#510).
The exemption honours BOTH the effective workspace role and the actual
profile role (#540). ``comment_issue`` preflight stamps
@@ -818,6 +819,7 @@ import root_checkout_guard # noqa: E402
import remote_repo_guard # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402
import reviewer_pr_lease # noqa: E402
import merger_lease_adoption # noqa: E402
import merged_cleanup_reconcile # noqa: E402
@@ -827,7 +829,10 @@ import reconciliation_workflow # noqa: E402
import audit_reconciliation_mode # noqa: E402
import review_merge_state_machine # noqa: E402
import pr_work_lease # noqa: E402
import workflow_skill # noqa: E402
import conflict_fix_classification # noqa: E402
import native_mcp_preference # noqa: E402
import thread_state_ledger_validator # noqa: E402
import master_parity_gate # noqa: E402
# Master-parity baseline (#420): the commit this server process started at.
@@ -836,6 +841,7 @@ import master_parity_gate # noqa: E402
# Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
import worktree_cleanup_audit # noqa: E402
import canonical_comment_validator as ccv # noqa: E402
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
@@ -1175,6 +1181,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non
issues.update(int(m) for m in pattern.findall(branch_name))
return sorted(list(issues))
def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
labels = api_get_all(f"{base}/labels", auth) or []
return {
str(lb["name"]): int(lb["id"])
for lb in labels
if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None
}
def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
return issue_workflow_labels.label_names(issue)
def _put_issue_label_names(
*,
base: str,
auth: str,
issue_number: int,
names: list[str],
label_ids_by_name: dict[str, int] | None = None,
) -> list[dict]:
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
missing = [name for name in names if name not in by_name]
if missing:
raise RuntimeError(
f"The following labels do not exist on the repository: {missing}. "
"Create the canonical workflow labels first."
)
ids = [by_name[name] for name in names]
return api_request(
"PUT",
f"{base}/issues/{issue_number}/labels",
auth,
{"labels": ids},
)
def _transition_issue_status(
*,
base: str,
auth: str,
issue_number: int,
status: str,
label_ids_by_name: dict[str, int] | None = None,
) -> dict:
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
target_status = issue_workflow_labels.canonical_status_label(status)
if target_status not in by_name:
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"target_status": target_status,
"reasons": [
f"required workflow status label '{target_status}' does not exist"
],
}
current = _issue_label_names(base, auth, issue_number)
updated = issue_workflow_labels.transition_status_labels(current, target_status)
_put_issue_label_names(
base=base,
auth=auth,
issue_number=issue_number,
names=updated,
label_ids_by_name=by_name,
)
return {
"success": True,
"performed": True,
"issue_number": issue_number,
"target_status": target_status,
"labels": updated,
}
def _prepare_issue_status_transition(
*,
base: str,
auth: str,
issue_number: int,
status: str,
) -> dict:
by_name = _repo_label_id_map(base, auth)
target_status = issue_workflow_labels.canonical_status_label(status)
if target_status not in by_name:
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"target_status": target_status,
"reasons": [
f"required workflow status label '{target_status}' does not exist"
],
}
current = _issue_label_names(base, auth, issue_number)
updated = issue_workflow_labels.transition_status_labels(current, target_status)
return {
"success": True,
"performed": False,
"issue_number": issue_number,
"target_status": target_status,
"labels": updated,
"label_ids_by_name": by_name,
}
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
if not issue_numbers:
return {}
@@ -1507,6 +1615,11 @@ def gitea_create_issue(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
labels: list[str] | None = None,
issue_type: str | None = None,
initial_status: str | None = None,
discussion: bool = False,
require_workflow_labels: bool = False,
allow_duplicate_override: bool = False,
split_from_issue: int | None = None,
worktree_path: str | None = None,
@@ -1520,6 +1633,12 @@ def gitea_create_issue(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
labels: Optional labels to apply by name after creating the issue.
issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'.
initial_status: Optional initial workflow status, e.g. 'ready'.
discussion: If True, require/apply type:discussion.
require_workflow_labels: If True, fail closed unless labels include one
type:* and one status:* label.
allow_duplicate_override: Operator-approved split after duplicate found.
split_from_issue: Existing duplicate issue number when overriding.
worktree_path: Optional path to verify branches-only guard.
@@ -1551,6 +1670,40 @@ def gitea_create_issue(
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
base = repo_api_url(h, o, r)
requested_labels = issue_workflow_labels.labels_for_new_issue(
issue_type=issue_type,
initial_status=initial_status,
extra_labels=labels,
discussion=discussion,
)
label_assessment = issue_workflow_labels.assess_issue_labels(
requested_labels,
discussion=discussion,
require_type=True,
require_status=True,
)
if require_workflow_labels and not label_assessment["valid"]:
return {
"success": False,
"performed": False,
"number": None,
"workflow_label_validation": label_assessment,
"reasons": label_assessment["errors"],
}
label_ids_by_name: dict[str, int] | None = None
if requested_labels:
label_ids_by_name = _repo_label_id_map(base, auth)
missing = [name for name in requested_labels if name not in label_ids_by_name]
if missing:
return {
"success": False,
"performed": False,
"number": None,
"workflow_label_validation": label_assessment,
"reasons": [
f"requested labels do not exist on the repository: {missing}"
],
}
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
f"{base}/issues?state=closed&type=issues", auth, limit=100
@@ -1585,7 +1738,29 @@ def gitea_create_issue(
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
request_metadata={"title": title}, mutation_task="create_issue")
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
result = {
"number": data["number"],
"workflow_label_validation": label_assessment,
}
if requested_labels:
with _audited(
"set_issue_labels",
host=h,
remote=remote,
org=o,
repo=r,
issue_number=data["number"],
request_metadata={"labels": requested_labels, "source": "create_issue"},
):
applied = _put_issue_label_names(
base=base,
auth=auth,
issue_number=data["number"],
names=requested_labels,
label_ids_by_name=label_ids_by_name,
)
result["labels"] = issue_workflow_labels.label_names(applied)
return _with_optional_url(result, data.get("html_url"))
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
@@ -1994,7 +2169,23 @@ def gitea_create_pr(
)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
repo_base = repo_api_url(h, o, r)
pr_open_transition = _prepare_issue_status_transition(
base=repo_base,
auth=auth,
issue_number=int(locked_issue),
status="status:pr-open",
)
if not pr_open_transition.get("success"):
return {
"success": False,
"performed": False,
"number": None,
"issue_number": locked_issue,
"issue_status_transition": pr_open_transition,
"reasons": pr_open_transition.get("reasons", []),
}
url = f"{repo_base}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
meta = {"title": title, "head": head, "base": base}
try:
@@ -2009,7 +2200,37 @@ def gitea_create_pr(
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
target_branch=head, request_metadata=meta,
mutation_task="create_pr")
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
with _audited(
"set_issue_labels",
host=h,
remote=remote,
org=o,
repo=r,
issue_number=int(locked_issue),
request_metadata={
"source": "create_pr",
"status": "status:pr-open",
"pr_number": data["number"],
},
):
_put_issue_label_names(
base=repo_base,
auth=auth,
issue_number=int(locked_issue),
names=pr_open_transition["labels"],
label_ids_by_name=pr_open_transition["label_ids_by_name"],
)
result = {
"number": data["number"],
"issue_status_transition": {
"success": True,
"performed": True,
"issue_number": int(locked_issue),
"target_status": "status:pr-open",
"labels": pr_open_transition["labels"],
},
}
return _with_optional_url(result, data.get("html_url"))
def _format_list_pr_entry(pr: dict) -> dict:
@@ -2279,13 +2500,23 @@ def gitea_check_pr_eligibility(
# ("gitea.pr.merge") always match each other and never cross services.
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
active_role = _role_kind(allowed, forbidden)
if action == "merge" and active_role == "reviewer":
op_ok = False
op_reason = "reviewer-cannot-merge"
else:
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
if not op_ok:
if op_reason == "no-allowed-operations":
reasons.append(
"profile has no configured allowed operations (fail closed)")
elif op_reason == "forbidden":
reasons.append(f"profile forbids '{action}'")
elif op_reason == "reviewer-cannot-merge":
reasons.append(
"Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head."
)
elif op_reason == "invalid-forbidden-entry":
reasons.append(
"profile has an unrecognized forbidden operation entry "
@@ -2309,21 +2540,23 @@ def gitea_check_pr_eligibility(
missing_perm)
req_profile = None
role_noun = "reviewer"
if action in ("approve", "request_changes", "review"):
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)"
elif action == "merge":
req_profile = "A profile with reviewer role permissions and explicit merge permission"
req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)"
role_noun = "merger"
result["required_profile"] = req_profile
switching_supported = gitea_config.is_runtime_switching_enabled()
if switching_supported:
result["fixable_by_profile_switch"] = True
result["requires_different_namespace"] = False
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}."
else:
result["fixable_by_profile_switch"] = False
result["requires_different_namespace"] = True
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile."
result["safe_next_step"] = safe_step
return result
@@ -3275,6 +3508,13 @@ def _evaluate_pr_review_submission(
result["pr_work_lease"] = lease_block
return result
if (body or "").strip():
gate = _canonical_comment_gate(body, context="pr_review")
if gate["blocked"]:
reasons.extend(gate["reasons"])
result["canonical_comment_validation"] = gate["canonical_comment_validation"]
return result
result["would_perform"] = True
if not live:
reasons.append(
@@ -4039,6 +4279,14 @@ def gitea_merge_pr(
_verify_role_mutation_workspace(
remote, worktree_path=worktree_path, task="merge_pr"
)
allowed = get_profile()["allowed_operations"]
forbidden = get_profile()["forbidden_operations"]
active_role = _role_kind(allowed, forbidden)
if active_role == "reviewer":
raise RuntimeError(
"Reviewer profile cannot merge. Use a merger profile/session "
"after formal approval at current head."
)
workflow_blockers = _review_workflow_load_gate_reasons()
do = (do or "").strip().lower()
result = {
@@ -4055,6 +4303,10 @@ def gitea_merge_pr(
"merge_result": None,
"merge_commit": None,
"reasons": [],
"active_profile": get_profile()["profile_name"],
"role_kind": active_role,
"merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config",
"explicit_operator_auth": confirmation,
}
reasons = result["reasons"]
if workflow_blockers:
@@ -4224,7 +4476,7 @@ def gitea_merge_pr(
# A profile/identity flip or side-channel override between preflight
# and merge fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
verify_mutation_authority(remote, host, required_role="merger",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
@@ -4779,6 +5031,7 @@ def gitea_reconcile_merged_cleanups(
remote_branch_exists=remote_branch_exists,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
target_ref=master_ref,
active_reviewer_leases=active_reviewer_leases,
pr_states=pr_states,
)
@@ -4820,7 +5073,9 @@ def gitea_reconcile_merged_cleanups(
if local_assessment.get("safe_to_remove_worktree"):
result = merged_cleanup_reconcile.remove_local_worktree(
PROJECT_ROOT, head_branch
PROJECT_ROOT,
head_branch,
worktree_path=local_assessment.get("worktree_path"),
)
actions.append({"action": "remove_local_worktree", **result})
@@ -5252,6 +5507,12 @@ def gitea_reconcile_already_landed_pr(
"gitea.pr.comment"
)
return result
gate = _canonical_comment_gate(comment_body)
if gate["blocked"]:
result["success"] = False
result["reasons"].extend(gate["reasons"])
result["canonical_comment_validation"] = gate["canonical_comment_validation"]
return result
comment_url = f"{base}/issues/{pr_number}/comments"
with _audited(
"comment_pr",
@@ -6463,6 +6724,23 @@ def gitea_list_issue_comments(
return {"success": True, "issue_number": issue_number, "comments": out}
def _two_comment_workflow_comment_gate(body: str) -> list[str]:
"""Fail closed on invalid tagged workflow comments (#507)."""
text = body or ""
reasons: list[str] = []
if thread_state_ledger_validator.has_controller_handoff_marker(text):
result = thread_state_ledger_validator.assess_controller_handoff_comment(
text
)
reasons.extend(result.get("reasons") or [])
if thread_state_ledger_validator.has_thread_state_ledger_marker(text):
result = thread_state_ledger_validator.assess_thread_state_ledger_comment(
text
)
reasons.extend(result.get("reasons") or [])
return reasons
@mcp.tool()
def gitea_create_issue_comment(
issue_number: int,
@@ -6507,6 +6785,8 @@ def gitea_create_issue_comment(
reasons = list(gate_reasons)
if not (body or "").strip():
reasons.append("comment body must be a non-empty string")
if not reasons:
reasons.extend(_two_comment_workflow_comment_gate(body))
if reasons:
blocked = {"success": False, "performed": False,
"issue_number": issue_number, "reasons": reasons}
@@ -6515,6 +6795,15 @@ def gitea_create_issue_comment(
blocked["permission_report"] = _permission_block_report(
"gitea.issue.comment")
return blocked
canon_gate = _canonical_comment_gate(body, context="issue_comment")
if canon_gate["blocked"]:
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"reasons": canon_gate["reasons"],
"canonical_comment_validation": canon_gate["canonical_comment_validation"],
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
@@ -6543,26 +6832,28 @@ def _role_kind(allowed, forbidden) -> str:
"""Classify the active profile from its normalized operations.
'reconciler' can close already-landed PRs without review/author powers;
'author' can create PRs / push branches; 'reviewer' can approve/merge;
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
review/author powers; 'mixed' can do both (a config smell the
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
neither.
'author' can create PRs / push branches; 'reviewer' can approve;
'merger' can merge PRs;
'mixed' can do multiple (a config smell); 'limited' can do neither.
"""
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
return "reconciler"
def can(op):
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
can_merge = can("gitea.pr.merge")
can_approve = can("gitea.pr.approve")
author = can("gitea.pr.create") or can("gitea.branch.push")
reconciler = (
can("gitea.pr.close")
and not review
and not can_approve
and not can_merge
and not author
)
if review and author:
if (can_approve or can_merge) and author:
return "mixed"
if review:
if can_merge:
return "merger"
if can_approve:
return "reviewer"
if reconciler:
return "reconciler"
@@ -7004,6 +7295,9 @@ _PROJECT_SKILLS = {
},
}
# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551).
_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries())
@mcp.tool()
def mcp_get_control_plane_guide(
@@ -7080,12 +7374,20 @@ def mcp_get_control_plane_guide(
"gitea.issue.comment (separate from gitea.pr.comment).")
elif role == "reviewer":
guidance.append(
"Reviewer profile: review/approve/merge may proceed ONLY after "
"Reviewer profile: review/approve may proceed ONLY after "
"gitea_check_pr_eligibility passes and the PR head SHA is "
"pinned (expected_head_sha); the PR author must be a different "
"user, and merging additionally requires explicit operator "
"authorization plus the 'MERGE PR <n>' confirmation. "
"PR comments do not imply issue comments.")
"user. Reviewer profiles cannot merge PRs. "
"PR comments do not imply issue comments. "
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
elif role == "merger":
guidance.append(
"Merger profile: merge may proceed ONLY after formal approval at "
"current head, gitea_check_pr_eligibility passes, and the PR head "
"SHA is pinned (expected_head_sha); the PR author must be a different "
"user, and merging requires explicit operator authorization plus the "
"'MERGE PR <n>' confirmation. "
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
elif role == "mixed":
guidance.append(
"WARNING: this profile allows both authoring and "
@@ -7117,7 +7419,7 @@ def mcp_get_control_plane_guide(
@mcp.tool()
def mcp_list_project_skills() -> dict:
"""List the project's workflow skills and when to use them (#128).
"""List the project's workflow skills and when to use them (#128, #551).
Read-only; makes no API calls. Each skill reports its name,
description, when-to-use guidance, required operations, status
@@ -7125,6 +7427,9 @@ def mcp_list_project_skills() -> dict:
'operator-only'), and whether the current profile's operations permit it. Use
mcp_get_skill_guide(name) for the step-by-step guide.
Includes the canonical workflow router under gitea-workflow,
llm-project-workflow, and git-pr-workflows (#551).
Returns:
dict with 'read_only', 'count', and 'skills' (list). Never secrets.
"""
@@ -7149,10 +7454,39 @@ def mcp_list_project_skills() -> dict:
}
if meta.get("notes"):
entry["notes"] = meta["notes"]
if meta.get("repo_path"):
entry["repo_path"] = meta["repo_path"]
if meta.get("canonical"):
entry["canonical"] = True
skills.append(entry)
return {"read_only": True, "count": len(skills), "skills": skills}
@mcp.tool()
def mcp_check_workflow_skill_preflight(
project_root: str | None = None,
) -> dict:
"""Fail-closed preflight for the canonical workflow skill wall (#551).
Verifies skills/llm-project-workflow/SKILL.md exists in the project tree
and reports whether Codex has a matching mount under ~/.codex/skills.
Call before git/Gitea mutations when the controller requires gitea-workflow.
Args:
project_root: Optional override; defaults to this MCP process root.
Returns:
dict with workflow_skill_ready, blocked, canonical_names, codex mount
status, and safe_next_action. Never secrets.
"""
root = (project_root or PROJECT_ROOT).strip()
result = workflow_skill.assess_workflow_skill_presence(root)
result["success"] = not result.get("blocked")
result["project_root"] = root
result["read_only"] = True
return result
@mcp.tool()
def mcp_get_skill_guide(skill_name: str) -> dict:
"""Step-by-step guide for one named project skill (#128).
@@ -8099,6 +8433,34 @@ def gitea_audit_config() -> dict:
return report
def _canonical_comment_gate(
body: str,
*,
context: str | None = None,
force_workflow: bool = False,
) -> dict:
"""Fail-closed canonical state validation before comment/review POST (#496)."""
assessment = ccv.assess_canonical_comment(
body,
context=context,
force_workflow=force_workflow,
)
if assessment.get("allowed"):
return {
"blocked": False,
"canonical_comment_validation": assessment,
"reasons": [],
}
correction = (assessment.get("correction_message") or "").strip()
return {
"blocked": True,
"canonical_comment_validation": assessment,
"reasons": [
correction or "canonical comment validation failed (fail closed before posting)"
],
}
def _post_structured_issue_comment(
*,
issue_number: int,
@@ -8108,8 +8470,18 @@ def _post_structured_issue_comment(
org: str | None,
repo: str | None,
audit_op: str = "create_issue_comment",
comment_context: str | None = None,
) -> dict:
"""Post an issue-thread comment after permission gates already passed."""
gate = _canonical_comment_gate(body, context=comment_context)
if gate["blocked"]:
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"reasons": gate["reasons"],
"canonical_comment_validation": gate["canonical_comment_validation"],
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
@@ -8174,15 +8546,8 @@ def gitea_mark_issue(
auth = _auth(h)
base = repo_api_url(h, o, r)
# Find the status:in-progress label id
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = None
for lb in labels:
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
if label_id is None:
label_ids_by_name = _repo_label_id_map(base, auth)
if "status:in-progress" not in label_ids_by_name:
raise RuntimeError(
"Label 'status:in-progress' not found. "
"Run manage_labels.py to create it first."
@@ -8190,10 +8555,19 @@ def gitea_mark_issue(
if action == "start":
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "add", "label": "status:in-progress"}):
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
{"labels": [label_id]})
issue_number=issue_number, request_metadata={
"op": "replace-status",
"label": "status:in-progress",
}):
transition = _transition_issue_status(
base=base,
auth=auth,
issue_number=issue_number,
status="status:in-progress",
label_ids_by_name=label_ids_by_name,
)
if not transition.get("success"):
raise RuntimeError("; ".join(transition.get("reasons") or []))
active_profile = (profile or get_profile().get("profile_name") or "unknown")
branch = (branch_name or "pending").strip() or "pending"
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
@@ -8218,8 +8592,10 @@ def gitea_mark_issue(
"message": f"Issue #{issue_number} claimed.",
"heartbeat_posted": heartbeat.get("success", False),
"heartbeat_comment_id": heartbeat.get("comment_id"),
"status_transition": transition,
}
else:
label_id = label_ids_by_name["status:in-progress"]
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "remove", "label": "status:in-progress"}):
@@ -8335,6 +8711,40 @@ def gitea_acquire_conflict_fix_lease(
}
@mcp.tool()
def gitea_assess_conflict_fix_classification(
pr_number: int,
inventory_head_sha: str | None = None,
inventory_mergeable: bool | None = None,
live_head_sha: str | None = None,
live_mergeable: bool | None = None,
) -> dict:
"""Read-only: classify conflict-fix need from live PR state (#522).
Open PR inventory can be stale. Callers must pass a live re-fetched PR head
and mergeability value before creating a conflict-fix worktree.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"performed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
result = conflict_fix_classification.assess_conflict_fix_classification(
pr_number=pr_number,
inventory_head_sha=inventory_head_sha,
inventory_mergeable=inventory_mergeable,
live_head_sha=live_head_sha,
live_mergeable=live_mergeable,
)
result["success"] = True
result["performed"] = False
return result
@mcp.tool()
def gitea_assess_conflict_fix_push(
pr_number: int,
@@ -8602,6 +9012,7 @@ def gitea_create_label(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Create a new label on a Gitea repository.
@@ -8613,11 +9024,16 @@ def gitea_create_label(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Optional branches worktree to verify before mutation.
Returns:
dict containing the created label details.
"""
verify_preflight_purity(remote)
blocked = _profile_permission_block(
task_capability_map.required_permission("create_label"))
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -8643,6 +9059,7 @@ def gitea_set_issue_labels(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> list:
"""Replace all labels on a Gitea issue with a new list of label names.
@@ -8653,6 +9070,7 @@ def gitea_set_issue_labels(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Optional branches worktree to verify before mutation.
Returns:
list of all labels currently applied to the issue.
@@ -8661,7 +9079,7 @@ def gitea_set_issue_labels(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
verify_preflight_purity(remote, task="set_issue_labels")
verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -9205,6 +9623,11 @@ def gitea_capability_stop_terminal_report() -> dict:
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
# #558: mark this process as the official MCP daemon before any tool
# dispatch so direct shell imports cannot reuse mutation/auth paths.
import mcp_daemon_guard
mcp_daemon_guard.mark_sanctioned_daemon()
# Lock this session's launch profile into the environment so child CLI
# processes (e.g. review_pr.py) can detect and refuse profile
# side-channel overrides (#199).
+194
View File
@@ -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,
}
+5 -2
View File
@@ -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)
+84
View File
@@ -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,
}
+11
View File
@@ -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:
+191 -29
View File
@@ -17,6 +17,7 @@ import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
REVIEWER_SCRATCH_NAME_RE = re.compile(
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
@@ -41,6 +42,59 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
def extract_issue_marker(*values: str | None) -> int | None:
"""Return the first issue marker found in branch/path-like values."""
for value in values:
match = ISSUE_MARKER_RE.search(value or "")
if match:
return int(match.group(1))
return None
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
"""Parse ``git worktree list --porcelain`` into structured entries."""
res = subprocess.run(
["git", "-C", project_root, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return []
entries: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
def flush() -> None:
nonlocal current
if current:
entries.append(current)
current = None
for raw_line in (res.stdout or "").splitlines():
line = raw_line.strip()
if not line:
flush()
continue
if line.startswith("worktree "):
flush()
current = {"path": line[9:].strip()}
elif current is not None and line.startswith("HEAD "):
current["head_sha"] = line[5:].strip()
elif current is not None and line.startswith("branch "):
ref = line[7:].strip()
current["branch_ref"] = ref
current["branch"] = (
ref[len("refs/heads/"):]
if ref.startswith("refs/heads/")
else ref
)
elif current is not None and line == "detached":
current["detached"] = True
flush()
return entries
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
"""Parse ``git worktree list --porcelain`` into structured records."""
res = subprocess.run(
@@ -82,14 +136,115 @@ def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
def discover_local_worktrees(project_root: str) -> dict[str, str]:
"""Return a mapping from branch name to absolute worktree path (#528)."""
mapping: dict[str, str] = {}
for record in _git_worktree_porcelain(project_root):
branch_name = record.get("branch")
path = record.get("worktree")
if branch_name and path:
mapping[str(branch_name)] = str(path)
for entry in list_local_worktrees(project_root):
branch = entry.get("branch")
path = entry.get("path")
if branch and path:
mapping[str(branch)] = str(path)
return mapping
def _is_under_branches(project_root: str, path: str) -> bool:
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
real_path = os.path.realpath(path)
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
def _head_is_safe_for_cleanup(
*,
project_root: str,
candidate_head: str | None,
pr_head_sha: str | None,
target_ref: str | None,
) -> bool:
if not candidate_head:
return False
if pr_head_sha and candidate_head == pr_head_sha:
return True
if target_ref:
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
return False
def resolve_cleanup_worktree_state(
*,
project_root: str,
head_branch: str,
issue_number: int | None,
pr_head_sha: str | None = None,
target_ref: str | None = None,
) -> dict[str, Any]:
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
expected_path = resolve_worktree_path(project_root, head_branch)
state = read_local_worktree_state(expected_path)
state["worktree_path"] = expected_path
state["expected_worktree_path"] = expected_path
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
state["match_type"] = "derived_path" if state.get("exists") else "none"
state["candidate_paths"] = []
state["alias_block_reasons"] = []
if state.get("exists"):
return state
branch_issue = extract_issue_marker(head_branch)
wanted_issue = issue_number or branch_issue
candidates: list[dict[str, Any]] = []
unsafe_matches: list[str] = []
for entry in list_local_worktrees(project_root):
path = entry.get("path") or ""
if not path or not _is_under_branches(project_root, path):
continue
branch = entry.get("branch") or ""
path_issue = extract_issue_marker(os.path.basename(path), path)
entry_issue = extract_issue_marker(branch) or path_issue
branch_match = branch == head_branch
issue_match = wanted_issue is not None and entry_issue == wanted_issue
if not (branch_match or issue_match):
continue
safe_head = _head_is_safe_for_cleanup(
project_root=project_root,
candidate_head=entry.get("head_sha"),
pr_head_sha=pr_head_sha,
target_ref=target_ref,
)
if not safe_head and branch_match and not pr_head_sha and not target_ref:
safe_head = True
if not safe_head:
unsafe_matches.append(path)
continue
candidates.append(entry)
state["candidate_paths"] = [entry.get("path") for entry in candidates]
if len(candidates) == 1:
path = candidates[0]["path"]
alias_state = read_local_worktree_state(path)
alias_state["worktree_path"] = path
alias_state["expected_worktree_path"] = expected_path
alias_state["discovered_worktree_path"] = path
alias_state["match_type"] = (
"branch" if candidates[0].get("branch") == head_branch else "alias"
)
alias_state["candidate_paths"] = [path]
alias_state["alias_block_reasons"] = []
alias_state["alias_verified"] = True
return alias_state
if len(candidates) > 1:
state["alias_block_reasons"].append(
"ambiguous local worktree aliases: " + ", ".join(
sorted(str(entry.get("path")) for entry in candidates)
)
)
state["match_type"] = "ambiguous_alias"
elif unsafe_matches:
state["alias_block_reasons"].append(
"matching local worktree aliases did not point to the PR head or target branch: "
+ ", ".join(sorted(unsafe_matches))
)
state["match_type"] = "unsafe_alias"
return state
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
@@ -323,6 +478,7 @@ def assess_local_worktree_cleanup(
exists = bool(worktree_state.get("exists"))
if not merged:
reasons.append("PR is not merged")
reasons.extend(worktree_state.get("alias_block_reasons") or [])
if not exists:
reasons.append("local worktree not present")
if exists and worktree_state.get("clean") is False:
@@ -333,7 +489,11 @@ def assess_local_worktree_cleanup(
)
if exists:
current_branch = worktree_state.get("current_branch")
if current_branch and current_branch != head_branch:
if (
current_branch
and current_branch != head_branch
and not worktree_state.get("alias_verified")
):
reasons.append(
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
)
@@ -345,6 +505,10 @@ def assess_local_worktree_cleanup(
"pr_number": pr_number,
"head_branch": head_branch,
"worktree_path": worktree_state.get("worktree_path"),
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
"match_type": worktree_state.get("match_type"),
"candidate_paths": worktree_state.get("candidate_paths") or [],
"worktree_exists": exists,
"worktree_clean": worktree_state.get("clean"),
"safe_to_remove_worktree": safe,
@@ -363,7 +527,7 @@ def build_pr_cleanup_entry(
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
worktree_map: dict[str, str] | None = None,
target_ref: str | None = None,
) -> dict[str, Any]:
pr_number = int(pr["number"])
head_branch = pr.get("head") or ""
@@ -372,18 +536,16 @@ def build_pr_cleanup_entry(
title = pr.get("title") or ""
body = pr.get("body") or ""
merged = bool(pr.get("merged_at"))
discovered_path = worktree_map.get(head_branch) if worktree_map else None
derived_path = resolve_worktree_path(project_root, head_branch)
if discovered_path:
worktree_path = discovered_path
match_type = "branch"
else:
worktree_path = derived_path
match_type = "path"
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
worktree_state = resolve_cleanup_worktree_state(
project_root=project_root,
head_branch=head_branch,
issue_number=issue_number,
pr_head_sha=pr_head_sha,
target_ref=target_ref,
)
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
remote = assess_remote_branch_cleanup(
@@ -404,11 +566,9 @@ def build_pr_cleanup_entry(
worktree_state=worktree_state,
active_lock=active_lock,
)
local["match_type"] = match_type
return {
"pr_number": pr_number,
"issue_number": extract_linked_issue(title, body),
"issue_number": issue_number,
"title": title,
"head_branch": head_branch,
"merge_commit_sha": pr.get("merge_commit_sha"),
@@ -429,11 +589,11 @@ def build_reconciliation_report(
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
target_ref: str | None = None,
active_reviewer_leases: dict[int, bool] | None = None,
pr_states: dict[int, dict[str, Any]] | None = None,
) -> dict[str, Any]:
open_heads = collect_open_pr_heads(open_prs)
worktree_map = discover_local_worktrees(project_root)
entries: list[dict[str, Any]] = []
for pr in closed_prs or []:
if not pr.get("merged_at"):
@@ -452,7 +612,7 @@ def build_reconciliation_report(
delete_capability_allowed=delete_capability_allowed,
issue_lock_path=issue_lock_path,
protected_branches=protected_branches,
worktree_map=worktree_map,
target_ref=target_ref,
)
)
@@ -505,10 +665,12 @@ def build_reconciliation_report(
}
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
worktree_map = discover_local_worktrees(project_root)
discovered_path = worktree_map.get(branch)
worktree_path = discovered_path or resolve_worktree_path(project_root, branch)
def remove_local_worktree(
project_root: str,
branch: str,
worktree_path: str | None = None,
) -> dict[str, Any]:
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
if not os.path.isdir(worktree_path):
return {
"success": False,
@@ -593,4 +755,4 @@ def remove_reviewer_scratch_worktree(
"message": f"removed reviewer scratch worktree {real}",
"worktree_path": real,
"author_worktree_cleanup": False,
}
}
+24 -3
View File
@@ -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
+186
View File
@@ -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,
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Install canonical Gitea workflow skill into Codex skills dir (#551).
# Symlinks the in-repo skills/llm-project-workflow package under the names
# gitea-workflow, llm-project-workflow, and git-pr-workflows.
set -euo pipefail
usage() {
cat <<'EOF'
usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR]
Symlink the portable skills/llm-project-workflow package into the Codex
skills directory under the canonical names:
gitea-workflow
llm-project-workflow
git-pr-workflows
Defaults:
skills dir: $CODEX_HOME/skills or ~/.codex/skills
source: <repo>/skills/llm-project-workflow
EOF
}
dry_run=0
skills_dir=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--skills-dir)
shift
skills_dir="${1:-}"
;;
--help|-h) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
shift
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
source_skill="$repo_root/skills/llm-project-workflow"
if [[ ! -f "$source_skill/SKILL.md" ]]; then
echo "Error: missing $source_skill/SKILL.md" >&2
exit 1
fi
if [[ -z "$skills_dir" ]]; then
if [[ -n "${CODEX_HOME:-}" ]]; then
skills_dir="$CODEX_HOME/skills"
else
skills_dir="${HOME}/.codex/skills"
fi
fi
names=(gitea-workflow llm-project-workflow git-pr-workflows)
echo "source: $source_skill"
echo "codex skills dir: $skills_dir"
if [[ "$dry_run" -eq 0 ]]; then
mkdir -p "$skills_dir"
fi
for name in "${names[@]}"; do
target="$skills_dir/$name"
if [[ "$dry_run" -eq 1 ]]; then
echo "dry-run: ln -sfn $source_skill $target"
continue
fi
if [[ -e "$target" || -L "$target" ]]; then
if [[ -L "$target" ]]; then
rm -f "$target"
else
echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2
exit 1
fi
fi
ln -sfn "$source_skill" "$target"
echo "linked $target -> $source_skill"
done
echo "done. Restart Codex so skills reload."
+37
View File
@@ -0,0 +1,37 @@
---
name: gitea-workflow
description: >-
Canonical alias for the Gitea/LLM project workflow router. Same skill as
llm-project-workflow. Use at the start of any Gitea-Tools author, review,
merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow,
llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue.
---
# gitea-workflow (canonical alias)
This directory is the **stable name** for controller prompts and Codex mounts
(`gitea-workflow`). The portable implementation lives at:
**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)**
## Required action
1. Open and follow `skills/llm-project-workflow/SKILL.md` (router).
2. Load the matching workflow under `skills/llm-project-workflow/workflows/`.
3. Do not mutate git/Gitea until the workflow is loaded.
## Multi-runtime names (must resolve identically)
| Name | Role |
|------|------|
| `gitea-workflow` | Canonical controller / Codex skill name |
| `llm-project-workflow` | Portable in-repo skill package |
| `git-pr-workflows` | Legacy alias |
Install for Codex:
```bash
./scripts/install-codex-workflow-skill.sh
```
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
+33 -2
View File
@@ -6,6 +6,8 @@ description: >-
report schema. Use at the start of any implementation, review, merge,
reconciliation, or issue-filing task.
---
> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551).
# LLM Project Workflow Skill
@@ -31,12 +33,29 @@ workflow file.
- A nearby capability does not count.
- Do not self-review or self-merge.
- Do not mix modes in one run.
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
- If the required workflow cannot be loaded, stop and produce a recovery handoff
only.
only (see BLOCKED + DIAGNOSE rule above).
- Final report must use the schema for the loaded workflow.
- If a task requires a different mode, stop and produce a handoff for the
correct workflow.
## Covered blocker classes (BLOCKED + DIAGNOSE must trigger for these)
- missing required skill or workflow guide (e.g. gitea-workflow, llm-project-workflow)
- broken terminal/tool runner or shell spawn failure
- MCP capability failure, reset, or deadlock (e.g. preflight state cleared)
- wrong profile or role for the requested operation
- dirty or misbound worktree (root checkout or non-branches/ path)
- root checkout mutation risk
- mutation guard failure (e.g. branches-only guard)
- missing required MCP tool/schema or operation
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
- unavailable project instructions or checked-in guides
- any other failure of a step the current workflow or controller prompt declares "required"
**Prohibited unless controller authorizes in writing for this instance:** temp scripts, direct API fallback, MCP internals, direct imports, in-memory state restoration, manual bypasses, or any continuation that hides the blocker. All such cases must be reported as process/tooling defects.
## Mode isolation
A run that starts in `review-merge-pr` mode may not create process issues,
@@ -167,6 +186,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
- [`release-tag.md`](templates/release-tag.md)
- [`canonical-state-comments.md`](templates/canonical-state-comments.md)
## Adapting to a project
@@ -184,6 +204,18 @@ Releases follow SemVer from remote `master` only, after full test suite passes.
See [`templates/release-tag.md`](templates/release-tag.md) and
`scripts/release-tag`.
## Proof: missing required workflow steps stop before mutation
- The llm-project-workflow router (this file) and every loaded workflow (work-issue.md, review-merge-pr.md, create-issue.md, etc.) now declare at the top: if required step/skill/tool/capability/instruction/profile/worktree binding/preflight fails, STOP, state BLOCKED, use blocked-diagnose-report.md template, only non-mutating recovery.
- Controller prompts (start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md, etc.) and the runbooks (docs/llm-workflow-runbooks.md) explicitly require the same and prohibit unsafe fallbacks.
- MCP guards (branches-only mutation guard #274, worktree binding #510, preflight purity, role checks, lease gates, gitea_lock_issue, etc.) plus the "prove before mutation" rules ensure that a BLOCKED state prevents git/Gitea mutations.
- When a skill/guide/tool is missing (e.g. gitea-workflow not mounted for a runtime), the load step in the router/prompt fails the "required" check → BLOCKED + report before any gitea_* call or git command that mutates.
- Terminal/shell failures, capability deadlocks, wrong profile, dirty/misbound worktree, root risk, guard failures, missing schema, stale state, unavailable instructions all map to the covered blocker classes and trigger the same stop + report.
- No code path in the canonical workflows allows continuation past a declared required step without the BLOCKED report.
- See also: Global LLM Worktree Rule, Shell Spawn Hard-Stop Rule, Identity and profile safety, Subagent Tool-Budget Guardrails, and the explicit prohibition list in Universal rules.
Tests / proof docs updated in this change + runbooks. Full relevant test runs (see PR handoff) pass; `git diff --check` clean. Missing-step cases are now documented to fail closed before mutation.
## Bootstrap Review Path (#557)
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
@@ -195,4 +227,3 @@ If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
`docs/bootstrap-review-path.md`
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
@@ -0,0 +1,52 @@
# Blocker Report Template (BLOCKED + DIAGNOSE)
Use this exact structure whenever a required workflow step cannot be performed. Emit this report and stop. Do not proceed to mutation or fallback unless a controller explicitly authorizes an exception in writing.
## Required step
<Describe the exact step, skill, tool, capability, instruction, profile, or preflight that was required. Include the canonical name and where it is defined (e.g. gitea-workflow skill, specific workflow file, gitea_xxx tool).>
## Observed failure
<Exact symptom, error message, missing output, guard error, 404, schema error, dirty state, wrong profile, etc. Quote relevant output or tool response.>
## Expected behavior
<What the workflow/docs/prompts say should happen. Reference the specific rule, template, or preflight that requires this step.>
## Checks performed
- List every verification attempted (e.g. gitea_whoami, resolve_task_capability, ls skills/, git status, mcp_list_*, worktree list, etc.)
- Note any discrepancies found (e.g. skill not mounted for this runtime, capability not in profile, cwd not under branches/, etc.)
## Safe recovery attempted
- Only non-mutating actions (reads, lists, views, status, whoami, resolve, fetch --dry, etc.)
- List what was tried and the result.
- If no safe recovery possible, state that explicitly.
## Likely classification
Choose one or more:
- missing required skill or workflow guide
- broken terminal/tool runner
- MCP capability failure or deadlock
- wrong profile or role
- dirty or misbound worktree
- root checkout mutation risk
- mutation guard failure
- missing required MCP tool/schema
- stale or inconsistent runtime state
- unavailable project instructions
- other: <describe>
## Durable fix recommendation
<Specific, actionable recommendation that fixes the root process/tooling issue (e.g. "Mount gitea-workflow skill for Codex under canonical name in ~/.codex/skills/", "Add preflight in llm-project-workflow/SKILL.md that hard-stops before any gitea_ call if X is unavailable", "Update controller prompt to require BLOCKED + this report before any fallback", "Grant capability in profile config", etc.). Do not suggest temp workarounds.>
## Mutation occurred?
- No (preferred and required unless explicitly authorized)
- Yes — describe exactly what was mutated and why it was unavoidable after diagnosis. (This should be rare and will trigger additional review.)
## Single next action
<One concrete next step for the current actor (e.g. "Controller to approve or reject recovery", "File follow-up issue #XXX for skill mounting", "Re-launch session from clean branches/ worktree after skill installed", "Stop and wait for profile update").>
---
**Rule reminder (do not bypass):**
If the required step is unavailable, you are BLOCKED. Diagnose using this template. Report. Stop. Unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) are prohibited unless a controller has authorized them for this specific instance in a prior handoff.
This report must appear in the final output / handoff before any further action.
@@ -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
```
@@ -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>
```
@@ -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`
@@ -1,4 +1,4 @@
# Template: merge a PR (eligible reviewer only)
# Template: merge a PR (eligible merger only)
Copy, fill the `<...>` fields, and paste as the task prompt.
@@ -10,12 +10,17 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
and is blocked when it disagrees with the local git remote URL.
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
- Only an eligible, NON-author merger merges. If authenticated user == PR
author → STOP.
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
- No force-merge, no bypassing branch protections.
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
@@ -63,10 +68,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
- fetch/prune; confirm main checkout is clean and current (0 0).
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
§K (long form — a merge is always high-risk), including the review/merge role
fields (Selected PR, Reviewer eligibility, Pinned reviewed head, Review
§K (long form — a merge is always high-risk), including the merger role
fields (Selected PR, Merger eligibility, Pinned reviewed head, Review
decision, Merge result, Linked issue status, Cleanup status) plus: merge
commit, PR metadata state/merged flag/hash, remote master hash, and the
post-merge verification method used & verification results. Reports missing
the handoff are downgraded (review_proofs.assess_controller_handoff).
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
```
@@ -3,12 +3,15 @@
Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then-
act: gather facts first, never discard unmerged work.
**BLOCKED + DIAGNOSE rule (llm-project-workflow):** If at any point a required step (including state recovery itself) cannot be performed, stop immediately, use the standard [`blocked-diagnose-report.md`](blocked-diagnose-report.md) template, attempt only safe non-mutating recovery, and report. Do not continue or fallback.
```text
Task: recover repo state for <situation>. Do not lose unmerged work.
Rules (llm-project-workflow):
- Fail closed: if state is unclear or a step would delete unmerged work, STOP.
- BLOCKED + DIAGNOSE default: if state is unclear, a required check fails, or a step would delete unmerged work or bypass a guard, STOP and emit a full blocked-diagnose-report.md using the template. Clearly state BLOCKED. Diagnose. Only non-mutating recovery.
- Never push master. Never discard commits not safely pushed to <remote>.
- Prove you are in a branches/ worktree before any recovery mutation.
Diagnose first:
1. git fetch <remote> --prune
@@ -16,8 +19,19 @@ Diagnose first:
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
4. For any PR involved: confirm state (open/closed/merged) AND whether
<remote>/master actually contains its commits ("closed" != "merged").
5. Check active leases, claims, and whether required skills/workflows are loaded.
Act per case:
If a required diagnostic or recovery step itself is unavailable (e.g. terminal broken, skill missing, guard blocks, wrong profile), emit:
## Required step
<the step>
## Observed failure
<...>
(complete the full blocked-diagnose-report.md template)
Act per case (only after clean diagnosis; if blocked, use the template and stop):
- Dirty worktree from another issue: leave it; start yours in a new worktree.
- Local master ahead of remote: confirm the extra commits live on a branch
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
@@ -26,6 +40,7 @@ Act per case:
- Branch deleted before merge: recover commits from a local branch/reflog (or
git fsck --lost-found), re-push, reopen the PR.
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
- Any blocker: use blocked-diagnose-report.md template and stop.
Handoff: what was wrong, evidence, action taken, current state, what remains.
Handoff: what was wrong, evidence, action taken, current state, what remains. If BLOCKED, include the full blocker report.
```
@@ -36,6 +36,10 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
- Find the latest CTH comment on the PR/issue thread before starting work.
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
@@ -110,8 +114,9 @@ Steps:
- base branch unchanged
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
If anything moved → STOP, re-pin, re-validate before any verdict.
10. Post the review verdict: approve only if scope is clean and checks pass;
otherwise request changes with specifics. Never merge from this review step.
10. Post the review verdict: approve only if scope is clean and checks pass;
otherwise request changes with specifics. Never merge from this review step.
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
Review Metadata:
@@ -128,6 +133,8 @@ Pinned reviewed head, Review decision, Merge result, Linked issue status,
Cleanup status. If you could not merge, name the exact gate. Reports missing
the handoff are downgraded (review_proofs.assess_controller_handoff).
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
@@ -10,6 +10,10 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
Rules (llm-project-workflow):
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, acquire lease, prove worktree under branches/, capability, profile, tool, instruction, preflight, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
- Find the latest CTH comment on the issue/PR thread before starting work.
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
- No repo changes without a tracking issue. If none exists, create one first;
if it can't be created, stop.
- Work only in an isolated branch worktree under branches/. The main checkout
@@ -12,6 +12,8 @@ This file is the canonical issue-creation workflow for Gitea-Tools. Load it
before any issue mutation. Final report schema:
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
**Default task prompt:**
> Create or update Gitea issues in this project only if every identity,
@@ -12,6 +12,8 @@ This file is the canonical PR review/merge workflow for Gitea-Tools. Load it
before any PR mutation. Final report schema:
[`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md).
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
**Default task prompt:**
> Review the next eligible open PR in this project. Merge it only if every
@@ -26,6 +28,12 @@ workflow rules exactly.
## 0. Load the canonical workflow first
Before starting PR work, **find the latest CTH comment** on the PR or linked
issue thread. Treat that CTH as the current handoff state. Post a new
**CTH: Reviewer Handoff**, **CTH: Merger Handoff**, **CTH: Blocker**, or
**CTH: Supersession Notice** when you finish, block, skip, supersede,
approve, request changes, or hand off. See `docs/canonical-thread-handoff.md`.
Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
@@ -12,6 +12,8 @@ This file is the canonical author/coder workflow for Gitea-Tools. Load it
before any issue implementation mutation. Final report schema:
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
**Default task prompt:**
> Find the next eligible issue in this project, work on it only if all gates
@@ -26,6 +28,12 @@ This is an author/coder workflow. It is not a reviewer workflow.
## 0. Load the canonical workflow first
Before starting issue work, **find the latest CTH comment** on the target
issue or linked PR thread. Treat that CTH as the current handoff state unless
a newer authoritative gate supersedes it. Post a new **CTH: Author Handoff**
(or `CTH: Blocker`) when you finish, block, or hand off.
See `docs/canonical-thread-handoff.md`.
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
@@ -615,10 +623,39 @@ After push, report:
If push fails, stop and produce a recovery handoff.
## 20A. Conflict-fix lease and push gate (#399)
## 20A. Live head re-pin before conflict-fix classification (#522)
Open PR inventory `mergeable` / `head_sha` fields are **advisory and may be
stale**. Before classifying a PR as conflicted or creating a conflict-fix
worktree:
1. Re-fetch the live PR (`gitea_view_pr` or equivalent) and record:
* inventory head SHA (if any)
* inventory mergeable (if any)
* **live** head SHA (full 40-char)
* **live** mergeable
2. Call `gitea_assess_conflict_fix_classification` with those values.
3. Act only on the returned classification and **pinned live head**:
* `stale_inventory_skip` / `live_mergeable_skip` → **do not** create a
conflict-fix worktree; do not mutate the PR branch for conflicts.
* `conflict_fix_needed` → conflict-fix worktree allowed for the pinned
live head only.
* `incomplete_live_repin` → stop; re-fetch live state.
4. If inventory head ≠ live head, report both SHAs and use the live head only.
5. If inventory says `mergeable:false` but live says `mergeable:true`, classify
as stale inventory and skip author conflict-fix mutation.
Conflict-fix final reports that claim conflict-fix work must include:
* citation of `gitea_assess_conflict_fix_classification`
* `Live head SHA: <40-char hex>` (or Pinned head SHA / Live PR head SHA)
* `Conflict-fix classification: <stale_inventory_skip|conflict_fix_needed|live_mergeable_skip|incomplete_live_repin>`
## 20B. Conflict-fix lease and push gate (#399)
When pushing to an existing PR branch to resolve merge conflicts:
0. Complete §20A live-head re-pin / classification first.
1. Call `gitea_acquire_conflict_fix_lease` before any push.
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
* branch head before push
+6 -1
View File
@@ -36,6 +36,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment",
"role": "author",
},
"create_label": {
"permission": "gitea.issue.comment",
"role": "author",
},
"create_branch": {
"permission": "gitea.branch.create",
"role": "author",
@@ -66,7 +70,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
},
"merge_pr": {
"permission": "gitea.pr.merge",
"role": "reviewer",
"role": "merger",
},
"adopt_merger_pr_lease": {
"permission": "gitea.pr.comment",
@@ -163,6 +167,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
"gitea_create_issue_comment": "comment_issue",
"gitea_mark_issue": "mark_issue",
"gitea_set_issue_labels": "set_issue_labels",
"gitea_create_label": "create_label",
"gitea_commit_files": "commit_files",
}
+2 -2
View File
@@ -10,7 +10,7 @@ import os
import subprocess
import sys
def test_connection(name, config):
def run_connection_test(name, config):
print(f"Testing MCP connection for '{name}'...")
command = config.get("command")
args = config.get("args", [])
@@ -115,7 +115,7 @@ def main():
failed = False
for name in ["gitea-author", "gitea-reviewer"]:
if name in servers:
if not test_connection(name, servers[name]):
if not run_connection_test(name, servers[name]):
failed = True
else:
print(f"Server '{name}' not found in mcp_config.json")
+8
View File
@@ -32,6 +32,14 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
monkeypatch.setattr(mcp_server, "_preflight_capability_violation", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_violation_files", [])
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
try:
import review_workflow_load
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
+15 -13
View File
@@ -81,13 +81,14 @@ class TestPreflightIntegration(unittest.TestCase):
mcp_server._preflight_resolved_role = "author"
control_root = "/repo/Gitea-Tools"
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Branches-only mutation guard", str(ctx.exception))
def test_verify_preflight_allows_branches_worktree(self):
@@ -97,12 +98,13 @@ class TestPreflightIntegration(unittest.TestCase):
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
worktree = "/repo/Gitea-Tools/branches/issue-274"
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
if __name__ == "__main__":
+217
View File
@@ -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()
+143
View File
@@ -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()
+139
View File
@@ -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()
+34
View File
@@ -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"
+142
View File
@@ -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()
+31
View File
@@ -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",
+225
View File
@@ -114,6 +114,28 @@ def _reconcile_handoff(drop=(), **overrides):
return text
class TestTwoCommentWorkflowIntegration(unittest.TestCase):
def test_tagged_pair_produces_no_two_comment_findings(self):
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
from tests.test_thread_state_ledger_validator import ( # noqa: E402
_minimal_handoff,
_minimal_ledger,
)
report = _minimal_handoff() + "\n\n" + _minimal_ledger()
self.assertEqual(findings_for_final_report(report), [])
def test_tagged_handoff_without_ledger_blocks_via_validator(self):
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
from tests.test_thread_state_ledger_validator import _minimal_handoff # noqa: E402
findings = findings_for_final_report(_minimal_handoff())
self.assertTrue(findings)
self.assertTrue(
any(f["severity"] == "block" for f in findings)
)
class TestValidatorFinding(unittest.TestCase):
def test_finding_shape(self):
finding = validator_finding(
@@ -351,6 +373,92 @@ class TestReconciliationRules(unittest.TestCase):
)
class TestCanonicalStateUpdateRules(unittest.TestCase):
def test_claimed_state_comment_without_block_blocks(self):
report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
)
def test_state_comment_missing_next_prompt_blocks(self):
report = (
_reconcile_handoff()
+ """
## Canonical PR State
STATE:
needs-review
WHO_IS_NEXT:
reviewer
NEXT_ACTION:
Review PR #99.
NEXT_PROMPT:
ISSUE:
#98
HEAD_SHA:
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
REVIEW_STATUS:
none
MERGE_READY:
no
"""
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
reasons = " ".join(f["reason"] for f in result["findings"])
self.assertIn("NEXT_PROMPT", reasons)
def test_valid_state_comment_claim_passes_shared_rule(self):
report = (
_reconcile_handoff()
+ """
## Canonical PR State
STATE:
needs-review
WHO_IS_NEXT:
reviewer
NEXT_ACTION:
Review PR #99 against the pinned head.
NEXT_PROMPT:
Load the review workflow and review PR #99.
ISSUE:
#98
HEAD_SHA:
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
REVIEW_STATUS:
none
MERGE_READY:
no
BLOCKERS:
none
"""
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertFalse(
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
)
class TestCanonicalReconcileSchema(unittest.TestCase):
"""Issue #307: reconciliation workflows emit only the canonical schema."""
@@ -579,6 +687,31 @@ class TestReconcilerCloseProof(unittest.TestCase):
)
class TestCanonicalCommentPostClaim(unittest.TestCase):
def test_blocks_post_claim_when_validator_rejected_in_report(self):
report = "\n".join([
"## Controller Handoff",
"- MCP/Gitea mutations: issue comment posted",
"- Issue comments posted: 1 canonical state comment",
"canonical comment validation failed (fail closed before posting).",
'- canonical_comment_validation: {"allowed": false}',
])
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("shared.canonical_comment_post_claim", rule_ids)
self.assertTrue(result["blocked"])
def test_allows_none_when_validator_rejected_without_post_claim(self):
report = "\n".join([
"## Controller Handoff",
"- Issue comments posted: none",
"canonical comment validation failed (fail closed before posting).",
])
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertNotIn("shared.canonical_comment_post_claim", rule_ids)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
@@ -594,8 +727,100 @@ class TestEntryPoint(unittest.TestCase):
def test_supported_task_kinds_include_review_and_reconcile(self):
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("merge_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
class TestMergePrRules(unittest.TestCase):
def test_clean_merger_report_passes(self):
lines = [
"## Controller Handoff",
"",
"- Task: merge PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: merger",
"- Identity: sysadmin / prgs-merger",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest in branches/review-203",
"- Mutations: merge completed",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: merge completed",
"- Review mutations: none",
"- Merge mutations: merged PR #203",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: metadata check",
"- Current status: PR merged",
"- Blockers: none",
"- Next: none",
"- Safety: review and merge are separate roles",
"- Selected PR: #203",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Push occurred during validation: no",
"- Active profile: prgs-merger",
"- Role kind: merger",
"- Merge capability source: profile",
"- Explicit operator authorization: MERGE PR 203",
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Approval at current head: yes",
"- Merge result: PR merged successfully",
"- Cleanup status: complete",
]
report = "\n".join(lines)
result = assess_final_report_validator(
report,
"merge_pr",
)
self.assertEqual(result["grade"], "A")
self.assertFalse(result["blocked"])
def test_missing_merger_fields_blocks(self):
lines = [
"## Controller Handoff",
"",
"- Task: merge PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: merger",
"- Identity: sysadmin / prgs-merger",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest in branches/review-203",
"- Mutations: merge completed",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: merge completed",
"- Review mutations: none",
"- Merge mutations: merged PR #203",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: metadata check",
"- Current status: PR merged",
"- Blockers: none",
"- Next: none",
"- Safety: review and merge are separate roles",
"- Selected PR: #203",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Push occurred during validation: no",
]
report = "\n".join(lines)
result = assess_final_report_validator(
report,
"merge_pr",
)
self.assertTrue(result["downgraded"])
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -198,10 +198,13 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
srv._preflight_capability_baseline_porcelain = ""
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
self._env_patch.start()
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
srv._preflight_resolved_role = None
self._env_patch.stop()
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@@ -230,7 +233,7 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
result = srv.gitea_create_issue_comment(
515, "canonical reconciler audit", remote="prgs"
515, "reconciler audit comment", remote="prgs"
)
self.assertTrue(result["success"])
self.assertEqual(result["comment_id"], 9001)
+6 -6
View File
@@ -107,7 +107,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
body="evidence comment",
remote="prgs",
)
self.assertIn("stable control checkout", str(ctx.exception))
@@ -140,7 +140,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
body="evidence comment",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
@@ -153,7 +153,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
method, url, _auth_arg, payload = mock_api.call_args[0]
self.assertEqual(method, "POST")
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
self.assertEqual(payload, {"body": "canonical evidence"})
self.assertEqual(payload, {"body": "evidence comment"})
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@@ -183,7 +183,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
body="evidence comment",
remote="prgs",
worktree_path=outside_worktree,
)
@@ -198,7 +198,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
body="evidence comment",
remote="prgs",
worktree_path=os.path.join(
CONTROL_CHECKOUT_ROOT,
@@ -239,7 +239,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
with patch.dict(os.environ, denied_env, clear=True):
result = srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
body="evidence comment",
remote="prgs",
worktree_path=valid_worktree,
)
+73
View File
@@ -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()
+6 -5
View File
@@ -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()
unittest.main()
+6 -2
View File
@@ -97,7 +97,7 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase):
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_get_all", return_value=[{"id": 4, "name": "status:pr-open"}])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -105,7 +105,11 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase):
self, _auth, _role, _api, _git_state, mock_api_request
):
worktree = os.path.realpath(os.getcwd())
mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"}
def mock_api(method, url, auth, data=None):
if "/labels" in url:
return [{"name": "status:pr-open"}]
return {"number": 521, "html_url": "https://example/pr/521"}
mock_api_request.side_effect = mock_api
lock_res = gitea_lock_issue(
issue_number=521,
branch_name="feat/issue-521-lock-issue-registration",
+6
View File
@@ -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():
+67
View File
@@ -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()
+183 -24
View File
@@ -91,6 +91,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
WORKFLOW_REPO_LABELS = [
{"id": 1, "name": "type:feature"},
{"id": 2, "name": "status:in-progress"},
{"id": 3, "name": "status:ready"},
{"id": 4, "name": "status:pr-open"},
]
def _issue_with_labels(*names):
return {"labels": [{"name": name} for name in names]}
def _reviewer_lease_comment(
@@ -305,6 +315,22 @@ class TestCreateIssue(unittest.TestCase):
self.assertIn("gitea.prgs.cc", url)
self.assertIn("Scaled-Tech-Consulting", url)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(
title="Test issue",
require_workflow_labels=True,
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("issue is missing a type:* label", result["reasons"])
mock_api.assert_not_called()
# ---------------------------------------------------------------------------
# Create PR
@@ -315,13 +341,24 @@ class TestCreatePR(unittest.TestCase):
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher):
def test_creates_pr(self, _auth, mock_api, _role, _labels, _dup_fetcher):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/123" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 3, "html_url": "https://example.com/pulls/3"}
if method == "PUT" and url.endswith("/issues/123/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
@@ -334,22 +371,37 @@ class TestCreatePR(unittest.TestCase):
)
self.assertEqual(result["number"], 3)
self.assertNotIn("url", result)
payload = mock_api.call_args[0][3]
post_call = next(c for c in mock_api.call_args_list if c[0][0] == "POST")
payload = post_call[0][3]
self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main")
self.assertIn("Closes #123", payload["title"])
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
self.assertEqual(put_call[0][3], {"labels": [1, 4]})
self.assertTrue(result["issue_status_transition"]["performed"])
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher):
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _labels, _dup_fetcher):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/123" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 3, "html_url": "https://example.com/pulls/3"}
if method == "PUT" and url.endswith("/issues/123/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True):
@@ -362,6 +414,38 @@ class TestCreatePR(unittest.TestCase):
)
self.assertIn("pulls/3", result["url"])
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=[{"id": 1, "name": "type:feature"}])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_blocks_when_pr_open_status_label_missing(
self, _auth, mock_api, _role, _labels, _dup_fetcher
):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr(
title="feat: X Closes #123",
head="feat/x",
base="main",
worktree_path=worktree,
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("status:pr-open", result["reasons"][0])
self.assertFalse(
any(c[0][0] == "POST" and c[0][1].endswith("/pulls")
for c in mock_api.call_args_list)
)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -486,26 +570,33 @@ class TestViewIssue(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_start_adds_label(self, _auth, mock_api):
# labels, add label, post claim heartbeat comment
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
{"id": 99},
]
def test_start_adds_label(self, _auth, mock_api, _labels):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/5" in url:
return _issue_with_labels("type:feature", "status:ready")
if method == "PUT" and url.endswith("/issues/5/labels"):
return [{"name": "type:feature"}, {"name": "status:in-progress"}]
if method == "POST" and url.endswith("/issues/5/comments"):
return {"id": 99}
return {}
mock_api.side_effect = api_side_effect
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"])
self.assertIn("claimed", result["message"])
self.assertTrue(result.get("heartbeat_posted"))
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
self.assertEqual(put_call[0][3], {"labels": [1, 2]})
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_done_removes_label(self, _auth, mock_api):
def test_done_removes_label(self, _auth, mock_api, _labels):
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
None,
]
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
@@ -517,10 +608,10 @@ class TestMarkIssue(unittest.TestCase):
with self.assertRaises(ValueError):
gitea_mark_issue(issue_number=5, action="pause")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_missing_label_raises(self, _auth, mock_api):
mock_api.return_value = [] # no labels exist
def test_missing_label_raises(self, _auth, mock_api, _labels):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with self.assertRaises(RuntimeError):
gitea_mark_issue(issue_number=5, action="start")
@@ -861,6 +952,19 @@ class TestMergePR(unittest.TestCase):
# -- identity / profile / eligibility fail-closed -------------------------
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs"
)
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_self_author_cannot_merge(self, _auth, mock_api):
@@ -902,14 +1006,13 @@ class TestMergePR(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
self.assertIn("profile is not allowed to merge", r["reasons"])
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
self._assert_no_merge_call(mock_api)
# -- PR state / mergeability ----------------------------------------------
@@ -3093,6 +3196,20 @@ class TestIssueCommentTools(unittest.TestCase):
self.assertFalse(result["success"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_tagged_handoff_missing_mutation_ledger_blocked(
self, _auth, mock_api
):
body = "[CONTROLLER HANDOFF] PR #1 — test\n\nBlockers:\n- none"
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = gitea_create_issue_comment(
issue_number=9, body=body, remote="prgs")
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result["reasons"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_empty_body_blocked(self, _auth, mock_api):
@@ -3102,6 +3219,36 @@ class TestIssueCommentTools(unittest.TestCase):
self.assertFalse(result["success"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_casual_comment_allowed(self, _auth, mock_api):
mock_api.return_value = self._comment(556, "gitea-author", "casual")
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = gitea_create_issue_comment(
issue_number=9,
body="Thanks, I will check this.",
remote="prgs",
)
self.assertTrue(result["success"])
self.assertTrue(result["performed"])
mock_api.assert_called_once()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_workflow_comment_missing_fields_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = gitea_create_issue_comment(
issue_number=9,
body="Blocked, author should fix.",
remote="prgs",
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("canonical_comment_validation", result)
self.assertFalse(result["canonical_comment_validation"]["allowed"])
self.assertTrue(result["canonical_comment_validation"]["is_workflow_comment"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_missing_issue_error_is_redacted(self, _auth, mock_api):
@@ -3765,13 +3912,24 @@ class TestIssueLocking(unittest.TestCase):
)
self.assertIn("lock provenance", str(ctx.exception).lower())
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api, _labels):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/249" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 250, "html_url": "https://example/pr/250"}
if method == "PUT" and url.endswith("/issues/249/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
_bind_test_lock(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
@@ -3786,6 +3944,7 @@ class TestIssueLocking(unittest.TestCase):
worktree_path=scratch,
)
self.assertEqual(res["number"], 250)
self.assertTrue(res["issue_status_transition"]["performed"])
# ---------------------------------------------------------------------------
@@ -4075,7 +4234,7 @@ class TestIssue546Deadlock(unittest.TestCase):
res = mcp_server.gitea_submit_pr_review(
pr_number=550,
action="approve",
body="APPROVED",
body="",
expected_head_sha="abc123",
final_review_decision_ready=True,
remote="prgs",
+199 -29
View File
@@ -4,7 +4,7 @@ import os
import sys
import tempfile
import unittest
from unittest.mock import patch, MagicMock
from unittest.mock import MagicMock, Mock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -163,7 +163,7 @@ class TestMergedCleanupReport(unittest.TestCase):
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})
@@ -177,14 +177,23 @@ class TestMergedCleanupReport(unittest.TestCase):
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
mock_state.return_value = {
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "cafebabe",
"dirty_files": [],
}
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "cafebabe",
"dirty_files": [],
},
]
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
@@ -209,29 +218,190 @@ class TestMergedCleanupReport(unittest.TestCase):
self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
self.assertEqual(local["match_type"], "branch")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_remove_local_worktree_uses_discovered_path(self, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools\n"
"HEAD aaaa\n"
"branch refs/heads/master\n\n"
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
# First call is discover_local_worktrees, second is git worktree remove
mock_run.side_effect = [
MagicMock(returncode=0, stdout=mock_output),
MagicMock(returncode=0, stdout="", stderr=""),
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-510-workspace-binding-isolation",
"head_sha": "cafebabe",
"dirty_files": [],
},
]
with patch("os.path.isdir", return_value=True):
result = mcr.remove_local_worktree("/repo/Gitea-Tools", "feat/issue-11-x")
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 512,
"title": "merged cleanup",
"body": "Closes #510",
"head": {
"ref": "feat/issue-510-workspace-binding-isolation",
"sha": "cafebabe",
},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
head_on_master={512: True},
delete_capability_allowed=True,
target_ref="prgs/master",
)
local = report["entries"][0]["local_worktree"]
self.assertTrue(local["safe_to_remove_worktree"])
self.assertEqual(local["match_type"], "branch")
self.assertEqual(
local["expected_worktree_path"],
"/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation",
)
self.assertEqual(
local["discovered_worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools/branches/issue-510-one\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
"worktree /repo/Gitea-Tools/branches/issue-510-two\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
mock_state.return_value = {
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
}
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 512,
"title": "merged cleanup",
"body": "Closes #510",
"head": {
"ref": "feat/issue-510-workspace-binding-isolation",
"sha": "cafebabe",
},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
head_on_master={512: True},
delete_capability_allowed=True,
target_ref="prgs/master",
)
local = report["entries"][0]["local_worktree"]
self.assertFalse(local["safe_to_remove_worktree"])
self.assertEqual(local["match_type"], "ambiguous_alias")
self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0])
@patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True)
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_alias_head_on_target_branch_is_safe_cleanup_commit(
self, mock_run, mock_state, _ancestor
):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
"HEAD deadbeef\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-510-workspace-binding-isolation",
"head_sha": "deadbeef",
"dirty_files": [],
},
]
state = mcr.resolve_cleanup_worktree_state(
project_root="/repo/Gitea-Tools",
head_branch="feat/issue-510-workspace-binding-isolation",
issue_number=510,
pr_head_sha="cafebabe",
target_ref="prgs/master",
)
self.assertTrue(state["exists"])
self.assertEqual(state["match_type"], "branch")
self.assertEqual(
state["discovered_worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir):
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = mcr.remove_local_worktree(
"/repo/Gitea-Tools",
"feat/issue-510-workspace-binding-isolation",
worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
self.assertTrue(result["success"])
self.assertTrue(result["performed"])
self.assertEqual(result["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
# Assert git worktree remove was called with the custom path
mock_run.assert_any_call(
["git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/custom-name"],
self.assertEqual(
result["worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
mock_run.assert_called_once_with(
[
"git",
"-C",
"/repo/Gitea-Tools",
"worktree",
"remove",
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
],
capture_output=True,
text=True,
check=False,
@@ -440,4 +610,4 @@ class TestReviewerScratchCleanup(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
unittest.main()
@@ -103,6 +103,8 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
srv._preflight_in_test_mode = lambda: False
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
self._env_patch.start()
self._guard_patch = mock.patch("gitea_mcp_server._enforce_root_checkout_guard")
self._guard_patch.start()
def tearDown(self):
srv._preflight_whoami_called = self._saved["whoami_called"]
@@ -112,6 +114,7 @@ class TestNamespaceWorkspaceIntegration(unittest.TestCase):
srv._preflight_capability_violation = self._saved["capability_violation"]
srv._preflight_in_test_mode = self._saved["in_test"]
self._env_patch.stop()
self._guard_patch.stop()
for key in (
nwb.AUTHOR_WORKTREE_ENV,
nwb.MERGER_WORKTREE_ENV,
+22 -2
View File
@@ -33,8 +33,15 @@ REVIEWER_ENV = {
"GITEA_PROFILE_NAME": "reviewer-test",
"GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
"gitea.pr.request_changes,gitea.pr.merge",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push",
"gitea.pr.request_changes",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.merge",
}
MERGER_ENV = {
"GITEA_PROFILE_NAME": "merger-test",
"GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.comment,gitea.pr.merge",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.approve",
}
EXPECTED_SKILLS = [
@@ -49,6 +56,10 @@ EXPECTED_SKILLS = [
"jenkins-mcp",
"glitchtip-mcp",
"release-operator",
# Canonical workflow router (#551) — same skill, multi-runtime names
"gitea-workflow",
"llm-project-workflow",
"git-pr-workflows",
]
@@ -88,6 +99,15 @@ class TestControlPlaneGuide(GuideTestBase):
self.assertIn("eligibility", blob)
self.assertIn("pinned", blob)
@patch("mcp_server.api_request", return_value={"login": "merger-bot"})
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merger_profile_guidance(self, _auth, _api):
with patch.dict(os.environ, MERGER_ENV, clear=True):
g = mcp_get_control_plane_guide(remote="prgs")
self.assertEqual(g["profile"]["role_kind"], "merger")
blob = " ".join(g["guidance"]).lower()
self.assertIn("merge", blob)
@patch("mcp_server.get_auth_header", return_value=None)
def test_unresolved_identity_instructs_stop(self, _auth):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
+6 -8
View File
@@ -234,11 +234,10 @@ class TestEligibilityDenialReport(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
from tests.test_mcp_server import _seed_ready_review_decision
_seed_ready_review_decision(42, "approve", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
mcp_server.init_review_decision_lock("prgs", "review_pr")
_seed_ready_review_decision(42, "approve", remote="prgs")
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="approve", body="lgtm", remote="prgs",
final_review_decision_ready=True)
@@ -255,9 +254,9 @@ class TestEligibilityDenialReport(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_load_review_workflow()
with patch.dict(os.environ, self._env("author-profile")):
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_load_review_workflow()
res = mcp_server.gitea_merge_pr(
pr_number=42, confirmation="MERGE PR 42",
expected_head_sha="abc123", remote="prgs")
@@ -280,11 +279,10 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
from tests.test_mcp_server import _seed_ready_review_decision
_seed_ready_review_decision(42, "comment", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
mcp_server.init_review_decision_lock("prgs", "review_pr")
_seed_ready_review_decision(42, "comment", remote="prgs")
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="comment", body="finding", remote="prgs",
final_review_decision_ready=True)
@@ -35,9 +35,12 @@ class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
self._env_patch.start()
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
self._env_patch.stop()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
+15 -4
View File
@@ -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):
+5
View File
@@ -35,6 +35,9 @@ def _minimal_review_report(**overrides):
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Current status: PR open",
"- Next actor: author",
"- Next action: Fix",
"- Next prompt: Fix",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
@@ -43,6 +46,8 @@ def _minimal_review_report(**overrides):
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Pinned reviewed head: none",
"- Scratch worktree used: none",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
+120
View File
@@ -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()
+46 -4
View File
@@ -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):
+187
View File
@@ -0,0 +1,187 @@
"""Tests for two-comment workflow validation (#507)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from thread_state_ledger_validator import ( # noqa: E402
BLOCKER_CLASSIFICATIONS,
assess_controller_handoff_comment,
assess_thread_state_ledger_comment,
assess_two_comment_workflow,
assess_two_comment_pair,
)
def _minimal_handoff(**overrides):
lines = [
"[CONTROLLER HANDOFF] PR #203 / Issue #182 — review complete",
"",
"Identity/profile:",
"- Active profile: prgs-reviewer",
"- Authenticated identity: sysadmin",
"",
"Server-side mutation ledger:",
"- gitea_submit_pr_review → APPROVED review posted to Gitea",
"",
"Blockers:",
"- none",
]
text = "\n".join(lines)
for key, value in overrides.items():
if f"{key}:" in text:
text = text.replace(f"{key}:", f"{key}: {value}", 1)
return text
def _minimal_ledger(**overrides):
lines = [
"[THREAD STATE LEDGER] PR #203 — APPROVED review posted to Gitea",
"",
"What is true now:",
"- PR state: open",
"- Current head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Server-side decision state: APPROVED review posted to Gitea",
"- Local verdict/state: APPROVE verdict prepared locally",
"- Latest known validation: tests passed locally",
"",
"What changed:",
"- APPROVED review posted to Gitea at pinned head",
"",
"What is blocked:",
"- Blocker classification: no blocker",
"",
"Who/what acts next:",
"- Next actor: merger",
"- Required action: merge on explicit operator command",
"- Do not do: re-post APPROVE",
"- Resume from: PR #203 review feedback",
]
text = "\n".join(lines)
for key, value in overrides.items():
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
class TestTwoCommentWorkflow(unittest.TestCase):
def test_valid_combined_report_passes(self):
report = _minimal_handoff() + "\n\n" + _minimal_ledger()
result = assess_two_comment_workflow(report)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_missing_ledger_after_tagged_handoff_blocks(self):
result = assess_two_comment_workflow(_minimal_handoff())
self.assertTrue(result["block"])
self.assertTrue(
any("THREAD STATE LEDGER" in r for r in result["reasons"])
)
def test_legacy_controller_handoff_section_not_required_ledger(self):
legacy = "## Controller Handoff\n\n- Task: work issue\n- Next: continue\n"
result = assess_two_comment_workflow(legacy)
self.assertFalse(result["block"])
def test_ambiguous_approved_blocks(self):
handoff = _minimal_handoff().replace(
"APPROVED review posted to Gitea",
"approved",
)
report = handoff + "\n\n" + _minimal_ledger()
result = assess_two_comment_workflow(report)
self.assertTrue(result["block"])
self.assertTrue(any("approved" in r.lower() for r in result["reasons"]))
def test_ambiguous_merged_blocks(self):
handoff = _minimal_handoff() + "\n\n- Narrative: merged successfully"
ledger = _minimal_ledger()
report = handoff + "\n\n" + ledger
result = assess_two_comment_workflow(report)
self.assertTrue(result["block"])
self.assertTrue(any("merge" in r.lower() for r in result["reasons"]))
def test_blocked_without_gate_blocks(self):
handoff = _minimal_handoff().replace("- none", "- blocked")
ledger = _minimal_ledger().replace(
"no blocker", "blocked but no gate named"
)
report = handoff + "\n\n" + ledger
result = assess_two_comment_workflow(report)
self.assertTrue(result["block"])
def test_mutation_ledger_contradiction_blocks(self):
handoff = _minimal_handoff().replace(
"APPROVED review posted to Gitea",
"none — no server-side state changed",
)
handoff += "\n- Narrative: APPROVED review posted to Gitea"
report = handoff + "\n\n" + _minimal_ledger()
result = assess_two_comment_workflow(report)
self.assertTrue(result["block"])
self.assertTrue(any("contradict" in r.lower() for r in result["reasons"]))
def test_local_verdict_as_server_side_blocks(self):
ledger = _minimal_ledger(
**{
"Server-side decision state": "APPROVE verdict prepared locally",
"Local verdict/state": "none",
}
)
report = _minimal_handoff() + "\n\n" + ledger
result = assess_two_comment_workflow(report)
self.assertTrue(result["block"])
class TestHandoffComment(unittest.TestCase):
def test_handoff_comment_valid(self):
result = assess_controller_handoff_comment(_minimal_handoff())
self.assertTrue(result["proven"])
def test_handoff_without_marker_skips(self):
result = assess_controller_handoff_comment("casual comment")
self.assertTrue(result["proven"])
self.assertFalse(result["performed"])
class TestLedgerComment(unittest.TestCase):
def test_ledger_requires_blocker_classification(self):
ledger = _minimal_ledger().replace(
"Blocker classification: no blocker",
"something unclear",
)
result = assess_thread_state_ledger_comment(ledger)
self.assertTrue(result["block"])
def test_all_blocker_classifications_recognized(self):
self.assertIn("no blocker", BLOCKER_CLASSIFICATIONS)
self.assertIn("environment/tooling blocker", BLOCKER_CLASSIFICATIONS)
class TestPairedComments(unittest.TestCase):
def test_pair_passes(self):
result = assess_two_comment_pair(_minimal_handoff(), _minimal_ledger())
self.assertTrue(result["proven"])
def test_pair_missing_ledger_blocks(self):
result = assess_two_comment_pair(_minimal_handoff(), "")
self.assertTrue(result["block"])
class TestExamples(unittest.TestCase):
"""Smoke-test bundled examples from docs."""
def test_examples_module_loads(self):
from thread_state_ledger_examples import EXAMPLES # noqa: E402
self.assertGreaterEqual(len(EXAMPLES), 8)
for name, handoff, ledger in EXAMPLES:
result = assess_two_comment_pair(handoff, ledger)
self.assertTrue(
result["proven"],
f"example '{name}' failed: {result['reasons']}",
)
if __name__ == "__main__":
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""Tests for web UI report audit paste (#431)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.audit_validator import audit_report, infer_task_kind
_MINIMAL_REVIEW_REPORT = """## Controller Handoff
- Task: review PR #1
- Repo: Scaled-Tech-Consulting/Gitea-Tools
- Role: reviewer
- Identity: sysadmin / prgs-reviewer
- Review decision: approve
"""
class TestAuditValidator(unittest.TestCase):
def test_infer_task_kind_from_review_report(self):
self.assertEqual(infer_task_kind(_MINIMAL_REVIEW_REPORT), "review_pr")
def test_infer_task_kind_explicit_override(self):
self.assertEqual(
infer_task_kind(_MINIMAL_REVIEW_REPORT, explicit="work_issue"),
"work_issue",
)
def test_empty_report_blocked(self):
result = audit_report("")
self.assertTrue(result["blocked"])
self.assertIn("empty", result["reasons"][0].lower())
def test_minimal_report_produces_findings(self):
result = audit_report(_MINIMAL_REVIEW_REPORT)
self.assertIn(result["grade"], {"blocked", "downgraded", "warning", "A"})
self.assertTrue(result["findings"] or result["blocked"] or result["downgraded"])
class TestAuditRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_audit_page_renders_form(self):
response = self.client.get("/audit")
self.assertEqual(response.status_code, 200)
self.assertIn("Report audit", response.text)
self.assertIn('name="report_text"', response.text)
self.assertIn("Run validator preview", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_audit_post_runs_validator(self):
response = self.client.post(
"/audit",
data={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"},
)
self.assertEqual(response.status_code, 200)
self.assertIn("Validator preview", response.text)
self.assertIn("Safe next action", response.text)
def test_api_audit_post_json(self):
response = self.client.post(
"/api/audit",
json={"report_text": _MINIMAL_REVIEW_REPORT, "task_kind": "review_pr"},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["task_kind"], "review_pr")
self.assertIn("grade", data)
self.assertIn("findings", data)
self.assertIn("suggested_next_prompt", data)
def test_api_audit_rejects_empty_body(self):
response = self.client.post("/api/audit", json={"report_text": ""})
self.assertEqual(response.status_code, 400)
def test_audit_post_allowed_other_post_still_blocked(self):
self.assertEqual(self.client.post("/health").status_code, 405)
self.assertEqual(
self.client.post(
"/audit",
data={"report_text": _MINIMAL_REVIEW_REPORT},
).status_code,
200,
)
if __name__ == "__main__":
unittest.main()
+11 -10
View File
@@ -34,12 +34,10 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
def test_route_stubs_render(self):
for path in ("/audit",):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
def test_audit_is_implemented(self):
response = self.client.get("/audit")
self.assertEqual(response.status_code, 200)
self.assertIn("Report audit", response.text)
def test_prompts_is_implemented(self):
response = self.client.get("/prompts")
@@ -51,14 +49,17 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn("Gitea-Tools", response.text)
def test_worktrees_is_implemented(self):
response = self.client.get("/worktrees")
self.assertEqual(response.status_code, 200)
self.assertIn("Worktree hygiene", response.text)
def test_leases_is_implemented(self):
response = self.client.get("/leases")
self.assertEqual(response.status_code, 200)
self.assertIn("Leases", response.text)
self.assertIn("Collision warnings", response.text)
def test_extra_stub_routes(self):
self.assertEqual(self.client.get("/worktrees").status_code, 200)
def test_actions_is_implemented(self):
response = self.client.get("/actions")
@@ -76,8 +77,8 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Live queue", response.text)
def test_nav_links_on_all_pages(self):
paths = ('/', '/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions')
hrefs = ('/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions')
paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
for path in paths:
with self.subTest(path=path):
text = self.client.get(path).text
+114
View File
@@ -0,0 +1,114 @@
"""Tests for web UI worktree hygiene dashboard (#432)."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.worktree_scanner import (
classify_entry,
load_hygiene_snapshot,
parse_worktree_list_porcelain,
snapshot_to_dict,
)
class TestWorktreeScanner(unittest.TestCase):
def test_parse_worktree_porcelain(self):
porcelain = (
"worktree /repo/branches/feat-issue-1-foo\n"
"HEAD abcdef0123456789\n"
"branch refs/heads/feat/issue-1-foo\n"
"\n"
)
entries = parse_worktree_list_porcelain(porcelain)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["branch"], "feat/issue-1-foo")
def test_classify_active_pr(self):
classification, _ = classify_entry(
rel_path="branches/feat-issue-99-demo",
worktree_record={"branch": "feat/issue-99-demo"},
state={"exists": True, "clean": True, "dirty_files": []},
open_pr_branches={"feat/issue-99-demo"},
active_lock_branch=None,
)
self.assertEqual(classification, "active-pr")
def test_classify_dirty(self):
classification, _ = classify_entry(
rel_path="branches/feat-issue-2-bar",
worktree_record={"branch": "feat/issue-2-bar"},
state={"exists": True, "clean": False, "dirty_files": ["webui/app.py"]},
open_pr_branches=set(),
active_lock_branch=None,
)
self.assertEqual(classification, "dirty")
def test_missing_lock_worktree_anomaly(self):
with tempfile.TemporaryDirectory() as tmp:
branches = Path(tmp) / "branches" / "other-worktree"
branches.mkdir(parents=True)
lock_path = Path(tmp) / "lock.json"
lock_path.write_text(
json.dumps({
"issue_number": 432,
"branch_name": "feat/issue-432-worktree-hygiene",
"worktree_path": str(Path(tmp) / "branches" / "missing-tree"),
}),
encoding="utf-8",
)
snapshot = load_hygiene_snapshot(
project_root=tmp,
worktree_porcelain="",
open_pr_branches=set(),
issue_lock_path=str(lock_path),
)
self.assertTrue(snapshot.anomalies)
self.assertIn("Missing preserved worktree", snapshot.anomalies[0])
def test_snapshot_to_dict(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "branches").mkdir()
snapshot = load_hygiene_snapshot(
project_root=tmp,
worktree_porcelain="",
open_pr_branches=set(),
issue_lock_path=str(Path(tmp) / "no-lock.json"),
)
data = snapshot_to_dict(snapshot)
self.assertIn("entries", data)
self.assertIn("cleanup_prompt", data)
class TestWorktreeRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_worktrees_page_renders(self):
response = self.client.get("/worktrees")
self.assertEqual(response.status_code, 200)
self.assertIn("Worktree hygiene", response.text)
self.assertIn("Canonical cleanup prompt", response.text)
self.assertIn("Copy cleanup prompt", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_worktrees_json(self):
response = self.client.get("/api/worktrees")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("entries", data)
self.assertIn("cleanup_prompt", data)
self.assertIn("anomalies", data)
def test_nav_includes_worktrees(self):
self.assertIn('href="/worktrees"', self.client.get("/").text)
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Tests for canonical workflow skill naming and preflight (#551)."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import workflow_skill
import mcp_server
class TestWorkflowSkillModule(unittest.TestCase):
def test_canonical_names_include_gitea_workflow(self):
names = workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES
self.assertIn("gitea-workflow", names)
self.assertIn("llm-project-workflow", names)
self.assertIn("git-pr-workflows", names)
def test_repo_skill_present_in_this_checkout(self):
root = str(Path(__file__).resolve().parent.parent)
result = workflow_skill.assess_workflow_skill_presence(root)
self.assertTrue(result["repo_skill_present"])
self.assertTrue(result["repo_alias_present"])
self.assertTrue(result["workflow_skill_ready"])
self.assertFalse(result["blocked"])
def test_missing_repo_skill_blocks(self):
with tempfile.TemporaryDirectory() as tmp:
result = workflow_skill.assess_workflow_skill_presence(tmp)
self.assertTrue(result["blocked"])
self.assertFalse(result["workflow_skill_ready"])
self.assertTrue(any("missing" in r for r in result["reasons"]))
def test_codex_mount_detection(self):
root = str(Path(__file__).resolve().parent.parent)
with tempfile.TemporaryDirectory() as tmp:
skill_dir = Path(tmp) / "gitea-workflow"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("# x\n", encoding="utf-8")
result = workflow_skill.assess_workflow_skill_presence(
root, codex_skills_dir=tmp
)
self.assertTrue(result["codex_skill_mounted"])
self.assertTrue(result["codex_skill_mounts"]["gitea-workflow"])
def test_registry_entries_cover_aliases(self):
entries = workflow_skill.workflow_skill_registry_entries()
for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES:
self.assertIn(name, entries)
self.assertEqual(entries[name]["status"], "available")
class TestWorkflowSkillMcpTools(unittest.TestCase):
def test_list_includes_canonical_names(self):
with patch.dict(
os.environ,
{
"GITEA_PROFILE_NAME": "author-test",
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
"GITEA_FORBIDDEN_OPERATIONS": "",
},
clear=False,
):
result = mcp_server.mcp_list_project_skills()
names = {s["name"] for s in result["skills"]}
for name in workflow_skill.CANONICAL_WORKFLOW_SKILL_NAMES:
self.assertIn(name, names)
def test_get_skill_guide_gitea_workflow(self):
guide = mcp_server.mcp_get_skill_guide("gitea-workflow")
self.assertTrue(guide["success"])
self.assertTrue(guide["steps"])
self.assertIn("workflow", " ".join(guide["steps"]).lower())
def test_preflight_tool_ok_on_repo(self):
result = mcp_server.mcp_check_workflow_skill_preflight()
self.assertTrue(result["success"])
self.assertTrue(result["workflow_skill_ready"])
self.assertFalse(result["blocked"])
self.assertIn("gitea-workflow", result["canonical_names"])
if __name__ == "__main__":
unittest.main()
+341
View File
@@ -0,0 +1,341 @@
"""Worked examples for the two-comment workflow (#507)."""
from __future__ import annotations
HEAD_SHA = "08202f7eaa6d09b2eca8f2960126994a4b22646b"
EXAMPLES: list[tuple[str, str, str]] = []
def _example(name: str, handoff: str, ledger: str) -> tuple[str, str, str]:
return (name, handoff.strip(), ledger.strip())
EXAMPLES.append(
_example(
"approved_review_posted",
f"""
[CONTROLLER HANDOFF] PR #487 / Issue #485 — review approved
Server-side mutation ledger:
- gitea_submit_pr_review APPROVED review posted to Gitea (comment id 6566)
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea
What is true now:
- PR state: open
- Current head SHA: {HEAD_SHA}
- Server-side decision state: APPROVED review posted to Gitea
- Local verdict/state: APPROVE verdict prepared locally
- Latest known validation: tests passed locally
What changed:
- APPROVED review posted to Gitea at pinned head
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: merger
- Required action: merge on explicit operator command
- Do not do: re-post APPROVE
- Resume from: PR #487 review feedback
""",
)
)
EXAMPLES.append(
_example(
"approve_blocked_before_posting",
f"""
[CONTROLLER HANDOFF] PR #487 / Issue #485 — approve blocked
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- process/rule blocker: MCP process root still control checkout
""",
f"""
[THREAD STATE LEDGER] PR #487 — APPROVE verdict prepared locally; post blocked
What is true now:
- PR state: open
- Current head SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: APPROVE verdict prepared locally
- Latest known validation: tests passed locally
What changed:
- Review validation completed locally; APPROVE not posted
What is blocked:
- Blocker classification: process/rule blocker
Who/what acts next:
- Next actor: operator
- Required action: reconnect reviewer MCP to branches worktree
- Do not do: retry APPROVE from root-bound session
- Resume from: lease + mark_final_review_decision + submit
""",
)
)
EXAMPLES.append(
_example(
"request_changes_posted",
f"""
[CONTROLLER HANDOFF] PR #200 — request changes
Server-side mutation ledger:
- gitea_submit_pr_review REQUEST_CHANGES posted to Gitea
Blockers:
- none
""",
"""
[THREAD STATE LEDGER] PR #200 — REQUEST_CHANGES posted to Gitea
What is true now:
- PR state: open
- Server-side decision state: REQUEST_CHANGES posted to Gitea
- Local verdict/state: REQUEST_CHANGES prepared locally
- Latest known validation: tests passed locally
What changed:
- REQUEST_CHANGES posted to Gitea
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: address review feedback and push fixes
- Do not do: merge
- Resume from: PR review comments
""",
)
)
EXAMPLES.append(
_example(
"merge_completed",
f"""
[CONTROLLER HANDOFF] PR #487 — merge performed
Server-side mutation ledger:
- gitea_merge_pr merge performed
Blockers:
- none
""",
f"""
[THREAD STATE LEDGER] PR #487 — merge performed
What is true now:
- PR state: merged
- Current head SHA: {HEAD_SHA}
- Server-side decision state: merge performed
- Local verdict/state: merge not attempted locally before MCP merge
- Latest known validation: full suite not rerun
What changed:
- merge performed via gitea_merge_pr
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: reconciler
- Required action: verify linked issue closure policy
- Do not do: re-merge
- Resume from: post-merge cleanup checklist
""",
)
)
EXAMPLES.append(
_example(
"merge_blocked",
f"""
[CONTROLLER HANDOFF] PR #487 — merge not performed
Server-side mutation ledger:
- gitea_merge_pr attempted merge not performed (stale approval gate)
Blockers:
- stale head blocker: approval not at current head
""",
f"""
[THREAD STATE LEDGER] PR #487 — merge not performed
What is true now:
- PR state: open
- Current head SHA: {HEAD_SHA}
- Server-side decision state: merge not performed
- Local verdict/state: merge not performed
- Latest known validation: tests passed locally
What changed:
- merge attempt blocked by stale-head gate
What is blocked:
- Blocker classification: stale head
Who/what acts next:
- Next actor: reviewer
- Required action: re-review at current head
- Do not do: merge until approval_at_current_head is true
- Resume from: gitea_get_pr_review_feedback
""",
)
)
EXAMPLES.append(
_example(
"reconciler_closure",
f"""
[CONTROLLER HANDOFF] PR #99 — reconciler closure
Server-side mutation ledger:
- gitea_reconcile_already_landed_pr PR closed on Gitea
Blockers:
- none
""",
"""
[THREAD STATE LEDGER] PR #99 — reconciler closure complete
What is true now:
- PR state: closed
- Server-side decision state: server-side state changed
- Local verdict/state: no server-side state changed locally before MCP close
- Latest known validation: ancestor proof passed
What changed:
- superseded PR closed by reconciler
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: controller
- Required action: verify queue has no duplicate open PR
- Do not do: reopen without canonical PR proof
- Resume from: queue inventory
""",
)
)
EXAMPLES.append(
_example(
"environment_tooling_blocker",
f"""
[CONTROLLER HANDOFF] PR #487 — tooling blocker
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- environment/tooling blocker: gitea-reviewer MCP process root mismatch
""",
f"""
[THREAD STATE LEDGER] PR #487 — lease attempt blocked
What is true now:
- PR state: open
- Current head SHA: {HEAD_SHA}
- Server-side decision state: no server-side state changed
- Local verdict/state: APPROVE verdict prepared locally
- Latest known validation: tests passed locally
What changed:
- lease attempt blocked by workspace guard
What is blocked:
- Blocker classification: environment/tooling blocker
Who/what acts next:
- Next actor: operator
- Required action: set GITEA_AUTHOR_WORKTREE and /mcp reconnect
- Do not do: acquire lease from control checkout
- Resume from: gitea_get_runtime_context
""",
)
)
EXAMPLES.append(
_example(
"stale_head_blocker",
f"""
[CONTROLLER HANDOFF] PR #300 — stale head
Server-side mutation ledger:
- none no server-side state changed
Blockers:
- stale head blocker: live head differs from pinned reviewed head
""",
"""
[THREAD STATE LEDGER] PR #300 — stale approval
What is true now:
- PR state: open
- Server-side decision state: no server-side state changed
- Local verdict/state: approval_at_current_head is false (stale head)
- Latest known validation: full suite not rerun
What changed:
- author pushed after approval
What is blocked:
- Blocker classification: stale head
Who/what acts next:
- Next actor: reviewer
- Required action: fresh review at current head
- Do not do: merge with stale approval
- Resume from: gitea_get_pr_review_feedback
""",
)
)
EXAMPLES.append(
_example(
"duplicate_canonicalization_blocker",
f"""
[CONTROLLER HANDOFF] Issue filing duplicate found
Server-side mutation ledger:
- gitea_create_issue_comment on #505 with missing acceptance criteria
Blockers:
- duplicate/canonicalization blocker: #505 tracks CTH umbrella
""",
"""
[THREAD STATE LEDGER] Issue #507 — filed as focused split from #505
What is true now:
- Issue state: open
- Server-side decision state: server-side state changed
- Local verdict/state: no server-side state changed before issue create
- Latest known validation: duplicate search completed
What changed:
- new issue #507 created after duplicate assessment
What is blocked:
- Blocker classification: no blocker
Who/what acts next:
- Next actor: author
- Required action: implement #507 two-comment validator
- Do not do: recreate duplicate CTH issue
- Resume from: issue #507 body
""",
)
)
+399
View File
@@ -0,0 +1,399 @@
"""Two-comment workflow validation for Controller Handoff + Thread State Ledger (#507).
Validates the paired reporting pattern without replacing the CTH umbrella (#505)
or the broader lifecycle ledger (#494/#495).
"""
from __future__ import annotations
import re
from typing import Any
CONTROLLER_HANDOFF_TAG = "[CONTROLLER HANDOFF]"
THREAD_STATE_LEDGER_TAG = "[THREAD STATE LEDGER]"
BLOCKER_CLASSIFICATIONS = frozenset({
"code blocker",
"test blocker",
"merge conflict",
"stale head",
"permission/capability blocker",
"environment/tooling blocker",
"process/rule blocker",
"queue/lease blocker",
"duplicate/canonicalization blocker",
"no blocker",
})
_PRECISE_APPROVE_PHRASES = (
"approve verdict prepared locally",
"approved review posted to gitea",
"request_changes posted to gitea",
)
_PRECISE_MERGE_PHRASES = (
"merge performed",
"merge not performed",
)
_PRECISE_SERVER_PHRASES = (
"server-side state changed",
"no server-side state changed",
)
_AMBIGUOUS_STANDALONE_TERMS = (
"approved",
"ready",
"queued",
"blocked",
"done",
"merged",
"submitted",
"validated",
)
_HANDOFF_TAG_RE = re.compile(r"\[CONTROLLER\s+HANDOFF\]", re.IGNORECASE)
_LEDGER_TAG_RE = re.compile(r"\[THREAD\s+STATE\s+LEDGER\]", re.IGNORECASE)
_LEGACY_HANDOFF_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
_MUTATION_LEDGER_RE = re.compile(
r"server-side mutation ledger\s*:",
re.IGNORECASE,
)
_BLOCKER_CLASS_RE = re.compile(
r"blocker\s+classification\s*:\s*(.+)",
re.IGNORECASE,
)
_LEDGER_REQUIRED_SECTIONS = (
"what is true now",
"what changed",
"what is blocked",
"who/what acts next",
)
_LEDGER_REQUIRED_FIELDS = (
"server-side decision state",
"local verdict/state",
"next actor",
"required action",
)
def _lower(text: str) -> str:
return (text or "").lower()
def has_controller_handoff_marker(text: str) -> bool:
return bool(_HANDOFF_TAG_RE.search(text or ""))
def has_thread_state_ledger_marker(text: str) -> bool:
return bool(_LEDGER_TAG_RE.search(text or ""))
def _has_tagged_handoff(text: str) -> bool:
return has_controller_handoff_marker(text)
def _has_tagged_ledger(text: str) -> bool:
return has_thread_state_ledger_marker(text)
def _has_legacy_handoff(text: str) -> bool:
return bool(_LEGACY_HANDOFF_RE.search(text or ""))
def _section_after_tag(text: str, tag_pattern: re.Pattern[str]) -> str | None:
text = text or ""
match = tag_pattern.search(text)
if not match:
return None
start = match.start()
other_tags = (
_HANDOFF_TAG_RE,
_LEDGER_TAG_RE,
)
end = len(text)
for other in other_tags:
if other.pattern == tag_pattern.pattern:
continue
later = other.search(text, match.end())
if later:
end = min(end, later.start())
return text[start:end]
def _extract_mutation_ledger(text: str) -> str:
lines = (text or "").splitlines()
capture = False
chunks: list[str] = []
for line in lines:
stripped = line.strip().lower()
if "server-side mutation ledger" in stripped:
capture = True
continue
if capture:
if stripped.startswith("local-only") or stripped.startswith("blockers:"):
break
chunks.append(line)
return "\n".join(chunks)
def _ambiguous_term_violations(text: str, *, scoped: bool) -> list[str]:
if not scoped:
return []
reasons: list[str] = []
lower = _lower(text)
for term in _AMBIGUOUS_STANDALONE_TERMS:
if not re.search(rf"\b{re.escape(term)}\b", lower):
continue
if term == "approved":
if any(p in lower for p in _PRECISE_APPROVE_PHRASES):
continue
if "review decision:" in lower and "approve" in lower:
continue
if "approval_at_current_head" in lower:
continue
elif term == "merged":
if any(p in lower for p in _PRECISE_MERGE_PHRASES):
continue
if "merge result:" in lower:
continue
elif term == "blocked":
if any(c in lower for c in BLOCKER_CLASSIFICATIONS):
continue
if "blocker classification:" in lower:
continue
reasons.append(
f"ambiguous standalone term '{term}' without precise state language"
)
return reasons
def _mutation_contradiction_reasons(handoff_text: str) -> list[str]:
reasons: list[str] = []
lower = _lower(handoff_text)
ledger = _lower(_extract_mutation_ledger(handoff_text))
no_server = (
"no server-side state changed" in ledger
or "none — no server-side" in ledger
or re.search(r"\bnone\b", ledger)
)
claims_posted = "approved review posted to gitea" in lower
claims_merge = "merge performed" in lower or (
re.search(r"\bmerged\b", lower) and "merge not performed" not in lower
)
if claims_posted and no_server:
reasons.append(
"narrative claims APPROVED review posted but mutation ledger "
"shows no server-side state changed"
)
if claims_merge and no_server:
reasons.append(
"narrative claims merge but mutation ledger lacks merge proof"
)
if (
"approved review posted to gitea" in lower
and "no server-side state changed" in ledger
):
reasons.append(
"mutation ledger contradicts APPROVED review posted claim"
)
return reasons
def _blocked_without_gate_reasons(handoff_text: str, ledger_text: str) -> list[str]:
reasons: list[str] = []
combined = _lower(handoff_text) + "\n" + _lower(ledger_text)
if not re.search(r"\bblocked\b", combined):
return reasons
has_classification = bool(_BLOCKER_CLASS_RE.search(ledger_text or ""))
has_gate = any(
marker in combined
for marker in (
"exact gate",
"failing gate",
"blocker classification:",
"process/rule blocker",
"environment/tooling blocker",
"permission/capability blocker",
)
)
if not has_classification and not has_gate:
reasons.append(
"handoff or ledger says blocked but does not identify exact "
"failing gate or blocker classification"
)
return reasons
def _local_server_separation_reasons(ledger_text: str) -> list[str]:
reasons: list[str] = []
lower = _lower(ledger_text)
server_line = ""
local_line = ""
for line in (ledger_text or "").splitlines():
stripped = line.strip().lower()
if stripped.startswith("- server-side decision state:"):
server_line = stripped
if stripped.startswith("- local verdict/state:"):
local_line = stripped
if not server_line or not local_line:
return reasons
if "approve verdict prepared locally" in server_line:
reasons.append(
"local-only verdict incorrectly listed under server-side "
"decision state"
)
if "approved review posted to gitea" in local_line:
reasons.append(
"server-side decision incorrectly listed under local verdict/state"
)
return reasons
def _ledger_field_reasons(ledger_text: str) -> list[str]:
reasons: list[str] = []
lower = _lower(ledger_text)
for section in _LEDGER_REQUIRED_SECTIONS:
if section not in lower:
reasons.append(f"thread state ledger missing section '{section}'")
for field in _LEDGER_REQUIRED_FIELDS:
if field not in lower:
reasons.append(f"thread state ledger missing field '{field}'")
match = _BLOCKER_CLASS_RE.search(ledger_text or "")
if not match:
reasons.append("thread state ledger missing blocker classification")
else:
value = match.group(1).strip().lower().rstrip(".")
if value not in BLOCKER_CLASSIFICATIONS:
reasons.append(
f"blocker classification '{value}' is not a recognized value"
)
if "do not do:" not in lower:
reasons.append(
"thread state ledger missing explicit 'Do not do' guidance"
)
return reasons
def _assessment(
reasons: list[str],
*,
performed: bool = True,
) -> dict[str, Any]:
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"performed": performed,
}
def assess_controller_handoff_comment(body: str) -> dict[str, Any]:
"""Validate a standalone ``[CONTROLLER HANDOFF]`` comment body."""
text = body or ""
if not _has_tagged_handoff(text):
return _assessment([], performed=False)
reasons: list[str] = []
if not _MUTATION_LEDGER_RE.search(text):
reasons.append(
"controller handoff missing 'Server-side mutation ledger' section"
)
reasons.extend(_ambiguous_term_violations(text, scoped=True))
reasons.extend(_mutation_contradiction_reasons(text))
return _assessment(reasons)
def assess_thread_state_ledger_comment(body: str) -> dict[str, Any]:
"""Validate a standalone ``[THREAD STATE LEDGER]`` comment body."""
text = body or ""
if not _has_tagged_ledger(text):
return _assessment([], performed=False)
reasons = _ledger_field_reasons(text)
reasons.extend(_ambiguous_term_violations(text, scoped=True))
reasons.extend(_local_server_separation_reasons(text))
return _assessment(reasons)
def assess_two_comment_pair(
handoff_body: str,
ledger_body: str,
) -> dict[str, Any]:
"""Validate paired Gitea comments (handoff then ledger)."""
reasons: list[str] = []
handoff = handoff_body or ""
ledger = ledger_body or ""
if _has_tagged_handoff(handoff) and not _has_tagged_ledger(ledger):
reasons.append(
"controller handoff posted without follow-up THREAD STATE LEDGER"
)
handoff_result = assess_controller_handoff_comment(handoff)
ledger_result = assess_thread_state_ledger_comment(ledger)
reasons.extend(handoff_result.get("reasons") or [])
reasons.extend(ledger_result.get("reasons") or [])
reasons.extend(_blocked_without_gate_reasons(handoff, ledger))
if handoff_result.get("performed") or ledger_result.get("performed"):
performed = True
else:
performed = False
return _assessment(reasons, performed=performed)
def assess_two_comment_workflow(report_text: str) -> dict[str, Any]:
"""Validate combined final-report text containing both comment types."""
text = report_text or ""
if not (_has_tagged_handoff(text) or _has_tagged_ledger(text)):
if _has_legacy_handoff(text):
return _assessment([], performed=False)
return _assessment([], performed=False)
reasons: list[str] = []
if _has_tagged_handoff(text) and not _has_tagged_ledger(text):
reasons.append(
"tagged controller handoff present without THREAD STATE LEDGER"
)
handoff_section = _section_after_tag(text, _HANDOFF_TAG_RE) or text
ledger_section = _section_after_tag(text, _LEDGER_TAG_RE) or ""
reasons.extend(
(assess_controller_handoff_comment(handoff_section).get("reasons") or [])
)
if ledger_section:
reasons.extend(
(assess_thread_state_ledger_comment(ledger_section).get("reasons") or [])
)
reasons.extend(_blocked_without_gate_reasons(handoff_section, ledger_section))
reasons.extend(_mutation_contradiction_reasons(handoff_section))
reasons.extend(_local_server_separation_reasons(ledger_section))
return _assessment(reasons)
def findings_for_final_report(report_text: str) -> list[dict[str, str]]:
"""Return final_report_validator findings for the two-comment workflow."""
from final_report_validator import validator_finding
if not (_has_tagged_handoff(report_text) or _has_tagged_ledger(report_text)):
return []
result = assess_two_comment_workflow(report_text)
if not result.get("reasons"):
return []
findings: list[dict[str, str]] = []
for reason in result.get("reasons") or []:
severity = "block" if result.get("block") else "downgrade"
findings.append(
validator_finding(
"shared.two_comment_workflow",
severity,
"Thread State Ledger",
reason,
"add a [THREAD STATE LEDGER] after [CONTROLLER HANDOFF] "
"with precise server-side vs local state language",
)
)
return findings
+59 -9
View File
@@ -14,16 +14,23 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.gated_actions import attempt_action, load_action_registry, preview_action
from webui.gated_action_views import render_actions_page
from webui.audit_validator import audit_report, audit_to_dict
from webui.audit_views import render_audit_page
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
from webui.lease_views import render_leases_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict
from webui.queue_views import render_queue_page
from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
from webui.worktree_views import render_worktrees_page
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
from webui.runtime_views import render_runtime_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
def _stub_page(title: str, description: str) -> HTMLResponse:
@@ -135,18 +142,56 @@ async def api_runtime(_request: Request) -> JSONResponse:
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
async def audit(_request: Request) -> HTMLResponse:
return _stub_page(
"Audit",
"Report audit will accept pasted final reports and run validator previews.",
async def _parse_audit_form(request: Request) -> tuple[str, str | None]:
if request.method == "GET":
return "", None
content_type = (request.headers.get("content-type") or "").lower()
if "application/json" in content_type:
payload = await request.json()
if not isinstance(payload, dict):
return "", None
report_text = str(payload.get("report_text") or "")
task_kind = payload.get("task_kind")
return report_text, str(task_kind) if task_kind else None
form = await request.form()
report_text = str(form.get("report_text") or "")
task_kind = form.get("task_kind")
return report_text, str(task_kind) if task_kind else None
async def audit(request: Request) -> HTMLResponse:
report_text, task_kind = await _parse_audit_form(request)
result = None
if request.method == "POST" and report_text.strip():
result = audit_report(report_text, task_kind=task_kind)
return HTMLResponse(
render_audit_page(
report_text=report_text,
task_kind=task_kind,
result=result,
)
)
async def api_audit(request: Request) -> JSONResponse:
if request.method == "GET":
return JSONResponse({
"usage": "POST JSON with report_text and optional task_kind",
"task_kinds": sorted(FINAL_REPORT_TASK_KINDS),
})
report_text, task_kind = await _parse_audit_form(request)
if not report_text.strip():
return JSONResponse({"error": "report_text required"}, status_code=400)
return JSONResponse(audit_to_dict(audit_report(report_text, task_kind=task_kind)))
async def worktrees(_request: Request) -> HTMLResponse:
return _stub_page(
"Worktrees",
"Worktree hygiene will summarize branches/ session folders and cleanup risk.",
)
snapshot = load_hygiene_snapshot()
return HTMLResponse(render_worktrees_page(snapshot))
async def api_worktrees(_request: Request) -> JSONResponse:
return JSONResponse(worktree_snapshot_to_dict(load_hygiene_snapshot()))
async def leases(_request: Request) -> HTMLResponse:
@@ -194,6 +239,9 @@ async def api_action_attempt(request: Request) -> JSONResponse:
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
path = request.url.path
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
return JSONResponse({"error": "not_found"}, status_code=404)
if request.method not in _READ_ONLY_METHODS:
return JSONResponse(
{"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
@@ -219,8 +267,10 @@ def create_app() -> Starlette:
Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/api/runtime", api_runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/audit", audit, methods=["GET", "POST"]),
Route("/api/audit", api_audit, methods=["GET", "POST"]),
Route("/worktrees", worktrees, methods=["GET"]),
Route("/api/worktrees", api_worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]),
Route("/actions", actions, methods=["GET"]),
Route("/api/actions", api_actions, methods=["GET"]),
+176
View File
@@ -0,0 +1,176 @@
"""Final-report audit preview for the web UI (#431)."""
from __future__ import annotations
import re
from typing import Any
from final_report_validator import FINAL_REPORT_TASK_KINDS, assess_final_report_validator
_TASK_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("review_pr", re.compile(r"\b(?:review\s+pr|task\s*:\s*review|review-merge-pr)\b", re.I)),
(
"reconcile_already_landed",
re.compile(
r"\b(?:reconcile\s+already[- ]landed|already[- ]landed\s+pr|"
r"reconcile-landed-pr)\b",
re.I,
),
),
("work_issue", re.compile(r"\b(?:work[- ]issue|task\s*:\s*work|selected\s+issue\s*:)\b", re.I)),
("issue_filing", re.compile(r"\b(?:issue\s+filing|create\s+issue|task\s*:\s*file)\b", re.I)),
("issue_selection", re.compile(r"\b(?:issue\s+selection|select\s+next\s+issue)\b", re.I)),
("inventory", re.compile(r"\b(?:issue\s+inventory|queue\s+inventory)\b", re.I)),
)
_SUGGESTED_PROMPTS: dict[str, str] = {
"review_pr": (
"Review the next eligible open PR in this project. Merge it only if every "
"gate passes. Post a final report matching review-merge-pr schema."
),
"work_issue": (
"Find the next eligible issue in this project, work on it only if all gates "
"pass, and create a PR when complete."
),
"reconcile_already_landed": (
"Reconcile the oldest eligible already-landed open PR. Post read-only audit "
"first; authorize cleanup only when required."
),
"issue_filing": "File a new Gitea issue using the canonical issue-filing workflow.",
"issue_selection": "Build a complete open-issue inventory and select the next eligible issue.",
"inventory": "Produce a proof-backed inventory of open PRs or issues without mutating state.",
}
def infer_task_kind(report_text: str, explicit: str | None = None) -> str:
"""Infer workflow task kind from explicit selection or report content."""
if explicit:
normalized = explicit.strip().lower().replace(" ", "_")
if normalized in FINAL_REPORT_TASK_KINDS:
return normalized
text = report_text or ""
for kind, pattern in _TASK_KIND_PATTERNS:
if pattern.search(text):
return kind
if re.search(r"\brole\s*:\s*reviewer\b", text, re.I):
return "review_pr"
if re.search(r"\brole\s*:\s*author\b", text, re.I):
return "work_issue"
return "review_pr"
def _merge_findings(*result_sets: dict[str, Any]) -> list[dict[str, str]]:
seen: set[tuple[str, str, str]] = set()
merged: list[dict[str, str]] = []
for result in result_sets:
for finding in result.get("findings") or []:
key = (
str(finding.get("rule_id", "")),
str(finding.get("field", "")),
str(finding.get("reason", "")),
)
if key in seen:
continue
seen.add(key)
merged.append(finding)
return merged
def _aggregate_result(task_kind: str, findings: list[dict[str, str]], base: dict[str, Any]) -> dict[str, Any]:
blocked = any(f.get("severity") == "block" for f in findings)
downgraded = any(f.get("severity") == "downgrade" for f in findings)
warning = any(f.get("severity") == "warning" for f in findings)
if blocked:
grade = "blocked"
elif downgraded:
grade = "downgraded"
elif warning:
grade = "warning"
else:
grade = base.get("grade") or "A"
safe_next_action = base.get("safe_next_action") or "proceed"
for finding in findings:
if finding.get("severity") in {"block", "downgrade"}:
safe_next_action = finding.get("safe_next_action") or safe_next_action
break
return {
"task_kind": task_kind,
"inferred_task_kind": task_kind,
"grade": grade,
"blocked": blocked or bool(base.get("blocked")),
"downgraded": downgraded or bool(base.get("downgraded")),
"findings": findings,
"reasons": [f"{f.get('rule_id')}: {f.get('reason')}" for f in findings],
"safe_next_action": safe_next_action,
"complete": grade == "A" and not findings,
"suggested_next_prompt": _SUGGESTED_PROMPTS.get(task_kind, ""),
"suggested_issue_comment": _build_issue_comment(findings, safe_next_action),
}
def _build_issue_comment(findings: list[dict[str, str]], safe_next_action: str) -> str:
if not findings:
return ""
lines = [
"## Report audit preview (local validator)",
"",
"Automated findings from pasted final report:",
"",
]
for finding in findings[:12]:
severity = finding.get("severity", "info")
rule_id = finding.get("rule_id", "unknown")
reason = finding.get("reason", "")
lines.append(f"- **{severity}** `{rule_id}` — {reason}")
if len(findings) > 12:
lines.append(f"- …and {len(findings) - 12} more finding(s)")
lines.extend(["", f"Safe next action: {safe_next_action}", ""])
return "\n".join(lines)
def audit_report(report_text: str, *, task_kind: str | None = None) -> dict[str, Any]:
"""Run composable final-report validation on pasted text."""
text = (report_text or "").strip()
if not text:
return {
"task_kind": task_kind or "review_pr",
"inferred_task_kind": task_kind or "review_pr",
"grade": "blocked",
"blocked": True,
"downgraded": False,
"findings": [],
"reasons": ["empty report text"],
"safe_next_action": "paste a non-empty final report",
"complete": False,
"suggested_next_prompt": "",
"suggested_issue_comment": "",
}
resolved_kind = infer_task_kind(text, task_kind)
base = assess_final_report_validator(text, resolved_kind)
findings = list(base.get("findings") or [])
if resolved_kind == "review_pr":
from review_final_report_schema import assess_review_final_report_schema
schema = assess_review_final_report_schema(text)
findings = _merge_findings(base, schema)
return _aggregate_result(resolved_kind, findings, base)
def audit_to_dict(result: dict[str, Any]) -> dict[str, Any]:
"""JSON-safe export for /api/audit."""
return {
"task_kind": result.get("task_kind"),
"inferred_task_kind": result.get("inferred_task_kind"),
"grade": result.get("grade"),
"blocked": result.get("blocked"),
"downgraded": result.get("downgraded"),
"complete": result.get("complete"),
"safe_next_action": result.get("safe_next_action"),
"findings": result.get("findings") or [],
"reasons": result.get("reasons") or [],
"suggested_next_prompt": result.get("suggested_next_prompt") or "",
"suggested_issue_comment": result.get("suggested_issue_comment") or "",
}
+139
View File
@@ -0,0 +1,139 @@
"""HTML views for final-report audit paste (#431)."""
from __future__ import annotations
import html
from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.audit_validator import audit_report
from webui.layout import render_page
_TASK_KIND_OPTIONS = sorted(FINAL_REPORT_TASK_KINDS)
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _render_findings(result: dict) -> str:
findings = result.get("findings") or []
if not findings:
return '<p class="muted">No validator findings — report structure looks complete for the selected task kind.</p>'
rows = []
for finding in findings:
rows.append(
"<tr>"
f"<td><code>{_escape(str(finding.get('rule_id', '')))}</code></td>"
f"<td>{_escape(str(finding.get('severity', '')))}</td>"
f"<td>{_escape(str(finding.get('field', '')))}</td>"
f"<td>{_escape(str(finding.get('reason', '')))}</td>"
f"<td>{_escape(str(finding.get('safe_next_action', '')))}</td>"
"</tr>"
)
return (
'<table class="detail audit-findings">'
"<thead><tr><th>Rule</th><th>Severity</th><th>Field</th>"
"<th>Reason</th><th>Safe next action</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _render_task_kind_select(selected: str | None) -> str:
options = ['<option value="">Auto-detect from report</option>']
for kind in _TASK_KIND_OPTIONS:
sel = ' selected' if selected == kind else ""
options.append(f'<option value="{_escape(kind)}"{sel}>{_escape(kind)}</option>')
return f'<select id="task_kind" name="task_kind">{"".join(options)}</select>'
def render_audit_page(
*,
report_text: str = "",
task_kind: str | None = None,
result: dict | None = None,
) -> str:
result_html = ""
if result is not None:
grade = _escape(str(result.get("grade", "")))
inferred = _escape(str(result.get("inferred_task_kind", "")))
safe_next = _escape(str(result.get("safe_next_action", "")))
suggested_prompt = result.get("suggested_next_prompt") or ""
suggested_comment = result.get("suggested_issue_comment") or ""
result_html = (
'<section class="audit-result">'
f"<h3>Validator preview</h3>"
f'<p class="meta">Task kind: <code>{inferred}</code> · '
f'Grade: <strong>{grade}</strong> · '
f"Blocked: <code>{result.get('blocked')}</code> · "
f"Downgraded: <code>{result.get('downgraded')}</code></p>"
f"<p><strong>Safe next action:</strong> {safe_next}</p>"
f"{_render_findings(result)}"
)
if suggested_prompt:
result_html += (
"<h4>Suggested next prompt</h4>"
f'<pre class="prompt-text">{_escape(suggested_prompt)}</pre>'
)
if suggested_comment:
result_html += (
"<h4>Suggested issue/PR comment</h4>"
f'<pre class="prompt-text">{_escape(suggested_comment)}</pre>'
)
result_html += "</section>"
body = (
"<h2>Report audit</h2>"
"<p>Paste an LLM final report for local validator preview. "
"Uses <code>final_report_validator</code> and review schema checks — "
"does not post to Gitea or store reports server-side.</p>"
'<form method="post" action="/audit" class="audit-form">'
"<label for=\"task_kind\">Task kind</label>"
f"{_render_task_kind_select(task_kind)}"
'<label for="report_text">Final report</label>'
f'<textarea id="report_text" name="report_text" rows="18" '
f'placeholder="Paste final report markdown here…">{_escape(report_text)}</textarea>'
'<button type="submit" class="copy-btn">Run validator preview</button>'
"</form>"
f"{result_html}"
"<p><a href=\"/api/audit\">JSON API</a> (POST <code>report_text</code>, "
"optional <code>task_kind</code>)</p>"
)
return render_page(title="Audit", body_html=body, extra_head=AUDIT_PAGE_STYLES)
AUDIT_PAGE_STYLES = """
<style>
.audit-form label {
display: block;
margin: 0.75rem 0 0.35rem;
color: var(--muted);
font-size: 0.9rem;
}
.audit-form select,
.audit-form textarea {
width: 100%;
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 0.65rem;
}
.audit-form textarea {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.45;
resize: vertical;
}
.audit-result {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
table.audit-findings td:nth-child(4),
table.audit-findings td:nth-child(5) {
font-size: 0.85rem;
color: var(--muted);
}
</style>
"""
+307
View File
@@ -0,0 +1,307 @@
"""Local branches/ worktree hygiene scan for the web UI (#432)."""
from __future__ import annotations
import os
import re
import subprocess
from dataclasses import dataclass
from typing import Any, Callable
from issue_lock_provenance import ISSUE_LOCK_FILE
from merged_cleanup_reconcile import (
branch_worktree_folder,
read_issue_lock,
read_local_worktree_state,
)
_REVIEW_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
re.IGNORECASE,
)
CLASSIFICATIONS = frozenset({
"active-pr",
"active-issue",
"dirty",
"stale-clean",
"detached-review",
"unsafe-unknown",
"orphan",
})
CLEANUP_PROMPT = (
"Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. "
"Confirm the merge on remote master before any deletion; never "
"force-remove a dirty worktree. Full steps: "
"skills/llm-project-workflow/templates/worktree-cleanup.md"
)
@dataclass(frozen=True)
class WorktreeEntry:
rel_path: str
folder_name: str
classification: str
branch: str | None
head_sha: str | None
dirty_tracked: int
dirty_untracked: bool
detached: bool
registered_worktree: bool
notes: str
@dataclass(frozen=True)
class HygieneSnapshot:
repo_root: str
entries: tuple[WorktreeEntry, ...]
anomalies: tuple[str, ...]
cleanup_prompt: str
scan_error: str | None = None
def _repo_root() -> str:
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
if override:
return os.path.realpath(override)
return os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
def _relative_branches_path(project_root: str, path: str) -> str:
root = os.path.normpath(project_root)
normalized = os.path.normpath(path)
if normalized.startswith(root + os.sep):
return normalized[len(root) + 1 :].replace("\\", "/")
return normalized.replace("\\", "/")
def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
current: dict[str, Any] = {}
for raw in (porcelain or "").splitlines():
line = raw.strip()
if not line:
if current:
entries.append(current)
current = {}
continue
if line.startswith("worktree "):
if current:
entries.append(current)
current = {"path": line.split(" ", 1)[1].strip()}
elif line.startswith("HEAD "):
current["head_sha"] = line.split(" ", 1)[1].strip()
elif line.startswith("branch "):
current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/")
elif line == "detached":
current["detached"] = True
if current:
entries.append(current)
return entries
def list_branches_directories(project_root: str) -> list[str]:
branches_root = os.path.join(project_root, "branches")
if not os.path.isdir(branches_root):
return []
names: list[str] = []
for entry in sorted(os.listdir(branches_root)):
if entry.startswith("."):
continue
full = os.path.join(branches_root, entry)
if os.path.isdir(full):
names.append(f"branches/{entry}")
return names
def _untracked_dirty(porcelain: str) -> bool:
return any(line.startswith("??") for line in (porcelain or "").splitlines())
def classify_entry(
*,
rel_path: str,
worktree_record: dict[str, Any] | None,
state: dict[str, Any],
open_pr_branches: set[str],
active_lock_branch: str | None,
) -> tuple[str, str]:
record = worktree_record or {}
branch_name = (record.get("branch") or state.get("current_branch") or "").strip()
folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path
if branch_name in open_pr_branches:
return "active-pr", "Head branch matches an open PR"
if active_lock_branch and branch_name == active_lock_branch:
return "active-issue", "Matches active issue lock branch"
dirty_tracked = len(state.get("dirty_files") or [])
dirty_untracked = bool(state.get("dirty_untracked"))
if dirty_tracked or dirty_untracked:
return "dirty", "Local tracked or untracked changes present"
if not record and not state.get("exists"):
return "orphan", "Directory missing on disk"
if not record:
return "orphan", "Directory exists but is not a registered git worktree"
if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
return "detached-review", "Detached HEAD in reviewer/simulation worktree"
if state.get("exists") and state.get("clean"):
return "stale-clean", "Registered worktree with clean working tree"
return "unsafe-unknown", "Could not classify safely — inspect manually before cleanup"
def _missing_preserved_anomalies(
project_root: str,
lock: dict[str, Any] | None,
entries_by_path: dict[str, WorktreeEntry],
) -> tuple[str, ...]:
anomalies: list[str] = []
if not lock:
return tuple(anomalies)
branch = (lock.get("branch_name") or "").strip()
issue_number = lock.get("issue_number")
worktree_path = (lock.get("worktree_path") or "").strip()
expected_rel = _relative_branches_path(project_root, worktree_path) if worktree_path else ""
if worktree_path and not os.path.isdir(worktree_path):
anomalies.append(
f"Missing preserved worktree for issue #{issue_number}: "
f"lock path {worktree_path!r} not found on disk (#404)"
)
elif expected_rel.startswith("branches/") and expected_rel not in entries_by_path:
anomalies.append(
f"Missing preserved worktree folder {expected_rel} for locked branch {branch!r}"
)
elif branch:
folder = f"branches/{branch_worktree_folder(branch)}"
entry = entries_by_path.get(folder)
if entry and entry.classification in {"orphan", "unsafe-unknown"}:
anomalies.append(
f"Locked branch {branch!r} maps to {folder} but classification is "
f"{entry.classification}"
)
return tuple(anomalies)
def load_hygiene_snapshot(
*,
project_root: str | None = None,
worktree_porcelain: str | None = None,
open_pr_branches: set[str] | None = None,
issue_lock_path: str | None = None,
run_git: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None,
) -> HygieneSnapshot:
root = project_root or _repo_root()
lock_path = issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE
lock = read_issue_lock(lock_path)
active_lock_branch = (lock.get("branch_name") or "").strip() if lock else None
git_run = run_git or (
lambda args: subprocess.run(args, capture_output=True, text=True, check=False)
)
scan_error: str | None = None
porcelain = worktree_porcelain
if porcelain is None:
result = git_run(["git", "-C", root, "worktree", "list", "--porcelain"])
if result.returncode != 0:
scan_error = (result.stderr or result.stdout or "git worktree list failed").strip()
porcelain = ""
else:
porcelain = result.stdout or ""
worktrees = parse_worktree_list_porcelain(porcelain)
worktree_by_rel: dict[str, dict[str, Any]] = {}
for wt in worktrees:
rel = _relative_branches_path(root, wt.get("path") or "")
if rel.startswith("branches/"):
worktree_by_rel[rel] = wt
open_heads = set(open_pr_branches or ())
if not open_heads:
try:
from webui.queue_loader import load_queue_snapshot
snapshot = load_queue_snapshot()
open_heads = {
item.extra.get("branch", "")
for item in snapshot.prs
if item.extra.get("branch")
}
except Exception:
open_heads = set()
entries: list[WorktreeEntry] = []
for rel_path in list_branches_directories(root):
abs_path = os.path.join(root, rel_path)
record = worktree_by_rel.get(rel_path)
state = read_local_worktree_state(abs_path)
if state.get("exists"):
status = git_run(["git", "-C", abs_path, "status", "--porcelain"])
state = {
**state,
"dirty_untracked": _untracked_dirty(status.stdout or ""),
}
classification, note = classify_entry(
rel_path=rel_path,
worktree_record=record,
state=state,
open_pr_branches=open_heads,
active_lock_branch=active_lock_branch,
)
entries.append(
WorktreeEntry(
rel_path=rel_path,
folder_name=rel_path.split("/", 1)[-1],
classification=classification,
branch=(record or {}).get("branch") or state.get("current_branch"),
head_sha=(record or {}).get("head_sha") or state.get("head_sha"),
dirty_tracked=len(state.get("dirty_files") or []),
dirty_untracked=bool(state.get("dirty_untracked")),
detached=bool((record or {}).get("detached")),
registered_worktree=record is not None,
notes=note,
)
)
entries_by_path = {entry.rel_path: entry for entry in entries}
anomalies = _missing_preserved_anomalies(root, lock, entries_by_path)
return HygieneSnapshot(
repo_root=root,
entries=tuple(entries),
anomalies=anomalies,
cleanup_prompt=CLEANUP_PROMPT,
scan_error=scan_error,
)
def snapshot_to_dict(snapshot: HygieneSnapshot) -> dict[str, Any]:
return {
"repo_root": snapshot.repo_root,
"count": len(snapshot.entries),
"cleanup_prompt": snapshot.cleanup_prompt,
"anomalies": list(snapshot.anomalies),
"scan_error": snapshot.scan_error,
"entries": [
{
"rel_path": entry.rel_path,
"folder_name": entry.folder_name,
"classification": entry.classification,
"branch": entry.branch,
"head_sha": entry.head_sha,
"dirty_tracked": entry.dirty_tracked,
"dirty_untracked": entry.dirty_untracked,
"detached": entry.detached,
"registered_worktree": entry.registered_worktree,
"notes": entry.notes,
}
for entry in snapshot.entries
],
}
+130
View File
@@ -0,0 +1,130 @@
"""HTML views for branches/ worktree hygiene dashboard (#432)."""
from __future__ import annotations
import html
from webui.layout import render_page
from webui.worktree_scanner import HygieneSnapshot, WorktreeEntry
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _badge(classification: str) -> str:
return (
f'<span class="badge badge-{_escape(classification)}">'
f"{_escape(classification)}</span>"
)
def _entry_row(entry: WorktreeEntry) -> str:
branch = entry.branch or ""
head = entry.head_sha[:12] if entry.head_sha else ""
dirty = (
f"{entry.dirty_tracked} tracked"
+ (" + untracked" if entry.dirty_untracked else "")
if entry.dirty_tracked or entry.dirty_untracked
else "clean"
)
return (
"<tr>"
f"<td><code>{_escape(entry.rel_path)}</code></td>"
f"<td>{_badge(entry.classification)}</td>"
f"<td><code>{_escape(branch)}</code></td>"
f"<td><code>{_escape(head)}</code></td>"
f"<td>{_escape(dirty)}</td>"
f"<td>{'yes' if entry.detached else 'no'}</td>"
f"<td class='muted'>{_escape(entry.notes)}</td>"
"</tr>"
)
WORKTREE_PAGE_SCRIPT = """
<script>
document.querySelectorAll('.copy-cleanup-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const node = document.getElementById('cleanup-prompt');
if (!node) return;
try {
await navigator.clipboard.writeText(node.textContent || '');
const prior = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prior; }, 1200);
} catch (_err) {
btn.textContent = 'Copy failed';
}
});
});
</script>
"""
WORKTREE_PAGE_STYLES = """
<style>
table.worktrees td:nth-child(7) { font-size: 0.85rem; }
.anomaly {
margin: 1rem 0;
padding: 0.75rem 0.9rem;
border: 1px solid #7a3b3b;
border-radius: 8px;
background: rgba(122, 59, 59, 0.15);
color: #f0c4c4;
}
.badge-active-pr { color: #9ec8f0; border-color: #3d5f7a; }
.badge-active-issue { color: #8fd19e; border-color: #3d6b4a; }
.badge-dirty { color: #e0c27a; border-color: #6b5730; }
.badge-stale-clean { color: #c9b8e8; border-color: #5a4a78; }
.badge-detached-review { color: #9ec8f0; border-color: #3d5f7a; }
.badge-unsafe-unknown { color: #f0a8a8; border-color: #7a3b3b; }
.badge-orphan { color: #f0a8a8; border-color: #7a3b3b; }
</style>
"""
def render_worktrees_page(snapshot: HygieneSnapshot) -> str:
error_block = ""
if snapshot.scan_error:
error_block = (
'<div class="stub"><p><strong>Partial scan:</strong> '
f"{_escape(snapshot.scan_error)}</p></div>"
)
anomaly_block = ""
if snapshot.anomalies:
items = "".join(f"<li>{_escape(text)}</li>" for text in snapshot.anomalies)
anomaly_block = (
'<section class="anomaly"><h3>Missing preserved worktree anomalies</h3>'
f"<ul>{items}</ul></section>"
)
if snapshot.entries:
rows = "".join(_entry_row(entry) for entry in snapshot.entries)
table = (
"<table class='registry worktrees'>"
"<thead><tr>"
"<th>Path</th><th>Class</th><th>Branch</th><th>HEAD</th>"
"<th>Dirty</th><th>Detached</th><th>Notes</th>"
"</tr></thead>"
f"<tbody>{rows}</tbody></table>"
)
else:
table = "<p class='muted'>No directories under <code>branches/</code>.</p>"
body = (
"<h2>Worktree hygiene</h2>"
"<p>Read-only scan of local <code>branches/</code> worktrees. "
"No deletion actions — copy the canonical cleanup prompt when merge is confirmed.</p>"
f"{error_block}"
f"{anomaly_block}"
f"<p class='meta'>Repo root: <code>{_escape(snapshot.repo_root)}</code> · "
f"entries: {len(snapshot.entries)}</p>"
f"{table}"
"<h3>Canonical cleanup prompt</h3>"
f'<pre class="prompt-text" id="cleanup-prompt">{_escape(snapshot.cleanup_prompt)}</pre>'
'<button type="button" class="copy-btn copy-cleanup-btn">Copy cleanup prompt</button>'
"<p><a href=\"/api/worktrees\">JSON API</a></p>"
f"{WORKTREE_PAGE_STYLES}"
f"{WORKTREE_PAGE_SCRIPT}"
)
return render_page(title="Worktrees", body_html=body)
+160
View File
@@ -0,0 +1,160 @@
"""Canonical workflow skill naming and preflight for multi-runtime agents (#551).
Controller prompts and Claude historically require ``gitea-workflow``. The
portable in-repo skill is ``skills/llm-project-workflow``. Codex must resolve
the same canonical names so sessions do not proceed without the workflow wall.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
# Canonical names that all runtimes (Claude, Codex, Gemini, MCP inventory)
# must treat as the same workflow router skill.
CANONICAL_WORKFLOW_SKILL_NAMES = (
"gitea-workflow",
"llm-project-workflow",
"git-pr-workflows",
)
# Primary checked-in skill path (portable source of truth).
REPO_SKILL_REL_PATH = "skills/llm-project-workflow/SKILL.md"
# In-repo alias directory so scanners that look for skills/gitea-workflow work.
REPO_ALIAS_SKILL_REL_PATH = "skills/gitea-workflow/SKILL.md"
CODEX_SKILLS_ENV = "CODEX_HOME"
DEFAULT_CODEX_SKILLS = os.path.expanduser("~/.codex/skills")
def default_codex_skills_dir() -> str:
home = (os.environ.get(CODEX_SKILLS_ENV) or "").strip()
if home:
return os.path.join(os.path.expanduser(home), "skills")
return DEFAULT_CODEX_SKILLS
def resolve_repo_skill_path(project_root: str) -> Path:
return Path(project_root) / REPO_SKILL_REL_PATH
def resolve_repo_alias_skill_path(project_root: str) -> Path:
return Path(project_root) / REPO_ALIAS_SKILL_REL_PATH
def normalize_skill_name(name: str | None) -> str:
return (name or "").strip().lower().replace("_", "-")
def is_canonical_workflow_skill_name(name: str | None) -> bool:
return normalize_skill_name(name) in CANONICAL_WORKFLOW_SKILL_NAMES
def assess_workflow_skill_presence(
project_root: str,
*,
codex_skills_dir: str | None = None,
) -> dict[str, Any]:
"""Return presence proof for the canonical workflow skill.
Fail-closed when the in-repo skill file is missing. Codex mount is advisory
in the report (operators install via script) but ``codex_skill_mounted`` is
explicit so preflight can BLOCK when required.
"""
root = (project_root or "").strip()
reasons: list[str] = []
repo_path = resolve_repo_skill_path(root) if root else None
alias_path = resolve_repo_alias_skill_path(root) if root else None
repo_present = bool(repo_path and repo_path.is_file())
alias_present = bool(alias_path and alias_path.is_file())
if not root:
reasons.append("project_root not provided (fail closed)")
elif not repo_present:
reasons.append(
f"canonical workflow skill missing at {REPO_SKILL_REL_PATH} "
"(fail closed — do not mutate without workflow wall)"
)
codex_dir = Path(codex_skills_dir or default_codex_skills_dir())
codex_mounts: dict[str, bool] = {}
for name in CANONICAL_WORKFLOW_SKILL_NAMES:
skill_md = codex_dir / name / "SKILL.md"
codex_mounts[name] = skill_md.is_file()
codex_any = any(codex_mounts.values())
blocked = bool(reasons)
return {
"canonical_names": list(CANONICAL_WORKFLOW_SKILL_NAMES),
"primary_name": "gitea-workflow",
"portable_name": "llm-project-workflow",
"repo_skill_rel_path": REPO_SKILL_REL_PATH,
"repo_skill_present": repo_present,
"repo_alias_rel_path": REPO_ALIAS_SKILL_REL_PATH,
"repo_alias_present": alias_present,
"codex_skills_dir": str(codex_dir),
"codex_skill_mounts": codex_mounts,
"codex_skill_mounted": codex_any,
"workflow_skill_ready": repo_present,
"blocked": blocked,
"block_reason": reasons[0] if reasons else None,
"reasons": reasons,
"safe_next_action": (
"Install or repair skills/llm-project-workflow/SKILL.md in the "
"repo; run scripts/install-codex-workflow-skill.sh for Codex; "
"call mcp_list_project_skills and load gitea-workflow / "
"llm-project-workflow before any git/Gitea mutation."
if blocked or not codex_any
else "Load gitea-workflow or llm-project-workflow, then proceed."
),
}
def workflow_skill_registry_entries() -> dict[str, dict[str, Any]]:
"""Shared skill metadata for mcp_list_project_skills / guides."""
shared_steps = [
"Call mcp_list_project_skills and confirm gitea-workflow (or "
"llm-project-workflow) is listed.",
"Call mcp_check_workflow_skill_preflight and stop if blocked.",
"Load skills/llm-project-workflow/SKILL.md (or the gitea-workflow "
"alias) and route to the matching workflow file for the task mode.",
"Do not perform git or Gitea mutations until the workflow is loaded.",
"Codex: ensure ~/.codex/skills/gitea-workflow -> repo skill via "
"scripts/install-codex-workflow-skill.sh.",
]
notes = (
f"Canonical names: {', '.join(CANONICAL_WORKFLOW_SKILL_NAMES)}. "
f"Portable source: {REPO_SKILL_REL_PATH}. "
"Controller prompts may say gitea-workflow; Codex and MCP inventory "
"must resolve the same skill."
)
base = {
"description": (
"Canonical Gitea/LLM project workflow router: pick task mode, "
"load the matching workflow, enforce isolation, emit the final "
"report schema."
),
"when_to_use": (
"At the start of every author, review, merge, reconcile, or "
"issue-filing session — before any mutation."
),
"required_operations": ["gitea.read"],
"status": "available",
"notes": notes,
"steps": shared_steps,
"repo_path": REPO_SKILL_REL_PATH,
"canonical": True,
}
return {
"gitea-workflow": dict(base),
"llm-project-workflow": dict(base),
"git-pr-workflows": {
**dict(base),
"description": (
"Legacy alias for the canonical Gitea/LLM project workflow "
"router (same as gitea-workflow / llm-project-workflow)."
),
"notes": notes + " Prefer gitea-workflow in new prompts.",
},
}