Merge master into issue-552-blocked-diagnose to resolve conflicts
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+88
-24
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -636,19 +641,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.`
|
||||
|
||||
@@ -831,6 +841,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,
|
||||
@@ -863,6 +894,55 @@ touched release state names the exact tag/commit and why. Design debates
|
||||
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
||||
on the issue, create no branches/PRs, and end the comment with this handoff.
|
||||
|
||||
## Canonical comment validation (#496)
|
||||
|
||||
Workflow-changing issue/PR/review comments must carry durable next-action
|
||||
state. Casual discussion is still allowed.
|
||||
|
||||
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
|
||||
**before** posting through:
|
||||
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
|
||||
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
|
||||
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
|
||||
|
||||
Detection examples:
|
||||
|
||||
- **Allowed:** `Thanks, I will check this.`
|
||||
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
|
||||
|
||||
When validation fails, the tool returns `canonical_comment_validation` with
|
||||
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
|
||||
`suggested_template`. **No Gitea API call is made.**
|
||||
|
||||
Minimum workflow comment fields:
|
||||
|
||||
```text
|
||||
STATE:
|
||||
WHO_IS_NEXT:
|
||||
NEXT_ACTION:
|
||||
NEXT_PROMPT:
|
||||
WHY:
|
||||
```
|
||||
|
||||
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
|
||||
`reconciler`, `user`.
|
||||
|
||||
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
|
||||
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
|
||||
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
|
||||
|
||||
Special states:
|
||||
|
||||
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
|
||||
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
|
||||
- `STATE: ready-to-merge` — requires approval proof and head SHA in
|
||||
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
|
||||
|
||||
Final reports must not claim a comment was posted when
|
||||
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
Before any mutating action the workflow verifies identity, active profile,
|
||||
@@ -974,6 +1054,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.
|
||||
|
||||
@@ -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
|
||||
+12
-2
@@ -43,7 +43,8 @@ Optional environment variables:
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | Stub — MCP runtime health (#430) |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
| `/api/runtime` | JSON runtime health export |
|
||||
| `/audit` | Stub — report audit paste (#431) |
|
||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
@@ -91,8 +92,17 @@ in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||
and duplicate local branches per issue. Links to collision-history backend
|
||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||
|
||||
## Runtime health (#430)
|
||||
|
||||
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||
project: active profile and role kind, authenticated identity (when credentials
|
||||
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
```
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Callable
|
||||
|
||||
import issue_acceptance_gate
|
||||
import issue_lock_provenance
|
||||
import reviewer_handoff_consistency
|
||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
@@ -195,6 +196,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 +399,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 +653,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 +1187,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 +1372,22 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]:
|
||||
from canonical_state_comments import validate_final_report_state_update
|
||||
|
||||
result = validate_final_report_state_update(report_text)
|
||||
if not result.get("applicable") or result.get("valid"):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"shared.canonical_state_update",
|
||||
"block",
|
||||
"Canonical state update",
|
||||
reason,
|
||||
"include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT",
|
||||
)
|
||||
for reason in (result.get("reasons") or ["invalid canonical state update"])
|
||||
]
|
||||
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||
|
||||
@@ -1325,17 +1436,23 @@ _SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
_rule_shared_author_reviewer_same_run,
|
||||
_rule_shared_canonical_state_update,
|
||||
)
|
||||
|
||||
_SHARED_CLEANUP_PROOF_RULES = (
|
||||
_rule_shared_mcp_native_cleanup_proof,
|
||||
)
|
||||
|
||||
_SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1354,6 +1471,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,
|
||||
@@ -1366,6 +1484,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1383,6 +1502,7 @@ _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_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
@@ -1390,9 +1510,11 @@ _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_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 +1522,14 @@ _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_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_state_handoff_next_action,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
@@ -1413,6 +1537,7 @@ _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_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
}
|
||||
|
||||
+342
-18
@@ -818,6 +818,7 @@ import root_checkout_guard # noqa: E402
|
||||
import remote_repo_guard # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import merger_lease_adoption # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
@@ -827,6 +828,7 @@ import reconciliation_workflow # noqa: E402
|
||||
import audit_reconciliation_mode # noqa: E402
|
||||
import review_merge_state_machine # noqa: E402
|
||||
import pr_work_lease # noqa: E402
|
||||
import conflict_fix_classification # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
|
||||
@@ -836,6 +838,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 +1178,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non
|
||||
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||
return sorted(list(issues))
|
||||
|
||||
def _repo_label_id_map(base: str, auth: str) -> dict[str, int]:
|
||||
labels = api_get_all(f"{base}/labels", auth) or []
|
||||
return {
|
||||
str(lb["name"]): int(lb["id"])
|
||||
for lb in labels
|
||||
if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None
|
||||
}
|
||||
|
||||
def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]:
|
||||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||||
return issue_workflow_labels.label_names(issue)
|
||||
|
||||
def _put_issue_label_names(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
names: list[str],
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> list[dict]:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
missing = [name for name in names if name not in by_name]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"The following labels do not exist on the repository: {missing}. "
|
||||
"Create the canonical workflow labels first."
|
||||
)
|
||||
ids = [by_name[name] for name in names]
|
||||
return api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{issue_number}/labels",
|
||||
auth,
|
||||
{"labels": ids},
|
||||
)
|
||||
|
||||
def _transition_issue_status(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
label_ids_by_name: dict[str, int] | None = None,
|
||||
) -> dict:
|
||||
by_name = label_ids_by_name or _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
_put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=issue_number,
|
||||
names=updated,
|
||||
label_ids_by_name=by_name,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
}
|
||||
|
||||
def _prepare_issue_status_transition(
|
||||
*,
|
||||
base: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
status: str,
|
||||
) -> dict:
|
||||
by_name = _repo_label_id_map(base, auth)
|
||||
target_status = issue_workflow_labels.canonical_status_label(status)
|
||||
if target_status not in by_name:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"reasons": [
|
||||
f"required workflow status label '{target_status}' does not exist"
|
||||
],
|
||||
}
|
||||
current = _issue_label_names(base, auth, issue_number)
|
||||
updated = issue_workflow_labels.transition_status_labels(current, target_status)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"issue_number": issue_number,
|
||||
"target_status": target_status,
|
||||
"labels": updated,
|
||||
"label_ids_by_name": by_name,
|
||||
}
|
||||
|
||||
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||
if not issue_numbers:
|
||||
return {}
|
||||
@@ -1507,6 +1612,11 @@ def gitea_create_issue(
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
discussion: bool = False,
|
||||
require_workflow_labels: bool = False,
|
||||
allow_duplicate_override: bool = False,
|
||||
split_from_issue: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
@@ -1520,6 +1630,12 @@ def gitea_create_issue(
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
labels: Optional labels to apply by name after creating the issue.
|
||||
issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'.
|
||||
initial_status: Optional initial workflow status, e.g. 'ready'.
|
||||
discussion: If True, require/apply type:discussion.
|
||||
require_workflow_labels: If True, fail closed unless labels include one
|
||||
type:* and one status:* label.
|
||||
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||
split_from_issue: Existing duplicate issue number when overriding.
|
||||
worktree_path: Optional path to verify branches-only guard.
|
||||
@@ -1551,6 +1667,40 @@ def gitea_create_issue(
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||||
base = repo_api_url(h, o, r)
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=issue_type,
|
||||
initial_status=initial_status,
|
||||
extra_labels=labels,
|
||||
discussion=discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=discussion,
|
||||
require_type=True,
|
||||
require_status=True,
|
||||
)
|
||||
if require_workflow_labels and not label_assessment["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": label_assessment["errors"],
|
||||
}
|
||||
label_ids_by_name: dict[str, int] | None = None
|
||||
if requested_labels:
|
||||
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||
missing = [name for name in requested_labels if name not in label_ids_by_name]
|
||||
if missing:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"workflow_label_validation": label_assessment,
|
||||
"reasons": [
|
||||
f"requested labels do not exist on the repository: {missing}"
|
||||
],
|
||||
}
|
||||
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||
closed_issues = api_get_all(
|
||||
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
||||
@@ -1585,7 +1735,29 @@ def gitea_create_issue(
|
||||
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
||||
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
||||
request_metadata={"title": title}, mutation_task="create_issue")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"workflow_label_validation": label_assessment,
|
||||
}
|
||||
if requested_labels:
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=data["number"],
|
||||
request_metadata={"labels": requested_labels, "source": "create_issue"},
|
||||
):
|
||||
applied = _put_issue_label_names(
|
||||
base=base,
|
||||
auth=auth,
|
||||
issue_number=data["number"],
|
||||
names=requested_labels,
|
||||
label_ids_by_name=label_ids_by_name,
|
||||
)
|
||||
result["labels"] = issue_workflow_labels.label_names(applied)
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
|
||||
@@ -1994,7 +2166,23 @@ def gitea_create_pr(
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||||
repo_base = repo_api_url(h, o, r)
|
||||
pr_open_transition = _prepare_issue_status_transition(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
status="status:pr-open",
|
||||
)
|
||||
if not pr_open_transition.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"number": None,
|
||||
"issue_number": locked_issue,
|
||||
"issue_status_transition": pr_open_transition,
|
||||
"reasons": pr_open_transition.get("reasons", []),
|
||||
}
|
||||
url = f"{repo_base}/pulls"
|
||||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||
meta = {"title": title, "head": head, "base": base}
|
||||
try:
|
||||
@@ -2009,7 +2197,37 @@ def gitea_create_pr(
|
||||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||||
target_branch=head, request_metadata=meta,
|
||||
mutation_task="create_pr")
|
||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||||
with _audited(
|
||||
"set_issue_labels",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=int(locked_issue),
|
||||
request_metadata={
|
||||
"source": "create_pr",
|
||||
"status": "status:pr-open",
|
||||
"pr_number": data["number"],
|
||||
},
|
||||
):
|
||||
_put_issue_label_names(
|
||||
base=repo_base,
|
||||
auth=auth,
|
||||
issue_number=int(locked_issue),
|
||||
names=pr_open_transition["labels"],
|
||||
label_ids_by_name=pr_open_transition["label_ids_by_name"],
|
||||
)
|
||||
result = {
|
||||
"number": data["number"],
|
||||
"issue_status_transition": {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"issue_number": int(locked_issue),
|
||||
"target_status": "status:pr-open",
|
||||
"labels": pr_open_transition["labels"],
|
||||
},
|
||||
}
|
||||
return _with_optional_url(result, data.get("html_url"))
|
||||
|
||||
|
||||
def _format_list_pr_entry(pr: dict) -> dict:
|
||||
@@ -3275,6 +3493,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(
|
||||
@@ -5252,6 +5477,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",
|
||||
@@ -6515,6 +6746,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"
|
||||
@@ -8099,6 +8339,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 +8376,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 +8452,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 +8461,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 +8498,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 +8617,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 +8918,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 +8930,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 +8965,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 +8976,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 +8985,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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -5540,6 +5540,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -184,6 +184,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
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
|
||||
@@ -37,6 +37,9 @@ 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
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
|
||||
@@ -28,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:
|
||||
|
||||
@@ -28,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:
|
||||
@@ -617,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
|
||||
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -351,6 +351,92 @@ class TestReconciliationRules(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalStateUpdateRules(unittest.TestCase):
|
||||
def test_claimed_state_comment_without_block_blocks(self):
|
||||
report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99."
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_state_comment_missing_next_prompt_blocks(self):
|
||||
report = (
|
||||
_reconcile_handoff()
|
||||
+ """
|
||||
|
||||
## Canonical PR State
|
||||
|
||||
STATE:
|
||||
needs-review
|
||||
|
||||
WHO_IS_NEXT:
|
||||
reviewer
|
||||
|
||||
NEXT_ACTION:
|
||||
Review PR #99.
|
||||
|
||||
NEXT_PROMPT:
|
||||
|
||||
ISSUE:
|
||||
#98
|
||||
|
||||
HEAD_SHA:
|
||||
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
|
||||
|
||||
REVIEW_STATUS:
|
||||
none
|
||||
|
||||
MERGE_READY:
|
||||
no
|
||||
"""
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
reasons = " ".join(f["reason"] for f in result["findings"])
|
||||
self.assertIn("NEXT_PROMPT", reasons)
|
||||
|
||||
def test_valid_state_comment_claim_passes_shared_rule(self):
|
||||
report = (
|
||||
_reconcile_handoff()
|
||||
+ """
|
||||
|
||||
## Canonical PR State
|
||||
|
||||
STATE:
|
||||
needs-review
|
||||
|
||||
WHO_IS_NEXT:
|
||||
reviewer
|
||||
|
||||
NEXT_ACTION:
|
||||
Review PR #99 against the pinned head.
|
||||
|
||||
NEXT_PROMPT:
|
||||
Load the review workflow and review PR #99.
|
||||
|
||||
ISSUE:
|
||||
#98
|
||||
|
||||
HEAD_SHA:
|
||||
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
|
||||
|
||||
REVIEW_STATUS:
|
||||
none
|
||||
|
||||
MERGE_READY:
|
||||
no
|
||||
|
||||
BLOCKERS:
|
||||
none
|
||||
"""
|
||||
)
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertFalse(
|
||||
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
||||
|
||||
@@ -579,6 +665,31 @@ class TestReconcilerCloseProof(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalCommentPostClaim(unittest.TestCase):
|
||||
def test_blocks_post_claim_when_validator_rejected_in_report(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- MCP/Gitea mutations: issue comment posted",
|
||||
"- Issue comments posted: 1 canonical state comment",
|
||||
"canonical comment validation failed (fail closed before posting).",
|
||||
'- canonical_comment_validation: {"allowed": false}',
|
||||
])
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("shared.canonical_comment_post_claim", rule_ids)
|
||||
self.assertTrue(result["blocked"])
|
||||
|
||||
def test_allows_none_when_validator_rejected_without_post_claim(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Issue comments posted: none",
|
||||
"canonical comment validation failed (fail closed before posting).",
|
||||
])
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertNotIn("shared.canonical_comment_post_claim", rule_ids)
|
||||
|
||||
|
||||
class TestEntryPoint(unittest.TestCase):
|
||||
def test_unknown_task_kind_blocks(self):
|
||||
result = assess_final_report_validator("report", "unknown_mode")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
+151
-18
@@ -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")
|
||||
@@ -3102,6 +3193,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 +3886,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 +3918,7 @@ class TestIssueLocking(unittest.TestCase):
|
||||
worktree_path=scratch,
|
||||
)
|
||||
self.assertEqual(res["number"], 250)
|
||||
self.assertTrue(res["issue_status_transition"]["performed"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for web UI runtime health dashboard (#430)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict
|
||||
|
||||
|
||||
def _mock_identity(_host: str):
|
||||
return "jcwalker3", None
|
||||
|
||||
|
||||
def _mock_git_sync(_repo: Path, _remote: str, _branch: str):
|
||||
return "localsha123456", "remoteshaabcdef", 2
|
||||
|
||||
|
||||
class TestRuntimeHealthLoader(unittest.TestCase):
|
||||
def test_role_kind_author(self):
|
||||
allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"]
|
||||
forbidden = ["gitea.pr.merge", "gitea.pr.approve"]
|
||||
self.assertEqual(_role_kind(allowed, forbidden), "author")
|
||||
|
||||
def test_snapshot_with_mocks(self):
|
||||
snapshot = load_runtime_snapshot(
|
||||
resolve_username=_mock_identity,
|
||||
git_sync=_mock_git_sync,
|
||||
)
|
||||
self.assertEqual(snapshot.project_id, "gitea-tools")
|
||||
self.assertEqual(snapshot.authenticated_username, "jcwalker3")
|
||||
self.assertEqual(snapshot.commits_behind_master, 2)
|
||||
self.assertIsNotNone(snapshot.stale_runtime_warning)
|
||||
self.assertGreaterEqual(len(snapshot.workflow_hashes), 3)
|
||||
self.assertGreaterEqual(len(snapshot.schema_hashes), 2)
|
||||
self.assertIn("shell_use_allowed", snapshot.shell_health)
|
||||
|
||||
def test_snapshot_dict_export(self):
|
||||
snapshot = load_runtime_snapshot(
|
||||
resolve_username=_mock_identity,
|
||||
git_sync=_mock_git_sync,
|
||||
)
|
||||
data = snapshot_to_dict(snapshot)
|
||||
self.assertEqual(data["authenticated_username"], "jcwalker3")
|
||||
self.assertEqual(data["commits_behind_master"], 2)
|
||||
self.assertTrue(data["stale_runtime_warning"])
|
||||
|
||||
|
||||
class TestRuntimeRoutes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
self.snapshot = load_runtime_snapshot(
|
||||
resolve_username=_mock_identity,
|
||||
git_sync=_mock_git_sync,
|
||||
)
|
||||
self._patch = mock.patch(
|
||||
"webui.app.load_runtime_snapshot",
|
||||
return_value=self.snapshot,
|
||||
)
|
||||
self._patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._patch.stop()
|
||||
|
||||
def test_runtime_page_renders_profile_and_warning(self):
|
||||
response = self.client.get("/runtime")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Runtime health", response.text)
|
||||
self.assertIn("Active profile", response.text)
|
||||
self.assertIn("Stale runtime warning", response.text)
|
||||
self.assertIn("Workflow hashes", response.text)
|
||||
self.assertNotIn("child issue", response.text.lower())
|
||||
|
||||
def test_api_runtime_json(self):
|
||||
response = self.client.get("/api/runtime")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["project_id"], "gitea-tools")
|
||||
self.assertEqual(data["role_kind"], self.snapshot.role_kind)
|
||||
self.assertEqual(data["commits_behind_master"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertIn("Operator console", response.text)
|
||||
self.assertIn("Read-only MVP", response.text)
|
||||
|
||||
def test_runtime_is_implemented(self):
|
||||
response = self.client.get("/runtime")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Runtime health", response.text)
|
||||
|
||||
def test_route_stubs_render(self):
|
||||
for path in ("/runtime", "/audit"):
|
||||
for path in ("/audit",):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
+11
-6
@@ -16,8 +16,10 @@ from webui.prompt_library import find_prompt, library_to_dict
|
||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||
from webui.lease_views import render_leases_page
|
||||
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
|
||||
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict
|
||||
from webui.queue_views import render_queue_page
|
||||
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
|
||||
from webui.runtime_views import render_runtime_page
|
||||
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
@@ -63,7 +65,7 @@ async def queue(_request: Request) -> HTMLResponse:
|
||||
|
||||
|
||||
async def api_queue(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(snapshot_to_dict(load_queue_snapshot()))
|
||||
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
|
||||
|
||||
|
||||
async def projects(_request: Request) -> HTMLResponse:
|
||||
@@ -122,10 +124,12 @@ async def api_prompts(_request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
async def runtime(_request: Request) -> HTMLResponse:
|
||||
return _stub_page(
|
||||
"Runtime",
|
||||
"Runtime health will report MCP profile, preflight, and stale-server signals.",
|
||||
)
|
||||
snapshot = load_runtime_snapshot()
|
||||
return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot)))
|
||||
|
||||
|
||||
async def api_runtime(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
|
||||
|
||||
|
||||
async def audit(_request: Request) -> HTMLResponse:
|
||||
@@ -176,6 +180,7 @@ def create_app() -> Starlette:
|
||||
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||
Route("/runtime", runtime, methods=["GET"]),
|
||||
Route("/api/runtime", api_runtime, methods=["GET"]),
|
||||
Route("/audit", audit, methods=["GET"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
Route("/leases", leases, methods=["GET"]),
|
||||
|
||||
@@ -11,8 +11,8 @@ from urllib.parse import urlparse
|
||||
|
||||
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
||||
from issue_claim_heartbeat import build_claim_inventory
|
||||
from merged_cleanup_reconcile import read_issue_lock
|
||||
from issue_lock_provenance import ISSUE_LOCK_FILE
|
||||
from merged_cleanup_reconcile import read_issue_lock
|
||||
|
||||
from webui.project_registry import ProjectRecord, load_registry
|
||||
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Read-only MCP/runtime health snapshot for the web UI (#430)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import gitea_config
|
||||
import reconciler_profile
|
||||
from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url
|
||||
|
||||
from native_mcp_preference import shell_health_status
|
||||
|
||||
from webui.project_registry import ProjectRecord, load_registry
|
||||
|
||||
_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload"
|
||||
_HOT_RELOAD_ISSUE = "#420"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileHash:
|
||||
label: str
|
||||
path: str
|
||||
sha256: str | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeSnapshot:
|
||||
project_id: str
|
||||
repo_root: str
|
||||
remote: str
|
||||
host: str
|
||||
profile_name: str
|
||||
role_kind: str
|
||||
config_model: str
|
||||
profile_mode: str
|
||||
profile_source: str
|
||||
authenticated_username: str | None
|
||||
identity_error: str | None
|
||||
repo_sha: str | None
|
||||
remote_master_sha: str | None
|
||||
commits_behind_master: int | None
|
||||
stale_runtime_warning: str | None
|
||||
shell_health: dict[str, Any]
|
||||
workflow_hashes: tuple[FileHash, ...]
|
||||
schema_hashes: tuple[FileHash, ...]
|
||||
restart_guidance: str
|
||||
fetch_error: str | None = None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||
if override:
|
||||
return Path(override).resolve()
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _host_from_url(remote_host: str) -> str:
|
||||
parsed = urlparse(remote_host.strip())
|
||||
return parsed.netloc or remote_host.strip().rstrip("/")
|
||||
|
||||
|
||||
def _remote_name_for_host(host: str) -> str:
|
||||
for name, profile in REMOTES.items():
|
||||
if profile.get("host") == host:
|
||||
return name
|
||||
return "custom"
|
||||
|
||||
|
||||
def _config_model(config: dict | None) -> str:
|
||||
if config is None:
|
||||
return "env-only"
|
||||
version = config.get("version")
|
||||
if version == 1:
|
||||
return "v1"
|
||||
if version == 2:
|
||||
if "contexts" in config or config.get("shape") == "contexts":
|
||||
return "v2-contexts"
|
||||
return "v2-environments"
|
||||
return f"unknown-version-{version}"
|
||||
|
||||
|
||||
def _profile_source() -> str:
|
||||
if os.environ.get("GITEA_PROFILE_NAME"):
|
||||
return "env var"
|
||||
if gitea_config.selected_profile_name():
|
||||
return "config file profile"
|
||||
return "default"
|
||||
|
||||
|
||||
def _role_kind(allowed: list[str], forbidden: list[str]) -> str:
|
||||
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
||||
return "reconciler"
|
||||
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
review = can("gitea.pr.approve") or can("gitea.pr.merge")
|
||||
author = can("gitea.pr.create") or can("gitea.branch.push")
|
||||
reconciler = can("gitea.pr.close") and not review and not author
|
||||
if review and author:
|
||||
return "mixed"
|
||||
if review:
|
||||
return "reviewer"
|
||||
if reconciler:
|
||||
return "reconciler"
|
||||
if author:
|
||||
return "author"
|
||||
return "limited"
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> FileHash:
|
||||
label = path.name
|
||||
try:
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
return FileHash(label=label, path=str(path), sha256=digest)
|
||||
except OSError as exc:
|
||||
return FileHash(label=label, path=str(path), sha256=None, error=str(exc))
|
||||
|
||||
|
||||
def _run_git(repo: Path, *args: str) -> str | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout.strip() or None
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
|
||||
def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]:
|
||||
local_sha = _run_git(repo, "rev-parse", "HEAD")
|
||||
remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}")
|
||||
behind = None
|
||||
if local_sha and remote_sha and local_sha != remote_sha:
|
||||
count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}")
|
||||
if count is not None and count.isdigit():
|
||||
behind = int(count)
|
||||
return local_sha, remote_sha, behind
|
||||
|
||||
|
||||
def _resolve_username(host: str) -> tuple[str | None, str | None]:
|
||||
auth = get_auth_header(host)
|
||||
if not auth:
|
||||
return None, f"Gitea credentials unavailable for {host}"
|
||||
try:
|
||||
data = api_request("GET", gitea_url(host, "/api/v1/user"), auth)
|
||||
except Exception as exc: # noqa: BLE001 — operator-visible identity errors
|
||||
return None, f"Identity lookup failed: {exc}"
|
||||
username = (data or {}).get("login")
|
||||
if not username:
|
||||
return None, "Authenticated identity could not be resolved"
|
||||
return str(username), None
|
||||
|
||||
|
||||
def _stale_warning(commits_behind: int | None) -> str | None:
|
||||
if commits_behind is None or commits_behind <= 0:
|
||||
return None
|
||||
return (
|
||||
f"Local checkout is {commits_behind} commit(s) behind remote master. "
|
||||
"Merged safety-gate changes may require an MCP server restart or profile "
|
||||
f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}."
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_snapshot(
|
||||
project_id: str | None = None,
|
||||
*,
|
||||
resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None,
|
||||
git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None,
|
||||
) -> RuntimeSnapshot:
|
||||
"""Build a read-only runtime health snapshot for the default registry project."""
|
||||
registry = load_registry()
|
||||
project: ProjectRecord | None = None
|
||||
if project_id:
|
||||
for entry in registry.projects:
|
||||
if entry.id == project_id:
|
||||
project = entry
|
||||
break
|
||||
else:
|
||||
project = registry.projects[0] if registry.projects else None
|
||||
|
||||
if project is None:
|
||||
return RuntimeSnapshot(
|
||||
project_id=project_id or "",
|
||||
repo_root=str(_repo_root()),
|
||||
remote="",
|
||||
host="",
|
||||
profile_name="",
|
||||
role_kind="",
|
||||
config_model="",
|
||||
profile_mode="",
|
||||
profile_source="",
|
||||
authenticated_username=None,
|
||||
identity_error="project not found in registry",
|
||||
repo_sha=None,
|
||||
remote_master_sha=None,
|
||||
commits_behind_master=None,
|
||||
stale_runtime_warning=None,
|
||||
shell_health={},
|
||||
workflow_hashes=(),
|
||||
schema_hashes=(),
|
||||
restart_guidance=_RESTART_DOCS,
|
||||
fetch_error="project not found in registry",
|
||||
)
|
||||
|
||||
repo = _repo_root()
|
||||
host = _host_from_url(project.remote_host)
|
||||
remote = _remote_name_for_host(host)
|
||||
profile = get_profile()
|
||||
allowed = profile.get("allowed_operations") or []
|
||||
forbidden = profile.get("forbidden_operations") or []
|
||||
config = None
|
||||
try:
|
||||
config = gitea_config.load_config()
|
||||
except gitea_config.ConfigError as exc:
|
||||
return RuntimeSnapshot(
|
||||
project_id=project.id,
|
||||
repo_root=str(repo),
|
||||
remote=remote,
|
||||
host=host,
|
||||
profile_name=profile.get("profile_name") or "",
|
||||
role_kind=_role_kind(allowed, forbidden),
|
||||
config_model="config-error",
|
||||
profile_mode="unknown",
|
||||
profile_source=_profile_source(),
|
||||
authenticated_username=None,
|
||||
identity_error=str(exc),
|
||||
repo_sha=None,
|
||||
remote_master_sha=None,
|
||||
commits_behind_master=None,
|
||||
stale_runtime_warning=None,
|
||||
shell_health=shell_health_status(),
|
||||
workflow_hashes=(),
|
||||
schema_hashes=(),
|
||||
restart_guidance=_RESTART_DOCS,
|
||||
fetch_error=str(exc),
|
||||
)
|
||||
|
||||
identity_fn = resolve_username or _resolve_username
|
||||
username, identity_error = identity_fn(host)
|
||||
|
||||
git_fn = git_sync or _git_sync_status
|
||||
remote_ref = "prgs" if remote == "prgs" else "origin"
|
||||
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)
|
||||
|
||||
workflow_hashes = tuple(
|
||||
_sha256_file(repo / rel_path)
|
||||
for rel_path in project.workflow_paths.values()
|
||||
)
|
||||
schema_hashes = tuple(
|
||||
_sha256_file(repo / rel_path)
|
||||
for rel_path in project.schema_paths.values()
|
||||
)
|
||||
|
||||
switching = gitea_config.is_runtime_switching_enabled()
|
||||
return RuntimeSnapshot(
|
||||
project_id=project.id,
|
||||
repo_root=str(repo),
|
||||
remote=remote,
|
||||
host=host,
|
||||
profile_name=str(profile.get("profile_name") or ""),
|
||||
role_kind=_role_kind(allowed, forbidden),
|
||||
config_model=_config_model(config),
|
||||
profile_mode="dynamic-profile" if switching else "static-profile",
|
||||
profile_source=_profile_source(),
|
||||
authenticated_username=username,
|
||||
identity_error=identity_error,
|
||||
repo_sha=local_sha,
|
||||
remote_master_sha=remote_sha,
|
||||
commits_behind_master=behind,
|
||||
stale_runtime_warning=_stale_warning(behind),
|
||||
shell_health=shell_health_status(),
|
||||
workflow_hashes=workflow_hashes,
|
||||
schema_hashes=schema_hashes,
|
||||
restart_guidance=_RESTART_DOCS,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]:
|
||||
def _hash_row(item: FileHash) -> dict[str, Any]:
|
||||
return {
|
||||
"label": item.label,
|
||||
"path": item.path,
|
||||
"sha256": item.sha256,
|
||||
"error": item.error,
|
||||
}
|
||||
|
||||
return {
|
||||
"project_id": snapshot.project_id,
|
||||
"repo_root": snapshot.repo_root,
|
||||
"remote": snapshot.remote,
|
||||
"host": snapshot.host,
|
||||
"profile_name": snapshot.profile_name,
|
||||
"role_kind": snapshot.role_kind,
|
||||
"config_model": snapshot.config_model,
|
||||
"profile_mode": snapshot.profile_mode,
|
||||
"profile_source": snapshot.profile_source,
|
||||
"authenticated_username": snapshot.authenticated_username,
|
||||
"identity_error": snapshot.identity_error,
|
||||
"repo_sha": snapshot.repo_sha,
|
||||
"remote_master_sha": snapshot.remote_master_sha,
|
||||
"commits_behind_master": snapshot.commits_behind_master,
|
||||
"stale_runtime_warning": snapshot.stale_runtime_warning,
|
||||
"shell_health": snapshot.shell_health,
|
||||
"workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes],
|
||||
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
|
||||
"restart_guidance": snapshot.restart_guidance,
|
||||
"fetch_error": snapshot.fetch_error,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""HTML views for MCP runtime health (#430)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from webui.runtime_health import FileHash, RuntimeSnapshot
|
||||
|
||||
|
||||
def _hash_rows(items: tuple[FileHash, ...]) -> str:
|
||||
if not items:
|
||||
return "<p class='muted'>No hashes available.</p>"
|
||||
rows = []
|
||||
for item in items:
|
||||
digest = item.sha256 or item.error or "unavailable"
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(item.label)}</td>"
|
||||
f"<td><code>{html.escape(item.path)}</code></td>"
|
||||
f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class='registry'><thead><tr>"
|
||||
"<th>Artifact</th><th>Path</th><th>SHA-256</th>"
|
||||
"</tr></thead><tbody>"
|
||||
f"{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
def render_runtime_page(snapshot: RuntimeSnapshot) -> str:
|
||||
error_block = ""
|
||||
if snapshot.fetch_error:
|
||||
error_block = (
|
||||
'<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> '
|
||||
f"{html.escape(snapshot.fetch_error)}</p></div>"
|
||||
)
|
||||
|
||||
identity = snapshot.authenticated_username or "unresolved"
|
||||
if snapshot.identity_error:
|
||||
identity = f"unresolved ({snapshot.identity_error})"
|
||||
|
||||
stale_block = ""
|
||||
if snapshot.stale_runtime_warning:
|
||||
stale_block = (
|
||||
'<div class="stub"><p><strong>Stale runtime warning:</strong> '
|
||||
f"{html.escape(snapshot.stale_runtime_warning)}</p></div>"
|
||||
)
|
||||
|
||||
shell = snapshot.shell_health or {}
|
||||
shell_summary = (
|
||||
f"shell_use_allowed={shell.get('shell_use_allowed')} · "
|
||||
f"failures={shell.get('consecutive_spawn_failures')} · "
|
||||
f"hard_stopped={shell.get('hard_stopped')}"
|
||||
)
|
||||
|
||||
return (
|
||||
"<h2>Runtime health</h2>"
|
||||
f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · "
|
||||
f"host <code>{html.escape(snapshot.host)}</code> · "
|
||||
f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>"
|
||||
f"{error_block}"
|
||||
f"{stale_block}"
|
||||
"<h3>Active profile</h3>"
|
||||
"<table class='detail'>"
|
||||
f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>"
|
||||
f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>"
|
||||
f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>"
|
||||
f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>"
|
||||
f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} "
|
||||
f"({html.escape(snapshot.profile_source)})</td></tr>"
|
||||
"</table>"
|
||||
"<h3>Server code sync</h3>"
|
||||
"<table class='detail'>"
|
||||
f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>"
|
||||
f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>"
|
||||
f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>"
|
||||
f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>"
|
||||
"</table>"
|
||||
"<h3>Shell health</h3>"
|
||||
f"<p class='meta'>{html.escape(shell_summary)}</p>"
|
||||
f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>"
|
||||
"<h3>Workflow hashes</h3>"
|
||||
f"{_hash_rows(snapshot.workflow_hashes)}"
|
||||
"<h3>Schema hashes</h3>"
|
||||
f"{_hash_rows(snapshot.schema_hashes)}"
|
||||
"<h3>Restart / reload</h3>"
|
||||
"<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator "
|
||||
"workflow. Related issue: <code>#420</code>. Guidance: "
|
||||
f"<code>{html.escape(snapshot.restart_guidance)}</code></p>"
|
||||
"<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>"
|
||||
)
|
||||
Reference in New Issue
Block a user