Merge remote-tracking branch 'prgs/master' into feat/issue-514-branch-delete-guard
# Conflicts: # final_report_validator.py
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_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_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_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_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_mark_issue` | Claim/release an issue (start/done) |
|
||||||
| `gitea_list_labels` | List all available labels in a repository |
|
| `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,
|
get_credentials, resolve_remote, add_remote_args,
|
||||||
api_request, repo_api_url,
|
api_request, repo_api_url,
|
||||||
)
|
)
|
||||||
|
import issue_workflow_labels
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
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", default="", help="Issue body text.")
|
||||||
parser.add_argument("--body-file",
|
parser.add_argument("--body-file",
|
||||||
help="Read issue body from this file ('-' for stdin).")
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
host, org, repo = resolve_remote(args)
|
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:
|
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||||
body = fh.read()
|
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)
|
user, password = get_credentials(host)
|
||||||
if not user or not password:
|
if not user or not password:
|
||||||
print(f"Could not get credentials for {host} "
|
print(f"Could not get credentials for {host} "
|
||||||
@@ -59,11 +88,34 @@ def main(argv=None):
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
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:
|
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})
|
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')}")
|
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
|
return 0
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
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
|
||||||
@@ -22,9 +22,12 @@ launched with exactly one static execution profile:
|
|||||||
| Namespace (MCP server name) | Profile (role) | Typical use |
|
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||||
|-----------------------------|----------------|-------------|
|
|-----------------------------|----------------|-------------|
|
||||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
| `gitea-reviewer` | a reviewer profile | review, approve/request changes |
|
||||||
|
| `gitea-merger` | a merger profile | merge PRs after approval and verification |
|
||||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
Properties:
|
Properties:
|
||||||
|
|
||||||
- **One process, one credential.** Each namespace authenticates as exactly
|
- **One process, one credential.** Each namespace authenticates as exactly
|
||||||
@@ -106,6 +109,14 @@ syntax to the client):
|
|||||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gitea-merger": {
|
||||||
|
"command": "<path-to>/venv/bin/python3",
|
||||||
|
"args": ["<path-to>/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||||
|
"GITEA_MCP_PROFILE": "<merger-profile-name>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo
|
|||||||
### 2. Dual MCP Namespaces Recommendation
|
### 2. Dual MCP Namespaces Recommendation
|
||||||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges.
|
||||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||||
This is the model accepted in #139; deployment details, rationale, and client
|
This is the model accepted in #139; deployment details, rationale, and client
|
||||||
setup live in
|
setup live in
|
||||||
|
|||||||
+88
-24
@@ -1,34 +1,98 @@
|
|||||||
# Label Taxonomy
|
# 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`**
|
Discussion-only issues must carry `type:discussion`.
|
||||||
* 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.
|
|
||||||
|
|
||||||
* **`glitchtip`**
|
## Issue Type Labels
|
||||||
* Description: GlitchTip integration
|
|
||||||
* Color: `b60205`
|
|
||||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
|
||||||
|
|
||||||
## 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`**
|
## Workflow Status Labels
|
||||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
|
||||||
* Proposed Color: `5319e7`
|
|
||||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
|
||||||
|
|
||||||
* **`source:glitchtip`**
|
Only one `status:*` label should be active on an issue at a time. When an issue
|
||||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
moves forward, tooling must remove the old `status:*` label and apply the new
|
||||||
* Proposed Color: `b60205`
|
one.
|
||||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
|
||||||
|
|
||||||
* **`status:triage`**
|
| Label | Use |
|
||||||
* Proposed Description: Issue needs human or orchestrator triage
|
| --- | --- |
|
||||||
* Proposed Color: `fbca04`
|
| `status:triage` | Issue needs triage |
|
||||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
| `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
|
and reviewing pull requests, merging, and closing out — safely and
|
||||||
reproducibly.
|
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,
|
> For the **project-agnostic** version of these operating rules (issue-first,
|
||||||
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
||||||
> that can be copied into any repository, see the reusable skill
|
> that can be copied into any repository, see the reusable skill
|
||||||
@@ -408,6 +413,13 @@ The guard blocks when the **control checkout** (repository root) is:
|
|||||||
`branches/...` directories are disposable role worktrees; the root checkout is
|
`branches/...` directories are disposable role worktrees; the root checkout is
|
||||||
the stable orchestration surface only.
|
the stable orchestration surface only.
|
||||||
|
|
||||||
|
## No direct-import mutation path (#558)
|
||||||
|
|
||||||
|
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
|
||||||
|
keychain fill from a raw shell to bypass MCP preflight.
|
||||||
|
|
||||||
|
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
|
||||||
|
|
||||||
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||||
|
|
||||||
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||||
@@ -634,19 +646,24 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
|||||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||||
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
||||||
minimal label set; link children to the parent.
|
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
|
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
||||||
<body>, then create child issues for <list> and link them to the parent.`
|
<body>, then create child issues for <list> and link them to the parent.`
|
||||||
|
|
||||||
### Implement an issue and open a PR
|
### Implement an issue and open a PR
|
||||||
|
|
||||||
- **Profile:** author.
|
- **Profile:** author.
|
||||||
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
|
- **Steps:** claim the issue (`status:in-progress`, replacing any old
|
||||||
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
|
`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
|
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||||
update tests if behavior changes; run the full suite; commit with an
|
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
|
issue-linked message; open a PR to `master`; move the issue to
|
||||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
`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
|
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||||
master. Do not self-review or self-merge.`
|
master. Do not self-review or self-merge.`
|
||||||
|
|
||||||
@@ -687,10 +704,10 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
|
|||||||
### Merge a PR
|
### Merge a PR
|
||||||
|
|
||||||
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
||||||
- **Steps:** confirm eligibility; require explicit confirmation
|
- **Steps:**
|
||||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
|
||||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||||
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||||
|
|
||||||
@@ -779,7 +796,7 @@ an author.
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
||||||
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
||||||
| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails |
|
| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails |
|
||||||
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
||||||
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
||||||
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
||||||
@@ -829,6 +846,27 @@ Never imply full-suite success unless the full-suite command itself passed
|
|||||||
(`full_suite_passed: true`). A report that hides a failed or skipped check
|
(`full_suite_passed: true`). A report that hides a failed or skipped check
|
||||||
is worse than a failing report.
|
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)
|
## Controller Handoff (required, every task)
|
||||||
|
|
||||||
Every task — implementation, review, merge, triage, documentation,
|
Every task — implementation, review, merge, triage, documentation,
|
||||||
@@ -861,6 +899,55 @@ touched release state names the exact tag/commit and why. Design debates
|
|||||||
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
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.
|
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
|
## Fail-closed behavior
|
||||||
|
|
||||||
Before any mutating action the workflow verifies identity, active profile,
|
Before any mutating action the workflow verifies identity, active profile,
|
||||||
@@ -972,6 +1059,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## 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).
|
- [`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.
|
- [`../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.
|
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# MCP daemon import and keychain guard (#558)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
During deadlock debugging, agents imported `gitea_mcp_server` / ran credential
|
||||||
|
helpers from a raw shell, bypassing preflight purity and role gates.
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Mutation auth and keychain fill require a **sanctioned MCP daemon** process.
|
||||||
|
|
||||||
|
| Context | Allowed |
|
||||||
|
|---------|---------|
|
||||||
|
| Official MCP entrypoint (`mcp_server.py` / `gitea_mcp_server` `__main__`) sets `GITEA_MCP_SANCTIONED_DAEMON=1` | yes |
|
||||||
|
| pytest | yes |
|
||||||
|
| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` (operator/tests only) | yes |
|
||||||
|
| bare `python -c 'import gitea_auth; get_auth_header(...)'` | **no** |
|
||||||
|
| keychain fill without daemon | **no** unless `GITEA_ALLOW_KEYCHAIN_CLI=1` |
|
||||||
|
|
||||||
|
## Operator note
|
||||||
|
|
||||||
|
LLM sessions must never set the allow-direct-import or allow-keychain-cli
|
||||||
|
overrides. Those are human-only escape hatches.
|
||||||
@@ -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
|
||||||
@@ -16,12 +16,21 @@ bind an authenticated Gitea identity to an allowed operation set.
|
|||||||
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
||||||
- **Forbidden:** PR approve, merge, request_changes.
|
- **Forbidden:** PR approve, merge, request_changes.
|
||||||
|
|
||||||
### Reviewer / merger
|
### Reviewer
|
||||||
|
|
||||||
- **Profile:** `prgs-reviewer`
|
- **Profile:** `prgs-reviewer`
|
||||||
- **Typical identity:** `sysadmin`
|
- **Typical identity:** `sysadmin`
|
||||||
- **Allowed:** PR review/approve/merge/request_changes, issue comment, read.
|
- **Allowed:** PR review/approve/request_changes, issue comment, read.
|
||||||
- **Forbidden:** branch push, PR create, repo commit.
|
- **Forbidden:** branch push, PR create, repo commit, PR merge.
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
|
### Merger
|
||||||
|
|
||||||
|
- **Profile:** `prgs-merger`
|
||||||
|
- **Typical identity:** `sysadmin`
|
||||||
|
- **Allowed:** PR merge, issue comment, read.
|
||||||
|
- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any, Callable
|
|||||||
import branch_cleanup_guard
|
import branch_cleanup_guard
|
||||||
import issue_acceptance_gate
|
import issue_acceptance_gate
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
|
import reviewer_handoff_consistency
|
||||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||||
from review_proofs import (
|
from review_proofs import (
|
||||||
@@ -30,6 +31,7 @@ from validation_status_vocabulary import assess_validation_status_vocabulary
|
|||||||
|
|
||||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
|
"merge_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"author_issue",
|
"author_issue",
|
||||||
"work_issue",
|
"work_issue",
|
||||||
@@ -41,6 +43,8 @@ FINAL_REPORT_TASK_KINDS = frozenset({
|
|||||||
_TASK_KIND_ALIASES = {
|
_TASK_KIND_ALIASES = {
|
||||||
"review": "review_pr",
|
"review": "review_pr",
|
||||||
"review-merge-pr": "review_pr",
|
"review-merge-pr": "review_pr",
|
||||||
|
"merge": "merge_pr",
|
||||||
|
"merge_pr": "merge_pr",
|
||||||
"reconcile-landed-pr": "reconcile_already_landed",
|
"reconcile-landed-pr": "reconcile_already_landed",
|
||||||
"reconcile_already_landed": "reconcile_already_landed",
|
"reconcile_already_landed": "reconcile_already_landed",
|
||||||
"work-issue": "work_issue",
|
"work-issue": "work_issue",
|
||||||
@@ -49,6 +53,7 @@ _TASK_KIND_ALIASES = {
|
|||||||
|
|
||||||
_HANDOFF_ROLE_BY_TASK = {
|
_HANDOFF_ROLE_BY_TASK = {
|
||||||
"review_pr": "review",
|
"review_pr": "review",
|
||||||
|
"merge_pr": "merger",
|
||||||
"reconcile_already_landed": None,
|
"reconcile_already_landed": None,
|
||||||
"author_issue": "author",
|
"author_issue": "author",
|
||||||
"work_issue": "author",
|
"work_issue": "author",
|
||||||
@@ -196,6 +201,23 @@ _PAGINATION_PROOF_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", 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(
|
_READONLY_DIAG_RE = re.compile(
|
||||||
r"read[- ]only diagnostics\s*:\s*(.+)$",
|
r"read[- ]only diagnostics\s*:\s*(.+)$",
|
||||||
re.IGNORECASE | re.MULTILINE,
|
re.IGNORECASE | re.MULTILINE,
|
||||||
@@ -382,6 +404,43 @@ def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_canonical_comment_post_claim(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
action_log: list[dict] | None = None,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""#496: reports must not claim comment posted when validator rejected."""
|
||||||
|
text = report_text or ""
|
||||||
|
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
||||||
|
rejected_in_log = False
|
||||||
|
if action_log:
|
||||||
|
for entry in action_log:
|
||||||
|
validation = entry.get("canonical_comment_validation") or {}
|
||||||
|
if validation.get("allowed") is False:
|
||||||
|
rejected_in_log = True
|
||||||
|
break
|
||||||
|
result = entry.get("result") or {}
|
||||||
|
nested = result.get("canonical_comment_validation") or {}
|
||||||
|
if nested.get("allowed") is False:
|
||||||
|
rejected_in_log = True
|
||||||
|
break
|
||||||
|
if not (rejected_in_report or rejected_in_log):
|
||||||
|
return []
|
||||||
|
if not _COMMENT_POSTED_CLAIM_RE.search(text):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
validator_finding(
|
||||||
|
"shared.canonical_comment_post_claim",
|
||||||
|
"block",
|
||||||
|
"MCP/Gitea mutations",
|
||||||
|
"report claims a Gitea comment was posted while canonical comment "
|
||||||
|
"validation rejected the workflow comment",
|
||||||
|
"do not claim comment posted when validator fail-closed; repair "
|
||||||
|
"the canonical comment body and retry posting",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_legacy_workspace_mutations(
|
def _rule_reviewer_legacy_workspace_mutations(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -599,6 +658,27 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||||
|
from conflict_fix_classification import (
|
||||||
|
assess_conflict_fix_classification_final_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = report_text or ""
|
||||||
|
result = assess_conflict_fix_classification_final_report(text)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"author.conflict_fix_classification_proof",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Conflict-fix classification",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=(
|
||||||
|
"call gitea_assess_conflict_fix_classification, state the live head "
|
||||||
|
"SHA, and state the classification before creating a conflict-fix worktree"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
|
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
|
||||||
from pr_work_lease import assess_conflict_fix_final_report
|
from pr_work_lease import assess_conflict_fix_final_report
|
||||||
|
|
||||||
@@ -1112,6 +1192,25 @@ def _rule_reviewer_validation_structured(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_handoff_consistency(report_text: str) -> list[dict[str, str]]:
|
||||||
|
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(
|
||||||
|
report_text
|
||||||
|
)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"reviewer.handoff_consistency",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Review mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=(
|
||||||
|
"rewrite reviewer handoff so narrative claims match the mutation "
|
||||||
|
"ledger; use the blocked-review handoff template when submission "
|
||||||
|
"was rejected"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_mutation_ledger(
|
def _rule_reviewer_mutation_ledger(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -1289,6 +1388,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]]:
|
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
|
||||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||||
|
|
||||||
@@ -1338,17 +1453,23 @@ _SHARED_ISSUE_LOCK_RULES = (
|
|||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
_rule_shared_author_reviewer_same_run,
|
_rule_shared_author_reviewer_same_run,
|
||||||
_rule_shared_raw_branch_delete_bypass,
|
_rule_shared_raw_branch_delete_bypass,
|
||||||
|
_rule_shared_canonical_state_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
_SHARED_CLEANUP_PROOF_RULES = (
|
_SHARED_CLEANUP_PROOF_RULES = (
|
||||||
_rule_shared_mcp_native_cleanup_proof,
|
_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]]]]] = {
|
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||||
"review_pr": [
|
"review_pr": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_legacy_workspace_mutations,
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
@@ -1367,6 +1488,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_already_landed_eligible,
|
_rule_reviewer_already_landed_eligible,
|
||||||
_rule_reviewer_already_landed_state,
|
_rule_reviewer_already_landed_state,
|
||||||
_rule_reviewer_target_branch_freshness,
|
_rule_reviewer_target_branch_freshness,
|
||||||
|
_rule_reviewer_handoff_consistency,
|
||||||
_rule_reviewer_workflow_load_boundary,
|
_rule_reviewer_workflow_load_boundary,
|
||||||
_rule_reviewer_mutation_ledger,
|
_rule_reviewer_mutation_ledger,
|
||||||
_rule_reviewer_review_mutation,
|
_rule_reviewer_review_mutation,
|
||||||
@@ -1375,10 +1497,22 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
*_SHARED_CLEANUP_PROOF_RULES,
|
*_SHARED_CLEANUP_PROOF_RULES,
|
||||||
_rule_reviewer_stale_head_proof,
|
_rule_reviewer_stale_head_proof,
|
||||||
],
|
],
|
||||||
|
"merge_pr": [
|
||||||
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
|
_rule_reviewer_vague_mutations_none,
|
||||||
|
_rule_reviewer_mutation_categories,
|
||||||
|
_rule_reviewer_git_fetch_readonly,
|
||||||
|
_rule_reviewer_linked_issue,
|
||||||
|
_rule_reviewer_stale_head_proof,
|
||||||
|
],
|
||||||
"reconcile_already_landed": [
|
"reconcile_already_landed": [
|
||||||
_rule_reconcile_controller_handoff,
|
_rule_reconcile_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
*_SHARED_CLEANUP_PROOF_RULES,
|
*_SHARED_CLEANUP_PROOF_RULES,
|
||||||
_rule_reconcile_stale_author_fields,
|
_rule_reconcile_stale_author_fields,
|
||||||
@@ -1396,6 +1530,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
],
|
],
|
||||||
@@ -1403,9 +1538,11 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_shared_issue_acceptance_gate,
|
_rule_shared_issue_acceptance_gate,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
|
_rule_conflict_fix_classification_proof,
|
||||||
_rule_conflict_fix_push_proof,
|
_rule_conflict_fix_push_proof,
|
||||||
_rule_worktree_cleanup_audit_proof,
|
_rule_worktree_cleanup_audit_proof,
|
||||||
],
|
],
|
||||||
@@ -1413,12 +1550,14 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
"inventory": [
|
"inventory": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reconcile_pagination_proof,
|
_rule_reconcile_pagination_proof,
|
||||||
],
|
],
|
||||||
@@ -1426,6 +1565,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
_rule_shared_state_handoff_next_action,
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,19 @@
|
|||||||
"execution_profile": "example-reviewer",
|
"execution_profile": "example-reviewer",
|
||||||
"audit_label": "example-reviewer",
|
"audit_label": "example-reviewer",
|
||||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"],
|
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"],
|
||||||
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
"forbidden_operations": ["branch", "commit", "push", "open_pr", "merge"]
|
||||||
|
},
|
||||||
|
"example-merger": {
|
||||||
|
"enabled": true,
|
||||||
|
"context": "example-context",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "reviewer-user",
|
||||||
|
"execution_profile": "example-merger",
|
||||||
|
"audit_label": "example-merger",
|
||||||
|
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||||
|
"allowed_operations": ["read", "comment", "issue.comment", "merge"],
|
||||||
|
"forbidden_operations": ["branch", "commit", "push", "open_pr", "approve", "review", "request_changes"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
@@ -63,7 +74,8 @@
|
|||||||
"default_owner": "Example-Org",
|
"default_owner": "Example-Org",
|
||||||
"default_repo": "Example-Repo",
|
"default_repo": "Example-Repo",
|
||||||
"default_author_profile": "example-author",
|
"default_author_profile": "example-author",
|
||||||
"default_reviewer_profile": "example-reviewer"
|
"default_reviewer_profile": "example-reviewer",
|
||||||
|
"default_merger_profile": "example-merger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ def get_credentials(host):
|
|||||||
|
|
||||||
# 3. Optional fallback to macOS Keychain via git credential fill
|
# 3. Optional fallback to macOS Keychain via git credential fill
|
||||||
if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1":
|
if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1":
|
||||||
|
# #558: block raw keychain dumps outside the sanctioned MCP daemon.
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||||
cmd_parts = ["git", "creden" + "tial", "fi" + "ll"]
|
cmd_parts = ["git", "creden" + "tial", "fi" + "ll"]
|
||||||
try:
|
try:
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
@@ -116,6 +120,8 @@ def get_credentials(host):
|
|||||||
user = line.split("=", 1)[1]
|
user = line.split("=", 1)[1]
|
||||||
elif line.startswith("password="):
|
elif line.startswith("password="):
|
||||||
password = line.split("=", 1)[1]
|
password = line.split("=", 1)[1]
|
||||||
|
except mcp_daemon_guard.UnsanctionedRuntimeError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -124,6 +130,11 @@ def get_credentials(host):
|
|||||||
|
|
||||||
def get_auth_header(host):
|
def get_auth_header(host):
|
||||||
"""Return an ``Authorization`` header value for *host*."""
|
"""Return an ``Authorization`` header value for *host*."""
|
||||||
|
# #558: resolving credentials for API mutation must not happen via ad-hoc
|
||||||
|
# direct imports that bypass the MCP daemon preflight wall.
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
mcp_daemon_guard.assert_sanctioned_mutation_runtime("get_auth_header")
|
||||||
host_key = host.lower().strip()
|
host_key = host.lower().strip()
|
||||||
|
|
||||||
# 1. Try Token-based auth from dynamic configs
|
# 1. Try Token-based auth from dynamic configs
|
||||||
|
|||||||
+409
-43
@@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None,
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Reviewer/author role pivot boundary: only an authorized pivot
|
# Reviewer/author role pivot boundary: only an authorized pivot
|
||||||
# (recorded by gitea_activate_profile) may cross author → reviewer.
|
# (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
|
||||||
if (required_role == "reviewer"
|
if (required_role in ("reviewer", "merger")
|
||||||
and "author" in str(data.get("initial_profile")).lower()
|
and "author" in str(data.get("initial_profile")).lower()
|
||||||
and "reviewer" in str(active_profile).lower()):
|
and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())):
|
||||||
if not data.get("role_pivot_authorized"):
|
if not data.get("role_pivot_authorized"):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Attempted reviewer mutation from author session without "
|
f"Attempted {required_role} mutation from author session without "
|
||||||
"authorized role pivot (fail closed)"
|
"authorized role pivot (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,7 +562,7 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
|||||||
|
|
||||||
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
||||||
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
||||||
(#468). Non-author namespaces use dedicated workspace env vars (#510).
|
(#468, #483). Non-author namespaces use dedicated workspace env vars (#510).
|
||||||
|
|
||||||
The exemption honours BOTH the effective workspace role and the actual
|
The exemption honours BOTH the effective workspace role and the actual
|
||||||
profile role (#540). ``comment_issue`` preflight stamps
|
profile role (#540). ``comment_issue`` preflight stamps
|
||||||
@@ -818,6 +818,7 @@ import root_checkout_guard # noqa: E402
|
|||||||
import remote_repo_guard # noqa: E402
|
import remote_repo_guard # noqa: E402
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import issue_work_duplicate_gate # noqa: E402
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
|
import issue_workflow_labels # noqa: E402
|
||||||
import reviewer_pr_lease # noqa: E402
|
import reviewer_pr_lease # noqa: E402
|
||||||
import merger_lease_adoption # noqa: E402
|
import merger_lease_adoption # noqa: E402
|
||||||
import merged_cleanup_reconcile # noqa: E402
|
import merged_cleanup_reconcile # noqa: E402
|
||||||
@@ -827,6 +828,7 @@ import reconciliation_workflow # noqa: E402
|
|||||||
import audit_reconciliation_mode # noqa: E402
|
import audit_reconciliation_mode # noqa: E402
|
||||||
import review_merge_state_machine # noqa: E402
|
import review_merge_state_machine # noqa: E402
|
||||||
import pr_work_lease # noqa: E402
|
import pr_work_lease # noqa: E402
|
||||||
|
import conflict_fix_classification # noqa: E402
|
||||||
import native_mcp_preference # noqa: E402
|
import native_mcp_preference # noqa: E402
|
||||||
import branch_cleanup_guard # noqa: E402
|
import branch_cleanup_guard # noqa: E402
|
||||||
import master_parity_gate # noqa: E402
|
import master_parity_gate # noqa: E402
|
||||||
@@ -837,6 +839,7 @@ import master_parity_gate # noqa: E402
|
|||||||
# Read-only operations are never blocked by staleness.
|
# Read-only operations are never blocked by staleness.
|
||||||
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
||||||
import worktree_cleanup_audit # noqa: E402
|
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
|
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
|
||||||
@@ -1176,6 +1179,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))
|
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||||
return sorted(list(issues))
|
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:
|
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:
|
if not issue_numbers:
|
||||||
return {}
|
return {}
|
||||||
@@ -1508,6 +1613,11 @@ def gitea_create_issue(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: 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,
|
allow_duplicate_override: bool = False,
|
||||||
split_from_issue: int | None = None,
|
split_from_issue: int | None = None,
|
||||||
worktree_path: str | None = None,
|
worktree_path: str | None = None,
|
||||||
@@ -1521,6 +1631,12 @@ def gitea_create_issue(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
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.
|
allow_duplicate_override: Operator-approved split after duplicate found.
|
||||||
split_from_issue: Existing duplicate issue number when overriding.
|
split_from_issue: Existing duplicate issue number when overriding.
|
||||||
worktree_path: Optional path to verify branches-only guard.
|
worktree_path: Optional path to verify branches-only guard.
|
||||||
@@ -1552,6 +1668,40 @@ def gitea_create_issue(
|
|||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||||||
base = repo_api_url(h, o, r)
|
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)
|
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
|
||||||
closed_issues = api_get_all(
|
closed_issues = api_get_all(
|
||||||
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
f"{base}/issues?state=closed&type=issues", auth, limit=100
|
||||||
@@ -1586,7 +1736,29 @@ def gitea_create_issue(
|
|||||||
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
|
||||||
request_metadata={"title": title}, mutation_task="create_issue")
|
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]:
|
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
|
||||||
@@ -1995,7 +2167,23 @@ def gitea_create_pr(
|
|||||||
)
|
)
|
||||||
|
|
||||||
auth = _auth(h)
|
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}
|
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||||
meta = {"title": title, "head": head, "base": base}
|
meta = {"title": title, "head": head, "base": base}
|
||||||
try:
|
try:
|
||||||
@@ -2010,7 +2198,37 @@ def gitea_create_pr(
|
|||||||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||||||
target_branch=head, request_metadata=meta,
|
target_branch=head, request_metadata=meta,
|
||||||
mutation_task="create_pr")
|
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:
|
def _format_list_pr_entry(pr: dict) -> dict:
|
||||||
@@ -2280,13 +2498,23 @@ def gitea_check_pr_eligibility(
|
|||||||
# ("gitea.pr.merge") always match each other and never cross services.
|
# ("gitea.pr.merge") always match each other and never cross services.
|
||||||
allowed = profile["allowed_operations"]
|
allowed = profile["allowed_operations"]
|
||||||
forbidden = profile["forbidden_operations"]
|
forbidden = profile["forbidden_operations"]
|
||||||
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
active_role = _role_kind(allowed, forbidden)
|
||||||
|
if action == "merge" and active_role == "reviewer":
|
||||||
|
op_ok = False
|
||||||
|
op_reason = "reviewer-cannot-merge"
|
||||||
|
else:
|
||||||
|
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
||||||
|
|
||||||
if not op_ok:
|
if not op_ok:
|
||||||
if op_reason == "no-allowed-operations":
|
if op_reason == "no-allowed-operations":
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"profile has no configured allowed operations (fail closed)")
|
"profile has no configured allowed operations (fail closed)")
|
||||||
elif op_reason == "forbidden":
|
elif op_reason == "forbidden":
|
||||||
reasons.append(f"profile forbids '{action}'")
|
reasons.append(f"profile forbids '{action}'")
|
||||||
|
elif op_reason == "reviewer-cannot-merge":
|
||||||
|
reasons.append(
|
||||||
|
"Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head."
|
||||||
|
)
|
||||||
elif op_reason == "invalid-forbidden-entry":
|
elif op_reason == "invalid-forbidden-entry":
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"profile has an unrecognized forbidden operation entry "
|
"profile has an unrecognized forbidden operation entry "
|
||||||
@@ -2310,21 +2538,23 @@ def gitea_check_pr_eligibility(
|
|||||||
missing_perm)
|
missing_perm)
|
||||||
|
|
||||||
req_profile = None
|
req_profile = None
|
||||||
|
role_noun = "reviewer"
|
||||||
if action in ("approve", "request_changes", "review"):
|
if action in ("approve", "request_changes", "review"):
|
||||||
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
|
req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)"
|
||||||
elif action == "merge":
|
elif action == "merge":
|
||||||
req_profile = "A profile with reviewer role permissions and explicit merge permission"
|
req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)"
|
||||||
|
role_noun = "merger"
|
||||||
result["required_profile"] = req_profile
|
result["required_profile"] = req_profile
|
||||||
|
|
||||||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||||||
if switching_supported:
|
if switching_supported:
|
||||||
result["fixable_by_profile_switch"] = True
|
result["fixable_by_profile_switch"] = True
|
||||||
result["requires_different_namespace"] = False
|
result["requires_different_namespace"] = False
|
||||||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}."
|
||||||
else:
|
else:
|
||||||
result["fixable_by_profile_switch"] = False
|
result["fixable_by_profile_switch"] = False
|
||||||
result["requires_different_namespace"] = True
|
result["requires_different_namespace"] = True
|
||||||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile."
|
||||||
|
|
||||||
result["safe_next_step"] = safe_step
|
result["safe_next_step"] = safe_step
|
||||||
return result
|
return result
|
||||||
@@ -3276,6 +3506,13 @@ def _evaluate_pr_review_submission(
|
|||||||
result["pr_work_lease"] = lease_block
|
result["pr_work_lease"] = lease_block
|
||||||
return result
|
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
|
result["would_perform"] = True
|
||||||
if not live:
|
if not live:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
@@ -4040,6 +4277,14 @@ def gitea_merge_pr(
|
|||||||
_verify_role_mutation_workspace(
|
_verify_role_mutation_workspace(
|
||||||
remote, worktree_path=worktree_path, task="merge_pr"
|
remote, worktree_path=worktree_path, task="merge_pr"
|
||||||
)
|
)
|
||||||
|
allowed = get_profile()["allowed_operations"]
|
||||||
|
forbidden = get_profile()["forbidden_operations"]
|
||||||
|
active_role = _role_kind(allowed, forbidden)
|
||||||
|
if active_role == "reviewer":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Reviewer profile cannot merge. Use a merger profile/session "
|
||||||
|
"after formal approval at current head."
|
||||||
|
)
|
||||||
workflow_blockers = _review_workflow_load_gate_reasons()
|
workflow_blockers = _review_workflow_load_gate_reasons()
|
||||||
do = (do or "").strip().lower()
|
do = (do or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
@@ -4056,6 +4301,10 @@ def gitea_merge_pr(
|
|||||||
"merge_result": None,
|
"merge_result": None,
|
||||||
"merge_commit": None,
|
"merge_commit": None,
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
|
"active_profile": get_profile()["profile_name"],
|
||||||
|
"role_kind": active_role,
|
||||||
|
"merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config",
|
||||||
|
"explicit_operator_auth": confirmation,
|
||||||
}
|
}
|
||||||
reasons = result["reasons"]
|
reasons = result["reasons"]
|
||||||
if workflow_blockers:
|
if workflow_blockers:
|
||||||
@@ -4225,7 +4474,7 @@ def gitea_merge_pr(
|
|||||||
# A profile/identity flip or side-channel override between preflight
|
# A profile/identity flip or side-channel override between preflight
|
||||||
# and merge fails closed here.
|
# and merge fails closed here.
|
||||||
try:
|
try:
|
||||||
verify_mutation_authority(remote, host, required_role="reviewer",
|
verify_mutation_authority(remote, host, required_role="merger",
|
||||||
active_identity=auth_user)
|
active_identity=auth_user)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
reasons.append(str(e))
|
reasons.append(str(e))
|
||||||
@@ -4923,6 +5172,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
remote_branch_exists=remote_branch_exists,
|
remote_branch_exists=remote_branch_exists,
|
||||||
head_on_master=head_on_master,
|
head_on_master=head_on_master,
|
||||||
delete_capability_allowed=delete_capability_allowed,
|
delete_capability_allowed=delete_capability_allowed,
|
||||||
|
target_ref=master_ref,
|
||||||
active_reviewer_leases=active_reviewer_leases,
|
active_reviewer_leases=active_reviewer_leases,
|
||||||
pr_states=pr_states,
|
pr_states=pr_states,
|
||||||
)
|
)
|
||||||
@@ -4964,7 +5214,9 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
|
|
||||||
if local_assessment.get("safe_to_remove_worktree"):
|
if local_assessment.get("safe_to_remove_worktree"):
|
||||||
result = merged_cleanup_reconcile.remove_local_worktree(
|
result = merged_cleanup_reconcile.remove_local_worktree(
|
||||||
PROJECT_ROOT, head_branch
|
PROJECT_ROOT,
|
||||||
|
head_branch,
|
||||||
|
worktree_path=local_assessment.get("worktree_path"),
|
||||||
)
|
)
|
||||||
actions.append({"action": "remove_local_worktree", **result})
|
actions.append({"action": "remove_local_worktree", **result})
|
||||||
|
|
||||||
@@ -5396,6 +5648,12 @@ def gitea_reconcile_already_landed_pr(
|
|||||||
"gitea.pr.comment"
|
"gitea.pr.comment"
|
||||||
)
|
)
|
||||||
return result
|
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"
|
comment_url = f"{base}/issues/{pr_number}/comments"
|
||||||
with _audited(
|
with _audited(
|
||||||
"comment_pr",
|
"comment_pr",
|
||||||
@@ -6659,6 +6917,15 @@ def gitea_create_issue_comment(
|
|||||||
blocked["permission_report"] = _permission_block_report(
|
blocked["permission_report"] = _permission_block_report(
|
||||||
"gitea.issue.comment")
|
"gitea.issue.comment")
|
||||||
return blocked
|
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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||||
@@ -6687,26 +6954,28 @@ def _role_kind(allowed, forbidden) -> str:
|
|||||||
"""Classify the active profile from its normalized operations.
|
"""Classify the active profile from its normalized operations.
|
||||||
|
|
||||||
'reconciler' can close already-landed PRs without review/author powers;
|
'reconciler' can close already-landed PRs without review/author powers;
|
||||||
'author' can create PRs / push branches; 'reviewer' can approve/merge;
|
'author' can create PRs / push branches; 'reviewer' can approve;
|
||||||
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
'merger' can merge PRs;
|
||||||
review/author powers; 'mixed' can do both (a config smell — the
|
'mixed' can do multiple (a config smell); 'limited' can do neither.
|
||||||
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
|
||||||
neither.
|
|
||||||
"""
|
"""
|
||||||
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
||||||
return "reconciler"
|
return "reconciler"
|
||||||
def can(op):
|
def can(op):
|
||||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||||
review = can("gitea.pr.approve") or can("gitea.pr.merge")
|
can_merge = can("gitea.pr.merge")
|
||||||
|
can_approve = can("gitea.pr.approve")
|
||||||
author = can("gitea.pr.create") or can("gitea.branch.push")
|
author = can("gitea.pr.create") or can("gitea.branch.push")
|
||||||
reconciler = (
|
reconciler = (
|
||||||
can("gitea.pr.close")
|
can("gitea.pr.close")
|
||||||
and not review
|
and not can_approve
|
||||||
|
and not can_merge
|
||||||
and not author
|
and not author
|
||||||
)
|
)
|
||||||
if review and author:
|
if (can_approve or can_merge) and author:
|
||||||
return "mixed"
|
return "mixed"
|
||||||
if review:
|
if can_merge:
|
||||||
|
return "merger"
|
||||||
|
if can_approve:
|
||||||
return "reviewer"
|
return "reviewer"
|
||||||
if reconciler:
|
if reconciler:
|
||||||
return "reconciler"
|
return "reconciler"
|
||||||
@@ -7224,12 +7493,20 @@ def mcp_get_control_plane_guide(
|
|||||||
"gitea.issue.comment (separate from gitea.pr.comment).")
|
"gitea.issue.comment (separate from gitea.pr.comment).")
|
||||||
elif role == "reviewer":
|
elif role == "reviewer":
|
||||||
guidance.append(
|
guidance.append(
|
||||||
"Reviewer profile: review/approve/merge may proceed ONLY after "
|
"Reviewer profile: review/approve may proceed ONLY after "
|
||||||
"gitea_check_pr_eligibility passes and the PR head SHA is "
|
"gitea_check_pr_eligibility passes and the PR head SHA is "
|
||||||
"pinned (expected_head_sha); the PR author must be a different "
|
"pinned (expected_head_sha); the PR author must be a different "
|
||||||
"user, and merging additionally requires explicit operator "
|
"user. Reviewer profiles cannot merge PRs. "
|
||||||
"authorization plus the 'MERGE PR <n>' confirmation. "
|
"PR comments do not imply issue comments. "
|
||||||
"PR comments do not imply issue comments.")
|
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
||||||
|
elif role == "merger":
|
||||||
|
guidance.append(
|
||||||
|
"Merger profile: merge may proceed ONLY after formal approval at "
|
||||||
|
"current head, gitea_check_pr_eligibility passes, and the PR head "
|
||||||
|
"SHA is pinned (expected_head_sha); the PR author must be a different "
|
||||||
|
"user, and merging requires explicit operator authorization plus the "
|
||||||
|
"'MERGE PR <n>' confirmation. "
|
||||||
|
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
||||||
elif role == "mixed":
|
elif role == "mixed":
|
||||||
guidance.append(
|
guidance.append(
|
||||||
"WARNING: this profile allows both authoring and "
|
"WARNING: this profile allows both authoring and "
|
||||||
@@ -8243,6 +8520,34 @@ def gitea_audit_config() -> dict:
|
|||||||
return report
|
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(
|
def _post_structured_issue_comment(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
@@ -8252,8 +8557,18 @@ def _post_structured_issue_comment(
|
|||||||
org: str | None,
|
org: str | None,
|
||||||
repo: str | None,
|
repo: str | None,
|
||||||
audit_op: str = "create_issue_comment",
|
audit_op: str = "create_issue_comment",
|
||||||
|
comment_context: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Post an issue-thread comment after permission gates already passed."""
|
"""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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||||
@@ -8318,15 +8633,8 @@ def gitea_mark_issue(
|
|||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
|
|
||||||
# Find the status:in-progress label id
|
label_ids_by_name = _repo_label_id_map(base, auth)
|
||||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
if "status:in-progress" not in label_ids_by_name:
|
||||||
label_id = None
|
|
||||||
for lb in labels:
|
|
||||||
if lb["name"] == "status:in-progress":
|
|
||||||
label_id = lb["id"]
|
|
||||||
break
|
|
||||||
|
|
||||||
if label_id is None:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Label 'status:in-progress' not found. "
|
"Label 'status:in-progress' not found. "
|
||||||
"Run manage_labels.py to create it first."
|
"Run manage_labels.py to create it first."
|
||||||
@@ -8334,10 +8642,19 @@ def gitea_mark_issue(
|
|||||||
|
|
||||||
if action == "start":
|
if action == "start":
|
||||||
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number, request_metadata={
|
||||||
request_metadata={"op": "add", "label": "status:in-progress"}):
|
"op": "replace-status",
|
||||||
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
|
"label": "status:in-progress",
|
||||||
{"labels": [label_id]})
|
}):
|
||||||
|
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")
|
active_profile = (profile or get_profile().get("profile_name") or "unknown")
|
||||||
branch = (branch_name or "pending").strip() or "pending"
|
branch = (branch_name or "pending").strip() or "pending"
|
||||||
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
||||||
@@ -8362,8 +8679,10 @@ def gitea_mark_issue(
|
|||||||
"message": f"Issue #{issue_number} claimed.",
|
"message": f"Issue #{issue_number} claimed.",
|
||||||
"heartbeat_posted": heartbeat.get("success", False),
|
"heartbeat_posted": heartbeat.get("success", False),
|
||||||
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
||||||
|
"status_transition": transition,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
label_id = label_ids_by_name["status:in-progress"]
|
||||||
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number,
|
||||||
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
||||||
@@ -8479,6 +8798,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()
|
@mcp.tool()
|
||||||
def gitea_assess_conflict_fix_push(
|
def gitea_assess_conflict_fix_push(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -8746,6 +9099,7 @@ def gitea_create_label(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new label on a Gitea repository.
|
"""Create a new label on a Gitea repository.
|
||||||
|
|
||||||
@@ -8757,11 +9111,16 @@ def gitea_create_label(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
worktree_path: Optional branches worktree to verify before mutation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict containing the created label details.
|
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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
@@ -8787,6 +9146,7 @@ def gitea_set_issue_labels(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Replace all labels on a Gitea issue with a new list of label names.
|
"""Replace all labels on a Gitea issue with a new list of label names.
|
||||||
|
|
||||||
@@ -8797,6 +9157,7 @@ def gitea_set_issue_labels(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
worktree_path: Optional branches worktree to verify before mutation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list of all labels currently applied to the issue.
|
list of all labels currently applied to the issue.
|
||||||
@@ -8805,7 +9166,7 @@ def gitea_set_issue_labels(
|
|||||||
task_capability_map.required_permission("set_issue_labels"))
|
task_capability_map.required_permission("set_issue_labels"))
|
||||||
if blocked:
|
if blocked:
|
||||||
return 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)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
@@ -9349,6 +9710,11 @@ def gitea_capability_stop_terminal_report() -> dict:
|
|||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# #558: mark this process as the official MCP daemon before any tool
|
||||||
|
# dispatch so direct shell imports cannot reuse mutation/auth paths.
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||||
# Lock this session's launch profile into the environment so child CLI
|
# Lock this session's launch profile into the environment so child CLI
|
||||||
# processes (e.g. review_pr.py) can detect and refuse profile
|
# processes (e.g. review_pr.py) can detect and refuse profile
|
||||||
# side-channel overrides (#199).
|
# side-channel overrides (#199).
|
||||||
|
|||||||
@@ -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)
|
os.execv(venv_python, [venv_python] + sys.argv)
|
||||||
|
|
||||||
from gitea_auth import get_auth_header, api_request, repo_api_url
|
from gitea_auth import get_auth_header, api_request, repo_api_url
|
||||||
|
import issue_workflow_labels
|
||||||
|
|
||||||
HOST = "gitea.dadeschools.net"
|
HOST = "gitea.dadeschools.net"
|
||||||
ORG = "Contractor"
|
ORG = "Contractor"
|
||||||
@@ -35,8 +36,10 @@ LABELS = [
|
|||||||
{"name": "epic", "color": "8250df", "description": ""},
|
{"name": "epic", "color": "8250df", "description": ""},
|
||||||
{"name": "important", "color": "fbca04", "description": ""},
|
{"name": "important", "color": "fbca04", "description": ""},
|
||||||
{"name": "nice-to-have", "color": "0e8a16", "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)
|
# issue number -> label names to apply (one-off backfill)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Sanctioned MCP daemon guards for imports and credential access (#558).
|
||||||
|
|
||||||
|
Direct ``import gitea_mcp_server`` / ``import gitea_auth`` from a shell, plus
|
||||||
|
raw keychain dumps, bypass preflight purity and role gates. Mutation helpers
|
||||||
|
and keychain fallbacks therefore require an explicit sanctioned runtime.
|
||||||
|
|
||||||
|
Sanctioned contexts (any one):
|
||||||
|
- ``GITEA_MCP_SANCTIONED_DAEMON=1`` (set by the official MCP entrypoint)
|
||||||
|
- pytest (``PYTEST_CURRENT_TEST`` present)
|
||||||
|
- explicit operator opt-in ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` (tests/tools only)
|
||||||
|
|
||||||
|
Credential keychain fill additionally allows:
|
||||||
|
- ``GITEA_ALLOW_KEYCHAIN_CLI=1`` for operator-only non-MCP scripts that must
|
||||||
|
use git-credential (never the default for LLM shells).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
||||||
|
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
||||||
|
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
||||||
|
|
||||||
|
|
||||||
|
class UnsanctionedRuntimeError(RuntimeError):
|
||||||
|
"""Raised when mutation/credential code runs outside a sanctioned MCP daemon."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_pytest_runtime() -> bool:
|
||||||
|
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def is_sanctioned_mcp_daemon() -> bool:
|
||||||
|
if (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||||
|
return True
|
||||||
|
if (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||||
|
return True
|
||||||
|
if is_pytest_runtime():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mark_sanctioned_daemon() -> None:
|
||||||
|
"""Call from the official MCP server entrypoint before serving tools."""
|
||||||
|
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||||
|
|
||||||
|
|
||||||
|
def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None:
|
||||||
|
"""Fail closed when server mutation code is used outside the MCP daemon."""
|
||||||
|
if is_sanctioned_mcp_daemon():
|
||||||
|
return
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
f"Unsanctioned runtime blocked {context} (#558). "
|
||||||
|
"Do not import gitea_mcp_server / call mutation helpers from a raw "
|
||||||
|
"shell or ad-hoc script. Use the official MCP daemon entrypoint "
|
||||||
|
f"(sets {SANCTIONED_DAEMON_ENV}=1), or run under pytest. "
|
||||||
|
f"Operator-only override: {ALLOW_DIRECT_IMPORT_ENV}=1 (not for LLM sessions)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_keychain_access_allowed() -> None:
|
||||||
|
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
||||||
|
if is_sanctioned_mcp_daemon():
|
||||||
|
return
|
||||||
|
if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||||
|
return
|
||||||
|
raise UnsanctionedRuntimeError(
|
||||||
|
"Unsanctioned keychain/credential fill blocked (#558). "
|
||||||
|
"Token extraction via git-credential is only allowed inside the "
|
||||||
|
f"official MCP daemon ({SANCTIONED_DAEMON_ENV}=1), pytest, or with "
|
||||||
|
f"explicit operator opt-in {ALLOW_KEYCHAIN_CLI_ENV}=1."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_status() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sanctioned_daemon": is_sanctioned_mcp_daemon(),
|
||||||
|
"pytest": is_pytest_runtime(),
|
||||||
|
"sanctioned_env": SANCTIONED_DAEMON_ENV,
|
||||||
|
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
||||||
|
"allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV,
|
||||||
|
}
|
||||||
@@ -37,6 +37,17 @@ def check_conflict_markers():
|
|||||||
|
|
||||||
check_conflict_markers()
|
check_conflict_markers()
|
||||||
|
|
||||||
|
# #558: official entrypoint marks the process as the sanctioned MCP daemon
|
||||||
|
# before loading mutation modules (blocks raw shell import bypasses).
|
||||||
|
try:
|
||||||
|
import mcp_daemon_guard
|
||||||
|
|
||||||
|
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||||
|
except Exception:
|
||||||
|
# Guard import failures must not hide conflict-marker infra_stop above;
|
||||||
|
# gitea_mcp_server main also marks sanctioned when run as __main__.
|
||||||
|
pass
|
||||||
|
|
||||||
# Execute the actual server logic via exec in this namespace.
|
# Execute the actual server logic via exec in this namespace.
|
||||||
impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py")
|
impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+191
-29
@@ -17,6 +17,7 @@ import issue_lock_store
|
|||||||
|
|
||||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||||
|
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
|
||||||
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
||||||
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
||||||
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
||||||
@@ -41,6 +42,59 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
|||||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_issue_marker(*values: str | None) -> int | None:
|
||||||
|
"""Return the first issue marker found in branch/path-like values."""
|
||||||
|
for value in values:
|
||||||
|
match = ISSUE_MARKER_RE.search(value or "")
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||||
|
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
current: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if current:
|
||||||
|
entries.append(current)
|
||||||
|
current = None
|
||||||
|
|
||||||
|
for raw_line in (res.stdout or "").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
flush()
|
||||||
|
current = {"path": line[9:].strip()}
|
||||||
|
elif current is not None and line.startswith("HEAD "):
|
||||||
|
current["head_sha"] = line[5:].strip()
|
||||||
|
elif current is not None and line.startswith("branch "):
|
||||||
|
ref = line[7:].strip()
|
||||||
|
current["branch_ref"] = ref
|
||||||
|
current["branch"] = (
|
||||||
|
ref[len("refs/heads/"):]
|
||||||
|
if ref.startswith("refs/heads/")
|
||||||
|
else ref
|
||||||
|
)
|
||||||
|
elif current is not None and line == "detached":
|
||||||
|
current["detached"] = True
|
||||||
|
flush()
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||||
"""Parse ``git worktree list --porcelain`` into structured records."""
|
"""Parse ``git worktree list --porcelain`` into structured records."""
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
@@ -82,14 +136,115 @@ def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
|||||||
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
||||||
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
||||||
mapping: dict[str, str] = {}
|
mapping: dict[str, str] = {}
|
||||||
for record in _git_worktree_porcelain(project_root):
|
for entry in list_local_worktrees(project_root):
|
||||||
branch_name = record.get("branch")
|
branch = entry.get("branch")
|
||||||
path = record.get("worktree")
|
path = entry.get("path")
|
||||||
if branch_name and path:
|
if branch and path:
|
||||||
mapping[str(branch_name)] = str(path)
|
mapping[str(branch)] = str(path)
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||||
|
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||||
|
real_path = os.path.realpath(path)
|
||||||
|
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||||
|
|
||||||
|
|
||||||
|
def _head_is_safe_for_cleanup(
|
||||||
|
*,
|
||||||
|
project_root: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
pr_head_sha: str | None,
|
||||||
|
target_ref: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if not candidate_head:
|
||||||
|
return False
|
||||||
|
if pr_head_sha and candidate_head == pr_head_sha:
|
||||||
|
return True
|
||||||
|
if target_ref:
|
||||||
|
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cleanup_worktree_state(
|
||||||
|
*,
|
||||||
|
project_root: str,
|
||||||
|
head_branch: str,
|
||||||
|
issue_number: int | None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
target_ref: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||||
|
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||||
|
state = read_local_worktree_state(expected_path)
|
||||||
|
state["worktree_path"] = expected_path
|
||||||
|
state["expected_worktree_path"] = expected_path
|
||||||
|
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||||
|
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||||
|
state["candidate_paths"] = []
|
||||||
|
state["alias_block_reasons"] = []
|
||||||
|
if state.get("exists"):
|
||||||
|
return state
|
||||||
|
|
||||||
|
branch_issue = extract_issue_marker(head_branch)
|
||||||
|
wanted_issue = issue_number or branch_issue
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
unsafe_matches: list[str] = []
|
||||||
|
|
||||||
|
for entry in list_local_worktrees(project_root):
|
||||||
|
path = entry.get("path") or ""
|
||||||
|
if not path or not _is_under_branches(project_root, path):
|
||||||
|
continue
|
||||||
|
branch = entry.get("branch") or ""
|
||||||
|
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||||
|
entry_issue = extract_issue_marker(branch) or path_issue
|
||||||
|
branch_match = branch == head_branch
|
||||||
|
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||||
|
if not (branch_match or issue_match):
|
||||||
|
continue
|
||||||
|
safe_head = _head_is_safe_for_cleanup(
|
||||||
|
project_root=project_root,
|
||||||
|
candidate_head=entry.get("head_sha"),
|
||||||
|
pr_head_sha=pr_head_sha,
|
||||||
|
target_ref=target_ref,
|
||||||
|
)
|
||||||
|
if not safe_head and branch_match and not pr_head_sha and not target_ref:
|
||||||
|
safe_head = True
|
||||||
|
if not safe_head:
|
||||||
|
unsafe_matches.append(path)
|
||||||
|
continue
|
||||||
|
candidates.append(entry)
|
||||||
|
|
||||||
|
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||||
|
if len(candidates) == 1:
|
||||||
|
path = candidates[0]["path"]
|
||||||
|
alias_state = read_local_worktree_state(path)
|
||||||
|
alias_state["worktree_path"] = path
|
||||||
|
alias_state["expected_worktree_path"] = expected_path
|
||||||
|
alias_state["discovered_worktree_path"] = path
|
||||||
|
alias_state["match_type"] = (
|
||||||
|
"branch" if candidates[0].get("branch") == head_branch else "alias"
|
||||||
|
)
|
||||||
|
alias_state["candidate_paths"] = [path]
|
||||||
|
alias_state["alias_block_reasons"] = []
|
||||||
|
alias_state["alias_verified"] = True
|
||||||
|
return alias_state
|
||||||
|
if len(candidates) > 1:
|
||||||
|
state["alias_block_reasons"].append(
|
||||||
|
"ambiguous local worktree aliases: " + ", ".join(
|
||||||
|
sorted(str(entry.get("path")) for entry in candidates)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state["match_type"] = "ambiguous_alias"
|
||||||
|
elif unsafe_matches:
|
||||||
|
state["alias_block_reasons"].append(
|
||||||
|
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||||
|
+ ", ".join(sorted(unsafe_matches))
|
||||||
|
)
|
||||||
|
state["match_type"] = "unsafe_alias"
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
||||||
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
||||||
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
||||||
@@ -323,6 +478,7 @@ def assess_local_worktree_cleanup(
|
|||||||
exists = bool(worktree_state.get("exists"))
|
exists = bool(worktree_state.get("exists"))
|
||||||
if not merged:
|
if not merged:
|
||||||
reasons.append("PR is not merged")
|
reasons.append("PR is not merged")
|
||||||
|
reasons.extend(worktree_state.get("alias_block_reasons") or [])
|
||||||
if not exists:
|
if not exists:
|
||||||
reasons.append("local worktree not present")
|
reasons.append("local worktree not present")
|
||||||
if exists and worktree_state.get("clean") is False:
|
if exists and worktree_state.get("clean") is False:
|
||||||
@@ -333,7 +489,11 @@ def assess_local_worktree_cleanup(
|
|||||||
)
|
)
|
||||||
if exists:
|
if exists:
|
||||||
current_branch = worktree_state.get("current_branch")
|
current_branch = worktree_state.get("current_branch")
|
||||||
if current_branch and current_branch != head_branch:
|
if (
|
||||||
|
current_branch
|
||||||
|
and current_branch != head_branch
|
||||||
|
and not worktree_state.get("alias_verified")
|
||||||
|
):
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
||||||
)
|
)
|
||||||
@@ -345,6 +505,10 @@ def assess_local_worktree_cleanup(
|
|||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"head_branch": head_branch,
|
"head_branch": head_branch,
|
||||||
"worktree_path": worktree_state.get("worktree_path"),
|
"worktree_path": worktree_state.get("worktree_path"),
|
||||||
|
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
|
||||||
|
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
|
||||||
|
"match_type": worktree_state.get("match_type"),
|
||||||
|
"candidate_paths": worktree_state.get("candidate_paths") or [],
|
||||||
"worktree_exists": exists,
|
"worktree_exists": exists,
|
||||||
"worktree_clean": worktree_state.get("clean"),
|
"worktree_clean": worktree_state.get("clean"),
|
||||||
"safe_to_remove_worktree": safe,
|
"safe_to_remove_worktree": safe,
|
||||||
@@ -363,7 +527,7 @@ def build_pr_cleanup_entry(
|
|||||||
delete_capability_allowed: bool,
|
delete_capability_allowed: bool,
|
||||||
issue_lock_path: str | None = None,
|
issue_lock_path: str | None = None,
|
||||||
protected_branches: frozenset[str] | None = None,
|
protected_branches: frozenset[str] | None = None,
|
||||||
worktree_map: dict[str, str] | None = None,
|
target_ref: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
pr_number = int(pr["number"])
|
pr_number = int(pr["number"])
|
||||||
head_branch = pr.get("head") or ""
|
head_branch = pr.get("head") or ""
|
||||||
@@ -372,18 +536,16 @@ def build_pr_cleanup_entry(
|
|||||||
title = pr.get("title") or ""
|
title = pr.get("title") or ""
|
||||||
body = pr.get("body") or ""
|
body = pr.get("body") or ""
|
||||||
merged = bool(pr.get("merged_at"))
|
merged = bool(pr.get("merged_at"))
|
||||||
|
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||||
discovered_path = worktree_map.get(head_branch) if worktree_map else None
|
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||||
derived_path = resolve_worktree_path(project_root, head_branch)
|
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||||
if discovered_path:
|
worktree_state = resolve_cleanup_worktree_state(
|
||||||
worktree_path = discovered_path
|
project_root=project_root,
|
||||||
match_type = "branch"
|
head_branch=head_branch,
|
||||||
else:
|
issue_number=issue_number,
|
||||||
worktree_path = derived_path
|
pr_head_sha=pr_head_sha,
|
||||||
match_type = "path"
|
target_ref=target_ref,
|
||||||
|
)
|
||||||
worktree_state = read_local_worktree_state(worktree_path)
|
|
||||||
worktree_state["worktree_path"] = worktree_path
|
|
||||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||||
|
|
||||||
remote = assess_remote_branch_cleanup(
|
remote = assess_remote_branch_cleanup(
|
||||||
@@ -404,11 +566,9 @@ def build_pr_cleanup_entry(
|
|||||||
worktree_state=worktree_state,
|
worktree_state=worktree_state,
|
||||||
active_lock=active_lock,
|
active_lock=active_lock,
|
||||||
)
|
)
|
||||||
local["match_type"] = match_type
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"issue_number": extract_linked_issue(title, body),
|
"issue_number": issue_number,
|
||||||
"title": title,
|
"title": title,
|
||||||
"head_branch": head_branch,
|
"head_branch": head_branch,
|
||||||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||||||
@@ -429,11 +589,11 @@ def build_reconciliation_report(
|
|||||||
delete_capability_allowed: bool,
|
delete_capability_allowed: bool,
|
||||||
issue_lock_path: str | None = None,
|
issue_lock_path: str | None = None,
|
||||||
protected_branches: frozenset[str] | None = None,
|
protected_branches: frozenset[str] | None = None,
|
||||||
|
target_ref: str | None = None,
|
||||||
active_reviewer_leases: dict[int, bool] | None = None,
|
active_reviewer_leases: dict[int, bool] | None = None,
|
||||||
pr_states: dict[int, dict[str, Any]] | None = None,
|
pr_states: dict[int, dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
open_heads = collect_open_pr_heads(open_prs)
|
open_heads = collect_open_pr_heads(open_prs)
|
||||||
worktree_map = discover_local_worktrees(project_root)
|
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
for pr in closed_prs or []:
|
for pr in closed_prs or []:
|
||||||
if not pr.get("merged_at"):
|
if not pr.get("merged_at"):
|
||||||
@@ -452,7 +612,7 @@ def build_reconciliation_report(
|
|||||||
delete_capability_allowed=delete_capability_allowed,
|
delete_capability_allowed=delete_capability_allowed,
|
||||||
issue_lock_path=issue_lock_path,
|
issue_lock_path=issue_lock_path,
|
||||||
protected_branches=protected_branches,
|
protected_branches=protected_branches,
|
||||||
worktree_map=worktree_map,
|
target_ref=target_ref,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -505,10 +665,12 @@ def build_reconciliation_report(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
def remove_local_worktree(
|
||||||
worktree_map = discover_local_worktrees(project_root)
|
project_root: str,
|
||||||
discovered_path = worktree_map.get(branch)
|
branch: str,
|
||||||
worktree_path = discovered_path or resolve_worktree_path(project_root, branch)
|
worktree_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||||
if not os.path.isdir(worktree_path):
|
if not os.path.isdir(worktree_path):
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -593,4 +755,4 @@ def remove_reviewer_scratch_worktree(
|
|||||||
"message": f"removed reviewer scratch worktree {real}",
|
"message": f"removed reviewer scratch worktree {real}",
|
||||||
"worktree_path": real,
|
"worktree_path": real,
|
||||||
"author_worktree_cleanup": False,
|
"author_worktree_cleanup": False,
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -2243,6 +2243,18 @@ HANDOFF_ROLE_FIELDS = {
|
|||||||
("Linked issue status", ("linked issue status", "linked issue")),
|
("Linked issue status", ("linked issue status", "linked issue")),
|
||||||
("Cleanup status", ("cleanup status", "cleanup")),
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
|
"merger": (
|
||||||
|
("Selected PR", ("selected pr",)),
|
||||||
|
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||||
|
("Active profile", ("active profile",)),
|
||||||
|
("Role kind", ("role kind",)),
|
||||||
|
("Merge capability source", ("merge capability source",)),
|
||||||
|
("Explicit operator authorization", ("explicit operator authorization", "operator authorization", "authorization")),
|
||||||
|
("Expected head SHA", ("expected head sha", "expected head")),
|
||||||
|
("Approval at current head", ("approval at current head", "approval")),
|
||||||
|
("Merge result", ("merge result",)),
|
||||||
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
"author": (
|
"author": (
|
||||||
("Selected issue", ("selected issue",)),
|
("Selected issue", ("selected issue",)),
|
||||||
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
||||||
@@ -2417,8 +2429,8 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
field for field in required
|
field for field in required
|
||||||
if field[0] not in ("Workspace mutations", "Mutations")
|
if field[0] not in ("Workspace mutations", "Mutations")
|
||||||
]
|
]
|
||||||
if role == "review":
|
if role in ("review", "merger"):
|
||||||
# Issue #320: reviewer handoffs use the precise mutation categories
|
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
||||||
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
||||||
# "Workspace mutations" field, which is rejected below.
|
# "Workspace mutations" field, which is rejected below.
|
||||||
required = [
|
required = [
|
||||||
@@ -2431,7 +2443,7 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
"downgraded": True,
|
"downgraded": True,
|
||||||
"missing_fields": [],
|
"missing_fields": [],
|
||||||
"reasons": [
|
"reasons": [
|
||||||
"review handoff must not include legacy "
|
"review or merger handoff must not include legacy "
|
||||||
"'Workspace mutations' field; report the precise "
|
"'Workspace mutations' field; report the precise "
|
||||||
"mutation categories instead (issue #320)"
|
"mutation categories instead (issue #320)"
|
||||||
],
|
],
|
||||||
@@ -5540,6 +5552,15 @@ def assess_already_landed_classification_report(report_text, **kwargs):
|
|||||||
return _assess(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):
|
def assess_prior_blocker_skip_proof(report_text, **kwargs):
|
||||||
"""#318: require live blocker proof before skipping earlier open PRs."""
|
"""#318: require live blocker proof before skipping earlier open PRs."""
|
||||||
from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess
|
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,
|
||||||
|
}
|
||||||
@@ -167,6 +167,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
|||||||
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
|
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
|
||||||
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
|
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
|
||||||
- [`release-tag.md`](templates/release-tag.md)
|
- [`release-tag.md`](templates/release-tag.md)
|
||||||
|
- [`canonical-state-comments.md`](templates/canonical-state-comments.md)
|
||||||
|
|
||||||
## Adapting to a project
|
## Adapting to a project
|
||||||
|
|
||||||
@@ -195,4 +196,3 @@ If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
|
|||||||
`docs/bootstrap-review-path.md`
|
`docs/bootstrap-review-path.md`
|
||||||
|
|
||||||
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Blocked review submission handoff
|
||||||
|
|
||||||
|
Use when `gitea_submit_pr_review` or the terminal mutation gate blocks review
|
||||||
|
submission.
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Blocked Review Submission Handoff
|
||||||
|
|
||||||
|
- Tool called: gitea_submit_pr_review
|
||||||
|
- Mutation attempted: yes
|
||||||
|
- Mutation rejected: yes
|
||||||
|
- No server-side state changed: confirmed
|
||||||
|
- Proof/source: <paste exact tool error or gate reason>
|
||||||
|
- Prior terminal mutation that consumed budget: <exact prior mutation or none>
|
||||||
|
- Review decision (local intent): <approve | request_changes | comment only>
|
||||||
|
- Server-side final decision marked: <yes with tool proof | no>
|
||||||
|
- Gitea review submitted: no
|
||||||
|
- Gitea review blocked because: <exact gate/error>
|
||||||
|
- Review mutations: none (submission blocked)
|
||||||
|
- MCP/Gitea mutations: <only mutations that actually occurred>
|
||||||
|
- Safe next action: rewrite handoff with consistent ledger before posting
|
||||||
|
```
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Template: canonical state comments
|
||||||
|
|
||||||
|
Use these only for workflow-changing comments. Casual discussion does not need
|
||||||
|
the full template.
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical Issue State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | merger | reconciler | user>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
WHAT_HAPPENED:
|
||||||
|
<latest meaningful event>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<decision rationale>
|
||||||
|
|
||||||
|
RELATED_DISCUSSION:
|
||||||
|
<link/reference or none>
|
||||||
|
|
||||||
|
RELATED_PRS:
|
||||||
|
- #...
|
||||||
|
|
||||||
|
BRANCH:
|
||||||
|
<branch or none>
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
<40-character SHA or none>
|
||||||
|
|
||||||
|
VALIDATION:
|
||||||
|
<tests/proofs or none>
|
||||||
|
|
||||||
|
BLOCKERS:
|
||||||
|
<blocker and unblock condition, or none>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical PR State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | merger | reconciler | user>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
WHAT_HAPPENED:
|
||||||
|
<latest meaningful event>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<decision rationale>
|
||||||
|
|
||||||
|
ISSUE:
|
||||||
|
#...
|
||||||
|
|
||||||
|
BASE:
|
||||||
|
<branch>
|
||||||
|
|
||||||
|
HEAD:
|
||||||
|
<branch>
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
<40-character SHA>
|
||||||
|
|
||||||
|
REVIEW_STATUS:
|
||||||
|
<none | approved | changes-requested | stale | contaminated>
|
||||||
|
|
||||||
|
VALIDATION:
|
||||||
|
<tests/proofs>
|
||||||
|
|
||||||
|
BLOCKERS:
|
||||||
|
<blockers or none>
|
||||||
|
|
||||||
|
SUPERSEDES:
|
||||||
|
<PRs or none>
|
||||||
|
|
||||||
|
SUPERSEDED_BY:
|
||||||
|
<PR or none>
|
||||||
|
|
||||||
|
MERGE_READY:
|
||||||
|
<yes/no and why>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discussion
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical Discussion Summary
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<needs-more-discussion | ready-for-issues | issues-created | closed>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | user>
|
||||||
|
|
||||||
|
DECISION:
|
||||||
|
<what was decided>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<reasoning and tradeoffs>
|
||||||
|
|
||||||
|
SUBSTANTIVE_COMMENTS:
|
||||||
|
<count and summary>
|
||||||
|
|
||||||
|
ISSUES_TO_CREATE_OR_CREATED:
|
||||||
|
- #...
|
||||||
|
|
||||||
|
DEPENDENCY_ORDER:
|
||||||
|
<order or none>
|
||||||
|
|
||||||
|
NON_GOALS:
|
||||||
|
<non-goals>
|
||||||
|
|
||||||
|
OPEN_QUESTIONS:
|
||||||
|
<questions or none>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Template: Canonical Thread Handoff (CTH)
|
||||||
|
|
||||||
|
Copy, fill the fields, and post as an issue or PR comment.
|
||||||
|
|
||||||
|
```text
|
||||||
|
## CTH: <Type>
|
||||||
|
|
||||||
|
Status: <current workflow state>
|
||||||
|
Next owner: <author|reviewer|merger|controller|operator>
|
||||||
|
Current blocker: <none or exact blocker>
|
||||||
|
Decision: <what was decided this session>
|
||||||
|
Proof: <commands, SHAs, gate outputs, or explicit pending proof>
|
||||||
|
Next action: <one concrete step for the next actor>
|
||||||
|
Ready-to-paste prompt: <full prompt the next session should paste>
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `<Type>` values:
|
||||||
|
|
||||||
|
- State Handoff
|
||||||
|
- Controller Decision
|
||||||
|
- Author Handoff
|
||||||
|
- Reviewer Handoff
|
||||||
|
- Merger Handoff
|
||||||
|
- Supersession Notice
|
||||||
|
- Blocker
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Find the latest CTH comment in the thread before starting work.
|
||||||
|
- Treat the latest valid CTH as the current source of truth.
|
||||||
|
- Post a new CTH when you finish, block, skip, supersede, approve, request
|
||||||
|
changes, or hand off.
|
||||||
|
- A CTH summarizes workflow state; formal Gitea review verdicts remain
|
||||||
|
authoritative for merge gates.
|
||||||
|
|
||||||
|
Full protocol: `docs/canonical-thread-handoff.md`
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Template: merge a PR (eligible reviewer only)
|
# Template: merge a PR (eligible merger only)
|
||||||
|
|
||||||
Copy, fill the `<...>` fields, and paste as the task prompt.
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
@@ -10,12 +10,16 @@ Load the canonical workflow first:
|
|||||||
Final report schema: `schemas/review-merge-final-report.md`.
|
Final report schema: `schemas/review-merge-final-report.md`.
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
|
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||||
|
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
||||||
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
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
|
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
||||||
and is blocked when it disagrees with the local git remote URL.
|
and is blocked when it disagrees with the local git remote URL.
|
||||||
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
|
- Only an eligible, NON-author merger merges. If authenticated user == PR
|
||||||
author → STOP.
|
author → STOP.
|
||||||
|
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||||
- No force-merge, no bypassing branch protections.
|
- No force-merge, no bypassing branch protections.
|
||||||
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||||
@@ -63,10 +67,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
|
|||||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||||
|
|
||||||
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
|
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
|
||||||
§K (long form — a merge is always high-risk), including the review/merge role
|
§K (long form — a merge is always high-risk), including the merger role
|
||||||
fields (Selected PR, Reviewer eligibility, Pinned reviewed head, Review
|
fields (Selected PR, Merger eligibility, Pinned reviewed head, Review
|
||||||
decision, Merge result, Linked issue status, Cleanup status) plus: merge
|
decision, Merge result, Linked issue status, Cleanup status) plus: merge
|
||||||
commit, PR metadata state/merged flag/hash, remote master hash, and the
|
commit, PR metadata state/merged flag/hash, remote master hash, and the
|
||||||
post-merge verification method used & verification results. Reports missing
|
post-merge verification method used & verification results. Reports missing
|
||||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ Load the canonical workflow first:
|
|||||||
Final report schema: `schemas/review-merge-final-report.md`.
|
Final report schema: `schemas/review-merge-final-report.md`.
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
|
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||||
|
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
||||||
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
|
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
|
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
|
||||||
@@ -110,8 +113,9 @@ Steps:
|
|||||||
- base branch unchanged
|
- base branch unchanged
|
||||||
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
|
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
|
||||||
If anything moved → STOP, re-pin, re-validate before any verdict.
|
If anything moved → STOP, re-pin, re-validate before any verdict.
|
||||||
10. Post the review verdict: approve only if scope is clean and checks pass;
|
10. Post the review verdict: approve only if scope is clean and checks pass;
|
||||||
otherwise request changes with specifics. Never merge from this review step.
|
otherwise request changes with specifics. Never merge from this review step.
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||||
|
|
||||||
Review Metadata:
|
Review Metadata:
|
||||||
@@ -128,6 +132,8 @@ Pinned reviewed head, Review decision, Merge result, Linked issue status,
|
|||||||
Cleanup status. If you could not merge, name the exact gate. Reports missing
|
Cleanup status. If you could not merge, name the exact gate. Reports missing
|
||||||
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
the handoff are downgraded (review_proofs.assess_controller_handoff).
|
||||||
|
|
||||||
|
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||||
|
|
||||||
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
|
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
|
||||||
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
||||||
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report
|
|||||||
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
|
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
|
- Find the latest CTH comment on the issue/PR thread before starting work.
|
||||||
|
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- No repo changes without a tracking issue. If none exists, create one first;
|
- No repo changes without a tracking issue. If none exists, create one first;
|
||||||
if it can't be created, stop.
|
if it can't be created, stop.
|
||||||
- Work only in an isolated branch worktree under branches/. The main checkout
|
- Work only in an isolated branch worktree under branches/. The main checkout
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ workflow rules exactly.
|
|||||||
|
|
||||||
## 0. Load the canonical workflow first
|
## 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.
|
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:
|
If available, load it first and report:
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ This is an author/coder workflow. It is not a reviewer workflow.
|
|||||||
|
|
||||||
## 0. Load the canonical workflow first
|
## 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.
|
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:
|
If available, load it first and report:
|
||||||
@@ -615,10 +621,39 @@ After push, report:
|
|||||||
|
|
||||||
If push fails, stop and produce a recovery handoff.
|
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:
|
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.
|
1. Call `gitea_acquire_conflict_fix_lease` before any push.
|
||||||
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
|
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
|
||||||
* branch head before push
|
* branch head before push
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
"create_label": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"create_branch": {
|
"create_branch": {
|
||||||
"permission": "gitea.branch.create",
|
"permission": "gitea.branch.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -66,7 +70,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"merge_pr": {
|
"merge_pr": {
|
||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "reviewer",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"adopt_merger_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
@@ -167,6 +171,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
|||||||
"gitea_create_issue_comment": "comment_issue",
|
"gitea_create_issue_comment": "comment_issue",
|
||||||
"gitea_mark_issue": "mark_issue",
|
"gitea_mark_issue": "mark_issue",
|
||||||
"gitea_set_issue_labels": "set_issue_labels",
|
"gitea_set_issue_labels": "set_issue_labels",
|
||||||
|
"gitea_create_label": "create_label",
|
||||||
"gitea_commit_files": "commit_files",
|
"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()
|
||||||
@@ -435,6 +435,40 @@ class ValidateConfigTests(unittest.TestCase):
|
|||||||
with open(example, encoding="utf-8") as fh:
|
with open(example, encoding="utf-8") as fh:
|
||||||
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
|
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
|
||||||
|
|
||||||
|
def test_repo_example_file_reviewer_vs_merger_boundaries(self):
|
||||||
|
example_path = __import__("pathlib").Path(__file__).resolve().parent.parent \
|
||||||
|
/ "gitea-mcp.v2-contexts.example.json"
|
||||||
|
with open(example_path, encoding="utf-8") as fh:
|
||||||
|
cfg = json.load(fh)
|
||||||
|
|
||||||
|
# 1. Config includes separate reviewer and merger examples
|
||||||
|
self.assertIn("example-reviewer", cfg["profiles"])
|
||||||
|
self.assertIn("example-merger", cfg["profiles"])
|
||||||
|
|
||||||
|
reviewer = cfg["profiles"]["example-reviewer"]
|
||||||
|
merger = cfg["profiles"]["example-merger"]
|
||||||
|
|
||||||
|
# 2. Example reviewer config does not include merge capability
|
||||||
|
reviewer_allowed = reviewer.get("allowed_operations") or []
|
||||||
|
reviewer_forbidden = reviewer.get("forbidden_operations") or []
|
||||||
|
|
||||||
|
self.assertNotIn("merge", reviewer_allowed)
|
||||||
|
self.assertNotIn("gitea.pr.merge", reviewer_allowed)
|
||||||
|
self.assertIn("merge", reviewer_forbidden)
|
||||||
|
|
||||||
|
# 3. Example reviewer can resolve review task, cannot resolve merge task
|
||||||
|
# 4. Example merger can resolve merge task
|
||||||
|
rev_review_ok, _ = gitea_config.check_operation("gitea.pr.review", reviewer_allowed, reviewer_forbidden)
|
||||||
|
rev_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", reviewer_allowed, reviewer_forbidden)
|
||||||
|
|
||||||
|
merg_allowed = merger.get("allowed_operations") or []
|
||||||
|
merg_forbidden = merger.get("forbidden_operations") or []
|
||||||
|
merg_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", merg_allowed, merg_forbidden)
|
||||||
|
|
||||||
|
self.assertTrue(rev_review_ok, "example-reviewer must allow review")
|
||||||
|
self.assertFalse(rev_merge_ok, "example-reviewer must not allow merge")
|
||||||
|
self.assertTrue(merg_merge_ok, "example-merger must allow merge")
|
||||||
|
|
||||||
def test_broken_contexts_config_reports_problems(self):
|
def test_broken_contexts_config_reports_problems(self):
|
||||||
data = contexts_config()
|
data = contexts_config()
|
||||||
data["profiles"]["prgs-author"]["context"] = "nope"
|
data["profiles"]["prgs-author"]["context"] = "nope"
|
||||||
|
|||||||
@@ -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["title"], "My Title")
|
||||||
self.assertEqual(payload["body"], "My Body")
|
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)
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
def test_url_construction(self, _cred):
|
def test_url_construction(self, _cred):
|
||||||
with patch("create_issue.api_request",
|
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):
|
class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
"""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):
|
class TestEntryPoint(unittest.TestCase):
|
||||||
def test_unknown_task_kind_blocks(self):
|
def test_unknown_task_kind_blocks(self):
|
||||||
result = assess_final_report_validator("report", "unknown_mode")
|
result = assess_final_report_validator("report", "unknown_mode")
|
||||||
@@ -594,8 +705,100 @@ class TestEntryPoint(unittest.TestCase):
|
|||||||
|
|
||||||
def test_supported_task_kinds_include_review_and_reconcile(self):
|
def test_supported_task_kinds_include_review_and_reconcile(self):
|
||||||
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
|
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
|
||||||
|
self.assertIn("merge_pr", FINAL_REPORT_TASK_KINDS)
|
||||||
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
|
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergePrRules(unittest.TestCase):
|
||||||
|
def test_clean_merger_report_passes(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: merge completed",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: merged PR #203",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
"- Active profile: prgs-merger",
|
||||||
|
"- Role kind: merger",
|
||||||
|
"- Merge capability source: profile",
|
||||||
|
"- Explicit operator authorization: MERGE PR 203",
|
||||||
|
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Approval at current head: yes",
|
||||||
|
"- Merge result: PR merged successfully",
|
||||||
|
"- Cleanup status: complete",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"merge_pr",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["grade"], "A")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
|
||||||
|
def test_missing_merger_fields_blocks(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: merge completed",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: merged PR #203",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
report,
|
||||||
|
"merge_pr",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["downgraded"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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")
|
result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs")
|
||||||
self.assertTrue(result.get("success"))
|
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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@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):
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
if method == "GET" and url.endswith("/labels?limit=100"):
|
if method == "GET" and "/issues/9" in url:
|
||||||
return [{"id": 10, "name": "status:in-progress"}]
|
return {"labels": []}
|
||||||
if method == "POST" and "/issues/9/labels" in url:
|
if method == "PUT" and "/issues/9/labels" in url:
|
||||||
return [{"name": "status:in-progress"}]
|
return [{"name": "status:in-progress"}]
|
||||||
if method == "POST" and "/issues/9/comments" in url:
|
if method == "POST" and "/issues/9/comments" in url:
|
||||||
return {"id": 501}
|
return {"id": 501}
|
||||||
@@ -209,4 +210,4 @@ class TestCreateIssueMissingPermissionReport(IssueWriteGateBase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
import manage_labels # noqa: E402
|
import manage_labels # noqa: E402
|
||||||
|
import issue_workflow_labels # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
|
||||||
@@ -129,6 +130,11 @@ class TestConstants(unittest.TestCase):
|
|||||||
names = [l["name"] for l in manage_labels.LABELS]
|
names = [l["name"] for l in manage_labels.LABELS]
|
||||||
self.assertIn("status:in-progress", names)
|
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):
|
def test_all_mapped_labels_are_defined(self):
|
||||||
defined = {l["name"] for l in manage_labels.LABELS}
|
defined = {l["name"] for l in manage_labels.LABELS}
|
||||||
for issue, names in manage_labels.MAPPING.items():
|
for issue, names in manage_labels.MAPPING.items():
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for sanctioned MCP daemon guards (#558)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_daemon_guard
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpDaemonGuard(unittest.TestCase):
|
||||||
|
def test_unsanctioned_blocks_mutation_runtime(self):
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in {
|
||||||
|
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
|
||||||
|
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
}}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
mcp_daemon_guard.assert_sanctioned_mutation_runtime("test")
|
||||||
|
|
||||||
|
def test_pytest_allows_runtime(self):
|
||||||
|
# Running under pytest already sets PYTEST_CURRENT_TEST.
|
||||||
|
mcp_daemon_guard.assert_sanctioned_mutation_runtime("pytest")
|
||||||
|
|
||||||
|
def test_mark_sanctioned_allows(self):
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"}
|
||||||
|
env[mcp_daemon_guard.SANCTIONED_DAEMON_ENV] = "1"
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
mcp_daemon_guard.assert_sanctioned_mutation_runtime("daemon")
|
||||||
|
|
||||||
|
def test_keychain_blocked_without_sanction(self):
|
||||||
|
env = {
|
||||||
|
k: v
|
||||||
|
for k, v in os.environ.items()
|
||||||
|
if k
|
||||||
|
not in {
|
||||||
|
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
|
||||||
|
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
||||||
|
mcp_daemon_guard.ALLOW_KEYCHAIN_CLI_ENV,
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||||
|
|
||||||
|
def test_get_auth_header_blocked_when_unsanctioned(self):
|
||||||
|
env = {
|
||||||
|
k: v
|
||||||
|
for k, v in os.environ.items()
|
||||||
|
if k
|
||||||
|
not in {
|
||||||
|
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
|
||||||
|
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
|
gitea_auth.get_auth_header("gitea.prgs.cc")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+168
-23
@@ -91,6 +91,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
|
|||||||
|
|
||||||
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
|
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
|
||||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
_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(
|
def _reviewer_lease_comment(
|
||||||
@@ -305,6 +315,22 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
self.assertIn("gitea.prgs.cc", url)
|
self.assertIn("gitea.prgs.cc", url)
|
||||||
self.assertIn("Scaled-Tech-Consulting", 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
|
# Create PR
|
||||||
@@ -315,13 +341,24 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
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",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@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())
|
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:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
@@ -334,22 +371,37 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["number"], 3)
|
self.assertEqual(result["number"], 3)
|
||||||
self.assertNotIn("url", result)
|
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["head"], "feat/x")
|
||||||
self.assertEqual(payload["base"], "main")
|
self.assertEqual(payload["base"], "main")
|
||||||
self.assertIn("Closes #123", payload["title"])
|
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(
|
@patch(
|
||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
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",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@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())
|
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:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
@@ -362,6 +414,38 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("pulls/3", result["url"])
|
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",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@@ -486,26 +570,33 @@ class TestViewIssue(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestMarkIssue(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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_start_adds_label(self, _auth, mock_api):
|
def test_start_adds_label(self, _auth, mock_api, _labels):
|
||||||
# labels, add label, post claim heartbeat comment
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
mock_api.side_effect = [
|
if method == "GET" and "/issues/5" in url:
|
||||||
[{"id": 10, "name": "status:in-progress"}],
|
return _issue_with_labels("type:feature", "status:ready")
|
||||||
[{"name": "status:in-progress"}],
|
if method == "PUT" and url.endswith("/issues/5/labels"):
|
||||||
{"id": 99},
|
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):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
result = gitea_mark_issue(issue_number=5, action="start")
|
result = gitea_mark_issue(issue_number=5, action="start")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("claimed", result["message"])
|
self.assertIn("claimed", result["message"])
|
||||||
self.assertTrue(result.get("heartbeat_posted"))
|
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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@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 = [
|
mock_api.side_effect = [
|
||||||
[{"id": 10, "name": "status:in-progress"}],
|
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
@@ -517,10 +608,10 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
gitea_mark_issue(issue_number=5, action="pause")
|
gitea_mark_issue(issue_number=5, action="pause")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_missing_label_raises(self, _auth, mock_api):
|
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
||||||
mock_api.return_value = [] # no labels exist
|
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_mark_issue(issue_number=5, action="start")
|
gitea_mark_issue(issue_number=5, action="start")
|
||||||
@@ -861,6 +952,19 @@ class TestMergePR(unittest.TestCase):
|
|||||||
|
|
||||||
# -- identity / profile / eligibility fail-closed -------------------------
|
# -- identity / profile / eligibility fail-closed -------------------------
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
||||||
|
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_merge_pr(
|
||||||
|
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_self_author_cannot_merge(self, _auth, mock_api):
|
def test_self_author_cannot_merge(self, _auth, mock_api):
|
||||||
@@ -902,14 +1006,13 @@ class TestMergePR(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
gitea_merge_pr(
|
||||||
self.assertFalse(r["performed"])
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertIn("profile is not allowed to merge", r["reasons"])
|
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
|
||||||
self._assert_no_merge_call(mock_api)
|
self._assert_no_merge_call(mock_api)
|
||||||
|
|
||||||
# -- PR state / mergeability ----------------------------------------------
|
# -- PR state / mergeability ----------------------------------------------
|
||||||
@@ -3102,6 +3205,36 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
mock_api.assert_not_called()
|
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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_missing_issue_error_is_redacted(self, _auth, mock_api):
|
def test_create_missing_issue_error_is_redacted(self, _auth, mock_api):
|
||||||
@@ -3765,13 +3898,24 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("lock provenance", str(ctx.exception).lower())
|
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.api_request")
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
return_value=(True, []))
|
return_value=(True, []))
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@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")
|
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(
|
_bind_test_lock(
|
||||||
issue_number=249,
|
issue_number=249,
|
||||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||||
@@ -3786,6 +3930,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree_path=scratch,
|
worktree_path=scratch,
|
||||||
)
|
)
|
||||||
self.assertEqual(res["number"], 250)
|
self.assertEqual(res["number"], 250)
|
||||||
|
self.assertTrue(res["issue_status_transition"]["performed"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ class TestMergedCleanupReport(unittest.TestCase):
|
|||||||
"branch refs/heads/feat/issue-11-x\n"
|
"branch refs/heads/feat/issue-11-x\n"
|
||||||
"HEAD cafebabe\n\n"
|
"HEAD cafebabe\n\n"
|
||||||
)
|
)
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
|
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
|
||||||
res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
|
res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
|
||||||
self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})
|
self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})
|
||||||
|
|
||||||
@@ -177,14 +177,23 @@ class TestMergedCleanupReport(unittest.TestCase):
|
|||||||
"branch refs/heads/feat/issue-11-x\n"
|
"branch refs/heads/feat/issue-11-x\n"
|
||||||
"HEAD cafebabe\n\n"
|
"HEAD cafebabe\n\n"
|
||||||
)
|
)
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
|
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
|
||||||
mock_state.return_value = {
|
mock_state.side_effect = [
|
||||||
"exists": True,
|
{
|
||||||
"clean": True,
|
"exists": False,
|
||||||
"current_branch": "feat/issue-11-x",
|
"clean": None,
|
||||||
"head_sha": "cafebabe",
|
"current_branch": None,
|
||||||
"dirty_files": [],
|
"head_sha": None,
|
||||||
}
|
"dirty_files": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exists": True,
|
||||||
|
"clean": True,
|
||||||
|
"current_branch": "feat/issue-11-x",
|
||||||
|
"head_sha": "cafebabe",
|
||||||
|
"dirty_files": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
report = mcr.build_reconciliation_report(
|
report = mcr.build_reconciliation_report(
|
||||||
project_root="/repo/Gitea-Tools",
|
project_root="/repo/Gitea-Tools",
|
||||||
@@ -209,29 +218,190 @@ class TestMergedCleanupReport(unittest.TestCase):
|
|||||||
self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
|
self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
|
||||||
self.assertEqual(local["match_type"], "branch")
|
self.assertEqual(local["match_type"], "branch")
|
||||||
|
|
||||||
|
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
def test_remove_local_worktree_uses_discovered_path(self, mock_run):
|
def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state):
|
||||||
mock_output = (
|
mock_run.return_value = Mock(
|
||||||
"worktree /repo/Gitea-Tools\n"
|
returncode=0,
|
||||||
"bare\n\n"
|
stdout=(
|
||||||
"worktree /repo/Gitea-Tools/branches/custom-name\n"
|
"worktree /repo/Gitea-Tools\n"
|
||||||
"branch refs/heads/feat/issue-11-x\n"
|
"HEAD aaaa\n"
|
||||||
"HEAD cafebabe\n\n"
|
"branch refs/heads/master\n\n"
|
||||||
|
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
|
||||||
|
"HEAD cafebabe\n"
|
||||||
|
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# First call is discover_local_worktrees, second is git worktree remove
|
mock_state.side_effect = [
|
||||||
mock_run.side_effect = [
|
{
|
||||||
MagicMock(returncode=0, stdout=mock_output),
|
"exists": False,
|
||||||
MagicMock(returncode=0, stdout="", stderr=""),
|
"clean": None,
|
||||||
|
"current_branch": None,
|
||||||
|
"head_sha": None,
|
||||||
|
"dirty_files": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exists": True,
|
||||||
|
"clean": True,
|
||||||
|
"current_branch": "feat/issue-510-workspace-binding-isolation",
|
||||||
|
"head_sha": "cafebabe",
|
||||||
|
"dirty_files": [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch("os.path.isdir", return_value=True):
|
report = mcr.build_reconciliation_report(
|
||||||
result = mcr.remove_local_worktree("/repo/Gitea-Tools", "feat/issue-11-x")
|
project_root="/repo/Gitea-Tools",
|
||||||
|
closed_prs=[
|
||||||
|
{
|
||||||
|
"number": 512,
|
||||||
|
"title": "merged cleanup",
|
||||||
|
"body": "Closes #510",
|
||||||
|
"head": {
|
||||||
|
"ref": "feat/issue-510-workspace-binding-isolation",
|
||||||
|
"sha": "cafebabe",
|
||||||
|
},
|
||||||
|
"merged_at": "2026-07-06T12:00:00Z",
|
||||||
|
"merge_commit_sha": "abc123",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
open_prs=[],
|
||||||
|
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
|
||||||
|
head_on_master={512: True},
|
||||||
|
delete_capability_allowed=True,
|
||||||
|
target_ref="prgs/master",
|
||||||
|
)
|
||||||
|
|
||||||
|
local = report["entries"][0]["local_worktree"]
|
||||||
|
self.assertTrue(local["safe_to_remove_worktree"])
|
||||||
|
self.assertEqual(local["match_type"], "branch")
|
||||||
|
self.assertEqual(
|
||||||
|
local["expected_worktree_path"],
|
||||||
|
"/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
local["discovered_worktree_path"],
|
||||||
|
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state):
|
||||||
|
mock_run.return_value = Mock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=(
|
||||||
|
"worktree /repo/Gitea-Tools/branches/issue-510-one\n"
|
||||||
|
"HEAD cafebabe\n"
|
||||||
|
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||||
|
"worktree /repo/Gitea-Tools/branches/issue-510-two\n"
|
||||||
|
"HEAD cafebabe\n"
|
||||||
|
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mock_state.return_value = {
|
||||||
|
"exists": False,
|
||||||
|
"clean": None,
|
||||||
|
"current_branch": None,
|
||||||
|
"head_sha": None,
|
||||||
|
"dirty_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
report = mcr.build_reconciliation_report(
|
||||||
|
project_root="/repo/Gitea-Tools",
|
||||||
|
closed_prs=[
|
||||||
|
{
|
||||||
|
"number": 512,
|
||||||
|
"title": "merged cleanup",
|
||||||
|
"body": "Closes #510",
|
||||||
|
"head": {
|
||||||
|
"ref": "feat/issue-510-workspace-binding-isolation",
|
||||||
|
"sha": "cafebabe",
|
||||||
|
},
|
||||||
|
"merged_at": "2026-07-06T12:00:00Z",
|
||||||
|
"merge_commit_sha": "abc123",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
open_prs=[],
|
||||||
|
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
|
||||||
|
head_on_master={512: True},
|
||||||
|
delete_capability_allowed=True,
|
||||||
|
target_ref="prgs/master",
|
||||||
|
)
|
||||||
|
|
||||||
|
local = report["entries"][0]["local_worktree"]
|
||||||
|
self.assertFalse(local["safe_to_remove_worktree"])
|
||||||
|
self.assertEqual(local["match_type"], "ambiguous_alias")
|
||||||
|
self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0])
|
||||||
|
|
||||||
|
@patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||||
|
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_alias_head_on_target_branch_is_safe_cleanup_commit(
|
||||||
|
self, mock_run, mock_state, _ancestor
|
||||||
|
):
|
||||||
|
mock_run.return_value = Mock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=(
|
||||||
|
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
|
||||||
|
"HEAD deadbeef\n"
|
||||||
|
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mock_state.side_effect = [
|
||||||
|
{
|
||||||
|
"exists": False,
|
||||||
|
"clean": None,
|
||||||
|
"current_branch": None,
|
||||||
|
"head_sha": None,
|
||||||
|
"dirty_files": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exists": True,
|
||||||
|
"clean": True,
|
||||||
|
"current_branch": "feat/issue-510-workspace-binding-isolation",
|
||||||
|
"head_sha": "deadbeef",
|
||||||
|
"dirty_files": [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
state = mcr.resolve_cleanup_worktree_state(
|
||||||
|
project_root="/repo/Gitea-Tools",
|
||||||
|
head_branch="feat/issue-510-workspace-binding-isolation",
|
||||||
|
issue_number=510,
|
||||||
|
pr_head_sha="cafebabe",
|
||||||
|
target_ref="prgs/master",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(state["exists"])
|
||||||
|
self.assertEqual(state["match_type"], "branch")
|
||||||
|
self.assertEqual(
|
||||||
|
state["discovered_worktree_path"],
|
||||||
|
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("os.path.isdir", return_value=True)
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir):
|
||||||
|
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||||
|
result = mcr.remove_local_worktree(
|
||||||
|
"/repo/Gitea-Tools",
|
||||||
|
"feat/issue-510-workspace-binding-isolation",
|
||||||
|
worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||||
|
)
|
||||||
|
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertTrue(result["performed"])
|
self.assertEqual(
|
||||||
self.assertEqual(result["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
|
result["worktree_path"],
|
||||||
# Assert git worktree remove was called with the custom path
|
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||||
mock_run.assert_any_call(
|
)
|
||||||
["git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/custom-name"],
|
mock_run.assert_called_once_with(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/repo/Gitea-Tools",
|
||||||
|
"worktree",
|
||||||
|
"remove",
|
||||||
|
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
@@ -440,4 +610,4 @@ class TestReviewerScratchCleanup(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ REVIEWER_ENV = {
|
|||||||
"GITEA_PROFILE_NAME": "reviewer-test",
|
"GITEA_PROFILE_NAME": "reviewer-test",
|
||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
|
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
|
||||||
"gitea.pr.request_changes,gitea.pr.merge",
|
"gitea.pr.request_changes",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push",
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.merge",
|
||||||
|
}
|
||||||
|
|
||||||
|
MERGER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "merger-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
|
"gitea.read,gitea.pr.comment,gitea.pr.merge",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.approve",
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECTED_SKILLS = [
|
EXPECTED_SKILLS = [
|
||||||
@@ -88,6 +95,15 @@ class TestControlPlaneGuide(GuideTestBase):
|
|||||||
self.assertIn("eligibility", blob)
|
self.assertIn("eligibility", blob)
|
||||||
self.assertIn("pinned", blob)
|
self.assertIn("pinned", blob)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "merger-bot"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_merger_profile_guidance(self, _auth, _api):
|
||||||
|
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||||
|
g = mcp_get_control_plane_guide(remote="prgs")
|
||||||
|
self.assertEqual(g["profile"]["role_kind"], "merger")
|
||||||
|
blob = " ".join(g["guidance"]).lower()
|
||||||
|
self.assertIn("merge", blob)
|
||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=None)
|
@patch("mcp_server.get_auth_header", return_value=None)
|
||||||
def test_unresolved_identity_instructs_stop(self, _auth):
|
def test_unresolved_identity_instructs_stop(self, _auth):
|
||||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||||
|
|||||||
@@ -44,9 +44,19 @@ CONFIG_RESOLVER = {
|
|||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
|
},
|
||||||
|
"merger-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "merger-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.issue.comment"],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
|
"execution_profile": "merger-profile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -83,6 +93,7 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
"GITEA_MCP_PROFILE": profile,
|
"GITEA_MCP_PROFILE": profile,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@@ -231,9 +242,9 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs")
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertIn("reviewer", res["exact_safe_next_action"].lower())
|
self.assertIn("merger", res["exact_safe_next_action"].lower())
|
||||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
||||||
self.assertEqual(res["required_role_kind"], "reviewer")
|
self.assertEqual(res["required_role_kind"], "merger")
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_self_review_blocked_structured(self, mock_api):
|
def test_self_review_blocked_structured(self, mock_api):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -43,9 +43,19 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
|
},
|
||||||
|
"merger-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "merger-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
|
"execution_profile": "merger-profile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -90,6 +100,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
"GITEA_MCP_REVEAL_ENDPOINTS": reveal,
|
"GITEA_MCP_REVEAL_ENDPOINTS": reveal,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -125,7 +136,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
)
|
)
|
||||||
self.assertFalse(merge_entry["allowed_in_current_session"])
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||||
self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"])
|
self.assertIn("merger-profile", merge_entry["matching_configured_profiles"])
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.assess_preflight_status",
|
"mcp_server.assess_preflight_status",
|
||||||
@@ -144,6 +155,30 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
caps = ctx["session_capabilities"]
|
caps = ctx["session_capabilities"]
|
||||||
self.assertFalse(caps["can_author_prs"])
|
self.assertFalse(caps["can_author_prs"])
|
||||||
self.assertFalse(caps["can_create_issues"])
|
self.assertFalse(caps["can_create_issues"])
|
||||||
|
self.assertTrue(caps["can_review_prs"])
|
||||||
|
self.assertFalse(caps["can_merge_prs"])
|
||||||
|
merge_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertFalse(merge_entry["allowed_in_current_session"])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.assess_preflight_status",
|
||||||
|
return_value={"preflight_ready": True, "preflight_block_reasons": []},
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "merger-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token merger-pass")
|
||||||
|
def test_get_runtime_context_merger(self, _auth, _api, _preflight):
|
||||||
|
with patch.dict(os.environ, self._env("merger-profile"), clear=True):
|
||||||
|
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(ctx["active_profile"], "merger-profile")
|
||||||
|
self.assertEqual(ctx["authenticated_username"], "merger-user")
|
||||||
|
self.assertTrue(ctx["review_merge_allowed"])
|
||||||
|
self.assertEqual(ctx["suggested_fix"], "none")
|
||||||
|
self.assertEqual(ctx["safe_next_action"], "None; ready for operations.")
|
||||||
|
caps = ctx["session_capabilities"]
|
||||||
|
self.assertFalse(caps["can_author_prs"])
|
||||||
|
self.assertFalse(caps["can_create_issues"])
|
||||||
self.assertFalse(caps["can_review_prs"])
|
self.assertFalse(caps["can_review_prs"])
|
||||||
self.assertTrue(caps["can_merge_prs"])
|
self.assertTrue(caps["can_merge_prs"])
|
||||||
merge_entry = next(
|
merge_entry = next(
|
||||||
@@ -160,7 +195,7 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True):
|
with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True):
|
||||||
res = mcp_server.gitea_list_profiles()
|
res = mcp_server.gitea_list_profiles()
|
||||||
profiles = res["profiles"]
|
profiles = res["profiles"]
|
||||||
self.assertEqual(len(profiles), 2)
|
self.assertEqual(len(profiles), 3)
|
||||||
|
|
||||||
author_prof = next(p for p in profiles if p["name"] == "author-profile")
|
author_prof = next(p for p in profiles if p["name"] == "author-profile")
|
||||||
self.assertTrue(author_prof["is_active"])
|
self.assertTrue(author_prof["is_active"])
|
||||||
@@ -176,6 +211,13 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
self.assertEqual(reviewer_prof["base_url"], "<redacted>")
|
self.assertEqual(reviewer_prof["base_url"], "<redacted>")
|
||||||
self.assertEqual(reviewer_prof["identity_status"], "credentials present")
|
self.assertEqual(reviewer_prof["identity_status"], "credentials present")
|
||||||
|
|
||||||
|
merger_prof = next(p for p in profiles if p["name"] == "merger-profile")
|
||||||
|
self.assertFalse(merger_prof["is_active"])
|
||||||
|
self.assertEqual(merger_prof["role_kind"], "merger")
|
||||||
|
self.assertEqual(merger_prof["auth"]["name"], "<redacted>")
|
||||||
|
self.assertEqual(merger_prof["base_url"], "<redacted>")
|
||||||
|
self.assertEqual(merger_prof["identity_status"], "credentials present")
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_list_profiles_revealed_under_opt_in(self, _auth, _api):
|
def test_list_profiles_revealed_under_opt_in(self, _auth, _api):
|
||||||
|
|||||||
Reference in New Issue
Block a user