Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1d5c5649 | ||
|
|
08c3488d7c | ||
|
|
4f2fd9a8b1 | ||
|
|
4185ee1417 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
ebd06b73c9 | ||
|
|
af9f1c5944 | ||
|
|
f83d2c2027 | ||
|
|
93781af7e9 | ||
|
|
314615ec2c | ||
|
|
52013791d4 | ||
|
|
2ac7519792 | ||
|
|
a32c68e24b | ||
|
|
7186718cfd | ||
|
|
964505654f | ||
|
|
008cd3fd74 | ||
|
|
11ffe0f9dd | ||
|
|
683649424b | ||
|
|
ac531dda05 | ||
|
|
36781ebef7 | ||
|
|
2941ec529c | ||
|
|
eddb68818e | ||
|
|
b98e8dfa1a | ||
|
|
86651a3d05 | ||
|
|
3f3f880147 | ||
|
|
6094069ef6 | ||
|
|
6913ac98f1 | ||
|
|
faf36b5bdf | ||
|
|
05b4f13e96 | ||
|
|
57b2023611 | ||
|
|
5debfcf178 | ||
|
|
27d75facd4 | ||
|
|
2b4c291f12 | ||
|
|
b0ae1605c3 | ||
|
|
c738e5b10d | ||
|
|
fe9faec741 | ||
|
|
7f2a5a3d3c | ||
|
|
946d6c2f81 | ||
|
|
64b83cd1fb | ||
|
|
23535e30bc | ||
|
|
351ef3899b | ||
|
|
1334b0b320 | ||
|
|
975674d7f5 | ||
|
|
8d1098f916 | ||
|
|
49aa3b2812 | ||
|
|
94004f05ac | ||
|
|
8d6d3cb014 | ||
|
|
f558b80cc8 | ||
|
|
223cde1b94 | ||
|
|
9b96085d8d | ||
|
|
ddb1dd941b | ||
|
|
d7c29afde5 | ||
|
|
cc4b95839d | ||
|
|
325e0bb44c | ||
|
|
cf130ed0fc | ||
|
|
cd83b7588f | ||
|
|
b3bc459423 | ||
|
|
e2ef8fbda0 | ||
|
|
5cf44d78e6 | ||
|
|
254ccf5821 | ||
|
|
a8177c2e73 | ||
|
|
e8f93e93d7 | ||
|
|
7acd55d2d2 | ||
|
|
b52fa2060f | ||
|
|
932b280a27 | ||
|
|
c35f713291 | ||
|
|
9340dc7009 | ||
|
|
8e7a69800a | ||
|
|
b14b1a9663 | ||
|
|
eac2b0f6f2 | ||
|
|
7274b76e81 | ||
|
|
5ada9e6cc0 | ||
|
|
9c599bf054 | ||
|
|
6cce87031b | ||
|
|
71e28aa5b4 | ||
|
|
0631e2e155 | ||
|
|
46709537c1 | ||
|
|
62ab4b4d95 | ||
|
|
ce0fb42bef | ||
|
|
92fc913cad | ||
|
|
412f0e0081 | ||
|
|
7cb024d4a5 | ||
|
|
1ed1539acf | ||
|
|
81ff3608ae | ||
|
|
e2e51290c4 | ||
|
|
a2e8bf17ec | ||
|
|
e8947438c9 | ||
|
|
bc16f6652b | ||
|
|
e7811cb5bb | ||
|
|
e5bf8c9647 | ||
|
|
b68805e88a | ||
|
|
ffa1ff95cc | ||
|
|
95f77bb648 | ||
|
|
08957aeb2a | ||
|
|
4d865666c7 | ||
|
|
d9cf26ddbf | ||
|
|
95ec891bd2 | ||
|
|
72ae320400 | ||
|
|
f04f61058f | ||
|
|
b71dfe9696 | ||
|
|
a82d35f8bd | ||
|
|
066eec8477 | ||
|
|
f29df56a99 | ||
|
|
8e9e4670c6 | ||
|
|
e4f1a52a87 | ||
|
|
a2b7327447 | ||
|
|
0a202970fd | ||
|
|
631620d264 | ||
|
|
7fb813446f | ||
|
|
f6b2adac4d | ||
|
|
719de6fb7a | ||
|
|
0ee89fe347 | ||
|
|
7a0de4a8e9 | ||
|
|
4e019f1a59 | ||
|
|
4bcdd350f6 | ||
|
|
e0c583db75 | ||
|
|
32f1f3486f | ||
|
|
32fc1719aa | ||
|
|
8ff1924047 | ||
|
|
84d921a52f | ||
|
|
44cf2a4ce8 | ||
|
|
22d4735170 | ||
|
|
87d6532cb3 | ||
|
|
d663114976 | ||
|
|
565363d5f4 | ||
|
|
7ba469f292 | ||
|
|
d57ca94ec5 | ||
|
|
add87b5708 | ||
|
|
05ea99cd32 | ||
|
|
7c3699ce2d | ||
|
|
c3452ce866 | ||
|
|
97b3c6573b | ||
|
|
51e226e8ba | ||
|
|
09d8db5b9e | ||
|
|
e97b1d3642 | ||
|
|
b12212493c | ||
|
|
daf9910e83 | ||
|
|
3550262613 | ||
|
|
59f2de9711 | ||
|
|
c0b1f562a9 | ||
|
|
560cfa8f47 | ||
|
|
3b4f222a7d | ||
|
|
a2aea5357d | ||
|
|
b7755f26e3 | ||
|
|
3cb05284b3 | ||
|
|
9e29d00292 | ||
|
|
96fe077292 | ||
|
|
3b499cec2a | ||
|
|
eff4572a91 | ||
|
|
25cf323d14 | ||
|
|
429faccd08 | ||
|
|
9235f3a23c | ||
|
|
d50328aea4 | ||
|
|
76d1417c28 | ||
|
|
a863928661 | ||
|
|
8f866ae753 | ||
|
|
84b841c727 | ||
|
|
276a71bd1a | ||
|
|
0e701d578a | ||
|
|
934688a71d |
@@ -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,98 @@
|
|||||||
|
"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
||||||
|
"""Return raw git branch-delete commands cited in *text*."""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
commands: list[str] = []
|
||||||
|
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
||||||
|
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||||
|
return list(dict.fromkeys(commands))
|
||||||
|
|
||||||
|
|
||||||
|
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
||||||
|
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
|
||||||
|
commands = raw_branch_delete_commands(text)
|
||||||
|
reasons = [
|
||||||
|
(
|
||||||
|
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
||||||
|
f"{command}"
|
||||||
|
)
|
||||||
|
for command in commands
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"commands": commands,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
|
||||||
|
"helper with explicit branch.delete capability"
|
||||||
|
if reasons
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_merged_pr_branch_cleanup(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_branch: str,
|
||||||
|
merged: bool,
|
||||||
|
remote_branch_exists: bool,
|
||||||
|
open_pr_heads: set[str],
|
||||||
|
head_on_target: bool | None,
|
||||||
|
delete_capability_allowed: bool,
|
||||||
|
confirmation: str | None,
|
||||||
|
protected_branches: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a merged PR source branch can be deleted via MCP."""
|
||||||
|
protected = protected_branches or PROTECTED_BRANCHES
|
||||||
|
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not merged:
|
||||||
|
reasons.append("PR is not merged")
|
||||||
|
if not remote_branch_exists:
|
||||||
|
reasons.append("remote branch already absent")
|
||||||
|
if not head_branch:
|
||||||
|
reasons.append("PR head branch is missing")
|
||||||
|
if head_branch in protected:
|
||||||
|
reasons.append(f"branch '{head_branch}' is protected")
|
||||||
|
if head_branch in open_pr_heads:
|
||||||
|
reasons.append("an open PR still references this head branch")
|
||||||
|
if head_on_target is False:
|
||||||
|
reasons.append("PR head is not an ancestor of the target branch")
|
||||||
|
if head_on_target is None:
|
||||||
|
reasons.append("PR head ancestry could not be proven")
|
||||||
|
if not delete_capability_allowed:
|
||||||
|
reasons.append("gitea.branch.delete capability is not allowed")
|
||||||
|
if confirmation != expected_confirmation:
|
||||||
|
reasons.append(
|
||||||
|
"confirmation must equal "
|
||||||
|
f"'{expected_confirmation}' for branch cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
safe = not reasons
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_branch": head_branch,
|
||||||
|
"expected_confirmation": expected_confirmation,
|
||||||
|
"remote_branch_exists": remote_branch_exists,
|
||||||
|
"safe_to_delete": safe,
|
||||||
|
"block_reasons": reasons,
|
||||||
|
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Controller-closure baseline-proof gate (#529, criterion 6).
|
||||||
|
|
||||||
|
A controller (or any session) may close a tracking issue with a closure
|
||||||
|
report that summarizes validation. When that report calls a non-zero
|
||||||
|
test-suite exit an "expected pre-existing failure" (or baseline / known
|
||||||
|
failure) it must carry pre-merge proof; otherwise the closure buries an
|
||||||
|
unproven regression as an accepted baseline and weakens controller
|
||||||
|
confidence in the durable state.
|
||||||
|
|
||||||
|
This module fails closed on exactly that case by reusing the pre-merge
|
||||||
|
baseline proof verifier (#533). A closure report is allowed when it is
|
||||||
|
empty (no validation claim), a clean pass, or a baseline failure proven on
|
||||||
|
the PR pre-merge base commit (or a documented known-failure record that
|
||||||
|
predates the PR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from premerge_baseline_proof import CLEAN_PASS, assess_premerge_baseline_proof
|
||||||
|
|
||||||
|
|
||||||
|
def assess_controller_closure_baseline_proof(
|
||||||
|
closure_report: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether an issue-closure report may proceed.
|
||||||
|
|
||||||
|
Returns a dict with ``block``, ``proven``, ``label``, ``reasons``,
|
||||||
|
``skipped`` and ``safe_next_action``. Only blocks when the closure
|
||||||
|
report claims a non-zero validation exit is an expected
|
||||||
|
pre-existing/baseline failure without valid pre-merge proof.
|
||||||
|
"""
|
||||||
|
text = (closure_report or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {
|
||||||
|
"block": False,
|
||||||
|
"proven": True,
|
||||||
|
"label": CLEAN_PASS,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = assess_premerge_baseline_proof(text)
|
||||||
|
block = bool(result.get("block"))
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"proven": not block,
|
||||||
|
"label": result.get("label"),
|
||||||
|
"reasons": list(result.get("reasons") or []),
|
||||||
|
"skipped": bool(result.get("skipped", False)),
|
||||||
|
"safe_next_action": (
|
||||||
|
result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"provide pre-merge baseline proof (base commit, tested commit, "
|
||||||
|
"command, exit status, failure signature) before closing on an "
|
||||||
|
"expected pre-existing failure"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if block
|
||||||
|
else "",
|
||||||
|
}
|
||||||
+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,301 @@
|
|||||||
|
# ADR: MCP allocator, control-plane DB, and Sentry/GlitchTip incident bridge architecture
|
||||||
|
|
||||||
|
- **Status:** Accepted (implementation pending; blocks code for #600 / #612 / #613)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issues:**
|
||||||
|
- [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) — control-plane DB (first)
|
||||||
|
- [#600](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/600) — allocator API (second)
|
||||||
|
- [#612](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/612) — Sentry/GlitchTip incident bridge (third)
|
||||||
|
- **Related:** existing GlitchTip contracts (`glitchtip-to-gitea-workflow-design.md`, `glitchtip-gitea-deduplication-linking-design.md`), trust boundaries (`tool-boundaries.md`, `safety-model.md`), current leases (`pr_work_lease.py`, `issue_lock_store.py`)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Multiple LLM sessions can independently inspect the Gitea queue and start the same issue or PR. Existing coordination (Gitea comment leases, local issue-lock files, labels) often detects collisions **after** work has started. Parallel issues #600, #612, and #613 each describe part of a fix; without one ADR they risk overlapping stores, wrong dependency order, and trust-boundary violations.
|
||||||
|
|
||||||
|
This ADR is the canonical architecture decision **before** implementing those issues.
|
||||||
|
|
||||||
|
## 2. Decision summary (core)
|
||||||
|
|
||||||
|
| Layer | Owns | Must not |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Control-plane DB** | Sessions, atomic assignment, leases, heartbeats, terminal-lock index, events, incident links | Bypass Gitea workflow gates or replace issue/PR history |
|
||||||
|
| **Gitea** | Durable work record: issues, PRs, comments, labels, reviews, merges | Be the only concurrency lock under multi-session load |
|
||||||
|
| **Sentry / GlitchTip** | Incidents, events, provider UI | Assign work, approve/merge/close, or mutate Gitea outside the bridge |
|
||||||
|
| **Incident bridge** | Provider adapters, reconcile/create Gitea issues, link storage upsert, optional provider writeback | Hand raw provider incidents to the allocator as work items |
|
||||||
|
|
||||||
|
**One-liner:** **DB coordinates. Gitea records. Sentry/GlitchTip observe. The bridge is the only path that turns observations into Gitea work. The allocator assigns only Gitea issues/PRs, never raw monitoring incidents.**
|
||||||
|
|
||||||
|
## 3. Dependency order
|
||||||
|
|
||||||
|
Implementation **must** follow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB → #600 allocator API → #612 incident bridge
|
||||||
|
(atomic substrate) (routing policy) (feeds Gitea work)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Issue | Role | Depends on |
|
||||||
|
|-------|------|------------|
|
||||||
|
| **#613** | Durable coordination DB + lease/assignment transactions | — |
|
||||||
|
| **#600** | `gitea_allocate_next_work` policy and tool surface | **#613** (hard) |
|
||||||
|
| **#612** | Multi-project Sentry/GlitchTip → Gitea bridge | **#613** for `incident_links` index; **#600** before treating bridge-created issues as allocator feed in multi-worker prod |
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
|
||||||
|
1. Do **not** implement #600 on file locks / comment-only leases and call it done.
|
||||||
|
2. Do **not** ship #612 as a second assignment system for raw incidents.
|
||||||
|
3. Partial previews (read-only list tools, schema stubs) may land earlier if they do not claim “allocator complete” or “bridge complete.”
|
||||||
|
|
||||||
|
## 4. Authority boundaries
|
||||||
|
|
||||||
|
### 4.1 Gitea (durable source of truth for work)
|
||||||
|
|
||||||
|
Gitea remains authoritative for:
|
||||||
|
|
||||||
|
- Issue and PR identity, titles, bodies, state (open/closed/merged)
|
||||||
|
- Comments, reviews, approvals, REQUEST_CHANGES
|
||||||
|
- Labels and workflow status labels (`status:ready`, `status:in-progress`, …)
|
||||||
|
- Merges, closes, branch refs as recorded by Gitea/git hosting
|
||||||
|
|
||||||
|
Operators and auditors read **history** from Gitea.
|
||||||
|
|
||||||
|
### 4.2 Control-plane DB (coordination / index)
|
||||||
|
|
||||||
|
The control-plane DB is authoritative for **live multi-session coordination**:
|
||||||
|
|
||||||
|
- Which session holds which assignment/lease
|
||||||
|
- Heartbeat freshness and expiry
|
||||||
|
- Terminal-lock index for routing
|
||||||
|
- Event log of allocation/lease transitions
|
||||||
|
- `incident_links` index (see §8)
|
||||||
|
|
||||||
|
It is **not** a substitute for Gitea history. When DB and Gitea disagree on durable work state (e.g. PR already merged), **Gitea wins**; the DB is reconciled.
|
||||||
|
|
||||||
|
### 4.3 Sentry / GlitchTip (observe)
|
||||||
|
|
||||||
|
Providers own incident lifecycle and raw event data. They:
|
||||||
|
|
||||||
|
- Must **not** bypass Gitea gates
|
||||||
|
- Must **not** assign LLM work
|
||||||
|
- May receive **writeback** of resolution status only through the bridge, when configured and supported
|
||||||
|
|
||||||
|
### 4.4 Trust boundaries for credentials
|
||||||
|
|
||||||
|
Aligned with `docs/tool-boundaries.md` and `docs/safety-model.md`:
|
||||||
|
|
||||||
|
- **One MCP server process per trust boundary** remains the default.
|
||||||
|
- Monitor API tokens (Sentry/GlitchTip) live in the **bridge/orchestrator boundary** (or a dedicated observability write/read profile), **not** blindly inside every Gitea MCP author/reviewer/merger process.
|
||||||
|
- Gitea tokens stay on Gitea MCP profiles only.
|
||||||
|
- Orchestrators compose services; they must not become a single credential pool that silently mixes Gitea write + monitor tokens into every worker.
|
||||||
|
|
||||||
|
Tool names such as `gitea_observability_*` may exist as a **namespace façade** only if the runtime still enforces separate credential scopes (e.g. bridge process vs pure Gitea mutation process).
|
||||||
|
|
||||||
|
## 5. Allocator rules (#600 on top of #613)
|
||||||
|
|
||||||
|
### 5.1 Worker contract
|
||||||
|
|
||||||
|
- Workers **do not self-select** work under the standard multi-LLM workflow.
|
||||||
|
- Workers call **`gitea_allocate_next_work`** (with `apply=true` only when taking work).
|
||||||
|
- Mutations that claim exclusive work require a **valid assignment and lease** from the control-plane DB.
|
||||||
|
- Return values include at least: role, issue/PR number, expected head SHA (when applicable), lease ID, expiry, allowed actions, forbidden actions — or a non-assignment outcome (`WAIT`, terminal-path block, no safe work, needs controller, etc.).
|
||||||
|
|
||||||
|
### 5.2 Atomic reserve
|
||||||
|
|
||||||
|
- **Assignment creation and lease creation happen in one DB transaction.**
|
||||||
|
- Two concurrent sessions **must not** receive the same issue/PR as assigned work.
|
||||||
|
- On contention: second session gets **WAIT** or **owner-resume**, never a duplicate lease.
|
||||||
|
|
||||||
|
### 5.3 Routing policy
|
||||||
|
|
||||||
|
| Condition | Route |
|
||||||
|
|-----------|--------|
|
||||||
|
| Current-head **REQUEST_CHANGES** | **Author** (not reviewer/merger) |
|
||||||
|
| **Stale** approval (approval not on current head) | **Reviewer** |
|
||||||
|
| **Clean** approval on current head, mergeable | **Merger** |
|
||||||
|
| Contested / contaminated approval | **Diagnosis / reconciler** (controller path) |
|
||||||
|
| Active **foreign** lease | **WAIT** or **owner-resume** |
|
||||||
|
| **Terminal-review** lock present | **Terminal-path resolution first** before downstream review work |
|
||||||
|
| Merged / closed PR | **Never assign** |
|
||||||
|
| Issue locked by sanctioned lease | **Never assign** to another worker unless lease cleanup/adoption is sanctioned |
|
||||||
|
|
||||||
|
### 5.4 Relationship to Gitea mirrors
|
||||||
|
|
||||||
|
After a successful assignment, the allocator (or sanctioned tooling) may update Gitea comments/labels so humans and audits see state. Those mirrors **are not** the sole lock source.
|
||||||
|
|
||||||
|
## 6. Control-plane DB topology (#613)
|
||||||
|
|
||||||
|
### 6.1 Preferred architecture (multi-daemon / Proxmox)
|
||||||
|
|
||||||
|
For true multi-session concurrency (multiple MCP daemons, multiple hosts, or Proxmox VMs):
|
||||||
|
|
||||||
|
**Prefer either:**
|
||||||
|
|
||||||
|
1. **Shared Postgres** used by all Gitea MCP profiles / allocator callers, **or**
|
||||||
|
2. A **single allocator daemon** (sole writer to the coordination store) that all workers call over MCP/RPC.
|
||||||
|
|
||||||
|
Both satisfy: one transactional authority for “who has this work item.”
|
||||||
|
|
||||||
|
### 6.2 SQLite MVP
|
||||||
|
|
||||||
|
SQLite is acceptable **only** for a **single-writer MVP**:
|
||||||
|
|
||||||
|
- One process owns writes (allocator daemon or single co-located MCP server), **or**
|
||||||
|
- Documented single-host single-daemon experiments with file locking and no multi-host claims.
|
||||||
|
|
||||||
|
SQLite is **not** sufficient to declare multi-session production readiness when four independent LLM sessions talk to four MCP processes without a shared writer.
|
||||||
|
|
||||||
|
### 6.3 Suggested entities (normative shape)
|
||||||
|
|
||||||
|
Minimum logical entities (names may vary; semantics must not):
|
||||||
|
|
||||||
|
1. **sessions** — session_id, role/profile, namespace, pid, started_at, last_heartbeat_at, status
|
||||||
|
2. **work_items** — remote/org/repo, kind ∈ {`issue`, `pr`}, number, state, priority, current_head_sha, updated_at
|
||||||
|
3. **leases** — lease_id, work_item_id, session_id, role, phase, expires_at, heartbeat_at, status
|
||||||
|
4. **assignments** — assignment_id, work_item_id, session_id, allowed_action(s), expected_head_sha, status
|
||||||
|
5. **terminal_locks** — repo/org, terminal_pr, review_id, decision, status, cleanup_state
|
||||||
|
6. **events** — event_id, work_item_id, type, message, created_at
|
||||||
|
7. **incident_links** — provider-neutral link model (see §8)
|
||||||
|
|
||||||
|
**Explicit non-kind:** do **not** use `work_items.kind = sentry_incident` (or glitchtip_incident) as an assignable work unit. Incidents become work only after the bridge creates/links a **Gitea issue**.
|
||||||
|
|
||||||
|
## 7. Lease migration (avoid split-brain)
|
||||||
|
|
||||||
|
### 7.1 Target state
|
||||||
|
|
||||||
|
- **Primary** for live coordination: **control-plane DB** leases and assignments.
|
||||||
|
- **Gitea comment leases** (e.g. `<!-- mcp-review-lease:v1 -->`) and **local issue-lock files** become:
|
||||||
|
- **mirrors** of DB state for human visibility / recovery, and/or
|
||||||
|
- written **only** through the allocator (or sanctioned lease tools that update DB + mirror in one workflow).
|
||||||
|
|
||||||
|
### 7.2 Migration rules
|
||||||
|
|
||||||
|
1. New exclusive claims go through DB assignment+lease first.
|
||||||
|
2. Comment lease / file lock writers that skip the DB are **deprecated** once #613+#600 land.
|
||||||
|
3. Reconciler tooling heals: expired DB lease, orphan Gitea comment, orphan local file — fail closed when ambiguous.
|
||||||
|
4. **Split-brain is a defect:** “DB free + Gitea comment leased” or “DB leased + Gitea free” must be detectable and reconcilable; production paths must not rely on two independent writers.
|
||||||
|
|
||||||
|
### 7.3 Heartbeats
|
||||||
|
|
||||||
|
- Heartbeats update the **DB**.
|
||||||
|
- Optional Gitea summaries must avoid comment spam (periodic summary or edit-in-place policy).
|
||||||
|
|
||||||
|
## 8. Observability bridge (#612)
|
||||||
|
|
||||||
|
### 8.1 Role
|
||||||
|
|
||||||
|
The bridge:
|
||||||
|
|
||||||
|
1. Scans configured Sentry and/or GlitchTip projects (multi-project mappings).
|
||||||
|
2. Deduplicates against existing links / Gitea issues.
|
||||||
|
3. Creates or updates **normal Gitea issues** (labels, sanitized body, provider URL).
|
||||||
|
4. Upserts **one** canonical `incident_links` row.
|
||||||
|
5. Optionally writes resolution status back to the provider when supported.
|
||||||
|
6. Never approves, merges, closes, or otherwise bypasses Gitea workflow gates.
|
||||||
|
|
||||||
|
### 8.2 Allocator interaction
|
||||||
|
|
||||||
|
- The allocator sees observability work **only after** a Gitea issue exists (typically `status:ready` + observability labels).
|
||||||
|
- **Do not assign raw Sentry/GlitchTip incidents** as work items.
|
||||||
|
- Bridge AC “allocator can see linked issues” means: **as ordinary Gitea issues**, not a special incident queue.
|
||||||
|
|
||||||
|
### 8.3 Provider adapters and trust
|
||||||
|
|
||||||
|
- Separate **Sentry** and **GlitchTip** adapters; document API differences; do not assume writeback parity.
|
||||||
|
- Self-hosted base URLs supported (e.g. `https://sentry.prgs.cc`).
|
||||||
|
- Tokens from environment / secret store only.
|
||||||
|
- Prefer phase-1 **explicit reconcile / dry-run** before unsupervised watchdog auto-filing, consistent with existing GlitchTip filing safety contracts.
|
||||||
|
|
||||||
|
### 8.4 Home of implementation
|
||||||
|
|
||||||
|
Filing/orchestration may live in:
|
||||||
|
|
||||||
|
- a control-plane / bridge package or profile, and/or
|
||||||
|
- Gitea-Tools as operator-facing tools that **delegate** to that boundary,
|
||||||
|
|
||||||
|
but must not violate §4.4 credential isolation.
|
||||||
|
|
||||||
|
## 9. Incident link storage (single source of truth)
|
||||||
|
|
||||||
|
### 9.1 Canonical model: `incident_links` (provider-neutral)
|
||||||
|
|
||||||
|
Prefer a **provider-neutral** model over Sentry-only `sentry_links`:
|
||||||
|
|
||||||
|
| Field (logical) | Purpose |
|
||||||
|
|-----------------|---------|
|
||||||
|
| provider | `sentry` \| `glitchtip` \| … |
|
||||||
|
| provider_base_url | Self-hosted base |
|
||||||
|
| provider_org / provider_project | Mapping key |
|
||||||
|
| provider_issue_id / provider_short_id | Provider identity |
|
||||||
|
| provider_permalink | Human URL |
|
||||||
|
| fingerprint | Stable dedupe key when available |
|
||||||
|
| gitea_org / gitea_repo / gitea_issue_number | Linked work |
|
||||||
|
| linked_pr_numbers | Optional PR association |
|
||||||
|
| first_seen / last_seen / event_count | Summary metrics (safe) |
|
||||||
|
| status | link lifecycle |
|
||||||
|
| release_resolved_at / last_sync_at | Sync bookkeeping |
|
||||||
|
|
||||||
|
### 9.2 Gitea as human mirror
|
||||||
|
|
||||||
|
- Issue body markers / structured comments (e.g. HTML comment metadata) remain the **human- and audit-visible mirror**.
|
||||||
|
- They must stay consistent with `incident_links` but are **not** a second independent write path for inventing links without the bridge.
|
||||||
|
|
||||||
|
### 9.3 One truth
|
||||||
|
|
||||||
|
- **Do not** maintain two independent systems of record (e.g. full mapping only in #612 storage **and** a separate #613 `sentry_links` with different semantics).
|
||||||
|
- #612 implementation **must** use the same `incident_links` model introduced under #613 (or a single agreed store both reference).
|
||||||
|
|
||||||
|
## 10. Security and redaction
|
||||||
|
|
||||||
|
Mandatory for bridge payloads, Gitea issue bodies/comments, DB-stored event summaries, and logs:
|
||||||
|
|
||||||
|
- No API tokens, DSNs, passwords, cookies, auth headers
|
||||||
|
- No keychain IDs, private config contents, raw session-state files, full prompt bodies
|
||||||
|
- Sanitize stack locals and request data; store only safe tags/context
|
||||||
|
- Logs must never print provider tokens or DSNs
|
||||||
|
- Redaction tests are required for #612
|
||||||
|
|
||||||
|
## 11. Non-goals
|
||||||
|
|
||||||
|
- Replace Gitea as the durable workflow record
|
||||||
|
- Let Sentry/GlitchTip assign work or mutate Gitea workflow state outside sanctioned tools
|
||||||
|
- Let the bridge approve, merge, close, release, or bypass Gitea gates
|
||||||
|
- Implement #600 on file locks / comments alone and call allocator complete
|
||||||
|
- Treat raw monitoring incidents as `work_items` for the allocator
|
||||||
|
- Put monitor tokens into every Gitea MCP worker process by default
|
||||||
|
|
||||||
|
## 12. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Clear implementation order and ownership
|
||||||
|
- Multi-session safety becomes testable at the DB layer
|
||||||
|
- Observability becomes assignable work without special-casing the allocator
|
||||||
|
- Trust boundaries stay compatible with existing control-plane docs
|
||||||
|
|
||||||
|
### Costs / risks
|
||||||
|
|
||||||
|
- Migration from comment/file leases requires reconciler work
|
||||||
|
- Shared Postgres or single allocator daemon is operational cost
|
||||||
|
- Bridge must be careful not to spam Gitea issues (dedupe, caps, policy modes)
|
||||||
|
|
||||||
|
### Follow-ups (documentation only until implemented)
|
||||||
|
|
||||||
|
1. Update #600, #612, and #613 bodies to **link this ADR** and restate hard dependencies.
|
||||||
|
2. Implement #613 schema + atomic assign/lease.
|
||||||
|
3. Implement #600 against the DB.
|
||||||
|
4. Implement #612 against `incident_links` + Gitea create/update.
|
||||||
|
5. Deprecate dual writers for leases.
|
||||||
|
|
||||||
|
## 13. Acceptance criteria for *this* ADR
|
||||||
|
|
||||||
|
This document is accepted when:
|
||||||
|
|
||||||
|
1. It is merged into `docs/architecture/` on the default branch.
|
||||||
|
2. #600 / #612 / #613 (or their PRs) reference this path as the architecture source of truth.
|
||||||
|
3. No implementation PR for those issues claims completion without conforming to §§2–11.
|
||||||
|
|
||||||
|
## 14. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Bootstrap Review Path for Self-Hosted MCP Workflow Fixes (#557)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Gitea-Tools MCP workflow fixes can create a **bootstrap deadlock**: the live
|
||||||
|
MCP daemons still run the broken code from `master`, so canonical reviewer /
|
||||||
|
merger tools cannot complete the review that would land the fix.
|
||||||
|
|
||||||
|
This document defines a **narrow, controller-authorized bootstrap path** so
|
||||||
|
such fixes can land without weakening normal review/merge gates.
|
||||||
|
|
||||||
|
It is **not** a general bypass. Normal PRs must still use the full
|
||||||
|
reviewer → merger MCP workflow.
|
||||||
|
|
||||||
|
## When this path applies
|
||||||
|
|
||||||
|
All of the following must be true:
|
||||||
|
|
||||||
|
1. The PR changes **Gitea-Tools MCP workflow / preflight / lease / gate** code
|
||||||
|
that the live daemon must load to review itself (self-hosted control plane).
|
||||||
|
2. Canonical review or merge tools **fail closed** because of that defect
|
||||||
|
(documented tool errors, not “inconvenience”).
|
||||||
|
3. A **controller** records an explicit bootstrap authorization on the PR or
|
||||||
|
linked issue (see below).
|
||||||
|
4. The PR source is clean, testable, and free of unrelated scope.
|
||||||
|
|
||||||
|
Example (historical): PR #553 / Issue #546 (reviewer preflight capability/lease
|
||||||
|
deadlock). Live daemons on unpatched `master` could not complete canonical
|
||||||
|
review of the fix PR.
|
||||||
|
|
||||||
|
## Durable authorization (required)
|
||||||
|
|
||||||
|
**No manual remote merge, force-merge, or API merge of a bootstrap PR is
|
||||||
|
allowed** unless a controller has posted a durable authorization record on
|
||||||
|
Gitea.
|
||||||
|
|
||||||
|
### Authorization record (issue or PR comment)
|
||||||
|
|
||||||
|
The controller comment **must** include a machine-readable marker and the
|
||||||
|
fields below:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## BOOTSTRAP REVIEW AUTHORIZATION (#557)
|
||||||
|
|
||||||
|
Status: APPROVED
|
||||||
|
PR: <number>
|
||||||
|
Issue: <number>
|
||||||
|
Controller: <gitea username>
|
||||||
|
Reason: live MCP runtime cannot canonically review this self-hosted workflow fix
|
||||||
|
Scope: narrow bootstrap only — does not weaken normal PR gates
|
||||||
|
|
||||||
|
Validation evidence:
|
||||||
|
- git diff --check: clean
|
||||||
|
- targeted pytest: <command> → pass
|
||||||
|
- full suite attribution (if non-zero): baseline compared to prgs/master
|
||||||
|
|
||||||
|
Duplicate-PR audit: <none | list and disposition>
|
||||||
|
Mutation ledger audit: <summary or path to proof>
|
||||||
|
Contamination audit: <clean | list contaminated comment/lease IDs and disposition>
|
||||||
|
|
||||||
|
Allowed verification actions used: <list>
|
||||||
|
Forbidden actions NOT used: root checkout edits; raw curl mutation; direct
|
||||||
|
module import of gitea_mcp_server for mutations; in-memory gate restoration
|
||||||
|
|
||||||
|
Post-land plan:
|
||||||
|
- restart MCP daemons for author/reviewer/merger/reconciler profiles
|
||||||
|
- re-verify with canonical tools (see Post-bootstrap steps)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this record, bootstrap merge is **forbidden**. Agents must stop with
|
||||||
|
`BLOCKED + DIAGNOSE` rather than improvise a merge.
|
||||||
|
|
||||||
|
## Review gates and proof (still required)
|
||||||
|
|
||||||
|
Bootstrap does **not** skip technical review. It only allows verification and
|
||||||
|
landing when the **live MCP mutation path** cannot complete the review.
|
||||||
|
|
||||||
|
Before authorization, the controller (or a delegated read-only verifier in an
|
||||||
|
isolated worktree) must have:
|
||||||
|
|
||||||
|
| Gate | Requirement |
|
||||||
|
|------|-------------|
|
||||||
|
| Diff hygiene | `git diff --check` clean on the PR tip vs `prgs/master` |
|
||||||
|
| Tests | Targeted tests for the fix pass; full suite either green or failures attributed to baseline `prgs/master` |
|
||||||
|
| Duplicate PR | Open-PR inventory shows no competing open PR for the same issue (or supersession is documented) |
|
||||||
|
| Mutation ledger | Any claimed mutations are ledger-consistent; no silent root edits |
|
||||||
|
| Contamination audit | PR/issue comments and leases classified as clean vs workflow-contaminated |
|
||||||
|
| Scope | Diff limited to the bootstrap issue; no drive-by refactors |
|
||||||
|
|
||||||
|
### Contamination audit (comments / leases)
|
||||||
|
|
||||||
|
Comments or leases created via **raw curl**, **direct Python import of MCP
|
||||||
|
modules**, or **token extraction** are **workflow-contaminated**. They must be
|
||||||
|
listed and must **not** be treated as canonical review proof.
|
||||||
|
|
||||||
|
Only comments posted via **sanctioned MCP reviewer tools** (after the fix is
|
||||||
|
landed and daemons restarted, or during a clean path that did not bypass gates)
|
||||||
|
count as canonical review state.
|
||||||
|
|
||||||
|
Historical example on PR #553:
|
||||||
|
|
||||||
|
| Comment | Disposition |
|
||||||
|
|---------|-------------|
|
||||||
|
| #7247 test / raw API | contaminated |
|
||||||
|
| #7251 lease metadata / raw API | contaminated |
|
||||||
|
| #7252 lease metadata / direct import | contaminated |
|
||||||
|
| #7265 lease metadata / MCP reviewer tools | workflow-clean |
|
||||||
|
|
||||||
|
## Allowed verification actions
|
||||||
|
|
||||||
|
These may run **outside** the broken live mutation path to establish facts:
|
||||||
|
|
||||||
|
- Read-only MCP tools (`gitea_view_pr`, `gitea_list_prs`, `gitea_whoami`,
|
||||||
|
`gitea_get_profile`, inventory, eligibility **reads**).
|
||||||
|
- Isolated `branches/` worktree checkout of the PR head (never root).
|
||||||
|
- `git fetch`, `git log`, `git diff`, `git merge-tree` (read-only analysis).
|
||||||
|
- Targeted `pytest` / `py_compile` inside that worktree.
|
||||||
|
- Controller posting of the bootstrap authorization comment via a healthy
|
||||||
|
MCP profile **if available**; if comment mutation is also deadlocked, a
|
||||||
|
**human operator** posts the authorization in the Gitea UI (still durable on
|
||||||
|
the thread).
|
||||||
|
|
||||||
|
## Forbidden actions (always)
|
||||||
|
|
||||||
|
Even under bootstrap:
|
||||||
|
|
||||||
|
- Editing or committing in the **project root / control checkout**.
|
||||||
|
- Treating root as a worktree for implementation or review mutations.
|
||||||
|
- Raw `curl` / REST mutation with extracted tokens as a normal workflow.
|
||||||
|
- `import gitea_mcp_server` (or sibling modules) from a shell to call mutation
|
||||||
|
helpers and bypass preflight.
|
||||||
|
- In-memory restoration of capability / lease / decision state to “finish”
|
||||||
|
a blocked chain.
|
||||||
|
- Force-push, history rewrite, or deleting evidence comments.
|
||||||
|
- Weakening normal gates in code “temporarily” without a tracked issue/PR.
|
||||||
|
- Self-review or self-merge by the PR author identity.
|
||||||
|
|
||||||
|
## Landing procedure (after APPROVED authorization)
|
||||||
|
|
||||||
|
1. Confirm the authorization record is present and complete on the PR or issue.
|
||||||
|
2. Merge **only** with an independent merger identity when possible.
|
||||||
|
- Preferred: restart/replace the MCP merger daemon with a build that includes
|
||||||
|
the fix (or a one-shot process started from the PR worktree binary) so
|
||||||
|
`gitea_merge_pr` can run under normal gates.
|
||||||
|
- If the live merger daemon still cannot load the fix, a **human operator**
|
||||||
|
may merge via the Gitea UI **only** after the authorization record exists.
|
||||||
|
3. Never merge from contaminated proof alone.
|
||||||
|
|
||||||
|
## Post-bootstrap steps
|
||||||
|
|
||||||
|
### 1. Restart MCP daemons
|
||||||
|
|
||||||
|
After the fix lands on `prgs/master`:
|
||||||
|
|
||||||
|
1. Stop author / reviewer / merger / reconciler MCP server processes for this
|
||||||
|
repo (IDE MCP pool + any long-lived terminals).
|
||||||
|
2. Confirm no stale PID still serves the old code.
|
||||||
|
3. Restart each profile so it loads the updated `master` tree (or installed
|
||||||
|
package path).
|
||||||
|
4. Call `gitea_whoami` / `gitea_get_profile` on each namespace and record
|
||||||
|
profile name + identity.
|
||||||
|
|
||||||
|
### 2. Re-verify with canonical tools
|
||||||
|
|
||||||
|
Example pattern after PR #553-class fixes (lease release / #550-style follow-up):
|
||||||
|
|
||||||
|
1. From a **reviewer** MCP session: acquire/release or inspect the relevant
|
||||||
|
lease with canonical tools only.
|
||||||
|
2. Confirm no direct-import or raw-API path is required.
|
||||||
|
3. Post a short controller or reconciler note: bootstrap complete; normal gates
|
||||||
|
restored.
|
||||||
|
|
||||||
|
If verification still requires a bypass, open a new issue — do **not** extend
|
||||||
|
this bootstrap authorization silently.
|
||||||
|
|
||||||
|
## Explicit non-goals
|
||||||
|
|
||||||
|
- This path does **not** authorize skipping tests, duplicate audits, or
|
||||||
|
contamination audits.
|
||||||
|
- This path does **not** authorize root checkout implementation work.
|
||||||
|
- This path does **not** replace BLOCKED + DIAGNOSE when a non-bootstrap
|
||||||
|
failure occurs (#552).
|
||||||
|
- This path does **not** grant permanent “operator exception” culture.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Root checkout policy: `docs/llm-workflow-runbooks.md` (Global LLM Worktree Rule, #475)
|
||||||
|
- BLOCKED + DIAGNOSE: issue #552 / skill workflows
|
||||||
|
- Reviewer lease / preflight deadlock class: issues #546, #548, #550
|
||||||
|
- Durable session proofs across daemon pools: issue #559
|
||||||
@@ -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
|
||||||
@@ -54,6 +54,25 @@ Use `-q` for a compact summary and `-v` to see individual test names.
|
|||||||
./venv/bin/python -m pytest tests/ -q
|
./venv/bin/python -m pytest tests/ -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Web UI suite (#436)
|
||||||
|
|
||||||
|
Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
|
||||||
|
read-only guards, registry/prompt/queue loaders, and optional child-issue
|
||||||
|
modules when present on the branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-webui
|
||||||
|
./scripts/ci-webui-check # skip unless the diff touches web UI paths
|
||||||
|
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
|
||||||
|
needs Gitea credentials or MCP daemon credential access. Set
|
||||||
|
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.
|
||||||
|
|
||||||
|
Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
|
||||||
|
`webui/`, `tests/test_webui_*`, or `docs/webui*`.
|
||||||
|
|
||||||
### Run targeted tests
|
### Run targeted tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Two-comment workflow examples (#507)
|
||||||
|
|
||||||
|
Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads.
|
||||||
|
See `thread_state_ledger_examples.py` for machine-checked fixtures.
|
||||||
|
|
||||||
|
## Approved review posted
|
||||||
|
|
||||||
|
**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with
|
||||||
|
`gitea_submit_pr_review → APPROVED review posted to Gitea`.
|
||||||
|
|
||||||
|
**Ledger** (concise):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- PR state: open
|
||||||
|
- Server-side decision state: APPROVED review posted to Gitea
|
||||||
|
- Local verdict/state: APPROVE verdict prepared locally
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: no blocker
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor: merger
|
||||||
|
- Required action: merge on explicit operator command
|
||||||
|
- Do not do: re-post APPROVE
|
||||||
|
```
|
||||||
|
|
||||||
|
## Approve validated locally but blocked before posting
|
||||||
|
|
||||||
|
Ledger must show `no server-side state changed` under server-side decision state and
|
||||||
|
`APPROVE verdict prepared locally` under local verdict/state.
|
||||||
|
|
||||||
|
## Environment / tooling blocker
|
||||||
|
|
||||||
|
Ledger blocker classification: `environment/tooling blocker`. Mutation ledger:
|
||||||
|
`none — no server-side state changed`.
|
||||||
|
|
||||||
|
## Stale head blocker
|
||||||
|
|
||||||
|
Ledger: `approval_at_current_head is false`; classification `stale head`.
|
||||||
|
Do not do: merge with stale approval.
|
||||||
@@ -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.
|
||||||
|
|||||||
+323
-10
@@ -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
|
||||||
@@ -28,6 +33,8 @@ audit logging). See [Related documents](#related-documents).
|
|||||||
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
|
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
|
||||||
> for step-by-step instructions. This replaces long pasted operator prompts for
|
> for step-by-step instructions. This replaces long pasted operator prompts for
|
||||||
> the standard rules; operator prompts still control task-specific scope.
|
> the standard rules; operator prompts still control task-specific scope.
|
||||||
|
>
|
||||||
|
> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop.
|
||||||
> See issue #129 for the skill registry design.
|
> See issue #129 for the skill registry design.
|
||||||
|
|
||||||
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
|
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
|
||||||
@@ -408,6 +415,53 @@ 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.
|
||||||
|
|
||||||
|
## Canonical workflow skill names (#551)
|
||||||
|
|
||||||
|
Controller prompts and sessions must load the **same** workflow skill wall
|
||||||
|
regardless of runtime (Claude, Codex, Gemini):
|
||||||
|
|
||||||
|
| Name | Role |
|
||||||
|
|------|------|
|
||||||
|
| `gitea-workflow` | Primary controller / Codex skill name |
|
||||||
|
| `llm-project-workflow` | Portable in-repo package |
|
||||||
|
| `git-pr-workflows` | Legacy alias |
|
||||||
|
|
||||||
|
- Inventory: `mcp_list_project_skills` lists all three.
|
||||||
|
- Preflight: `mcp_check_workflow_skill_preflight` before mutations.
|
||||||
|
- Codex install: `scripts/install-codex-workflow-skill.sh`
|
||||||
|
- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md)
|
||||||
|
|
||||||
|
If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate.
|
||||||
|
|
||||||
|
## No direct-import mutation path (#558)
|
||||||
|
|
||||||
|
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
|
||||||
|
keychain fill from a raw shell to bypass MCP preflight.
|
||||||
|
|
||||||
|
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
|
||||||
|
|
||||||
|
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||||
|
|
||||||
|
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||||
|
runs from broken `master`, canonical review can deadlock on itself.
|
||||||
|
|
||||||
|
Do **not** improvise raw API, direct imports, root edits, or gate bypasses.
|
||||||
|
|
||||||
|
Use the narrow controller-authorized path documented in:
|
||||||
|
|
||||||
|
- [`docs/bootstrap-review-path.md`](bootstrap-review-path.md)
|
||||||
|
|
||||||
|
Hard rules:
|
||||||
|
|
||||||
|
- No merge without a durable `BOOTSTRAP REVIEW AUTHORIZATION (#557)` record on
|
||||||
|
the PR or linked issue.
|
||||||
|
- Validation, duplicate-PR, mutation-ledger, and contamination audits remain
|
||||||
|
mandatory.
|
||||||
|
- Root checkout stays read-only orchestration only; verification runs in
|
||||||
|
`branches/` worktrees.
|
||||||
|
- After land: restart MCP daemons and re-verify with canonical tools only.
|
||||||
|
- This never weakens normal reviewer/merger gates for ordinary PRs.
|
||||||
|
|
||||||
## Shell Spawn Hard-Stop Rule
|
## Shell Spawn Hard-Stop Rule
|
||||||
|
|
||||||
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
||||||
@@ -607,24 +661,44 @@ session, clearing hung background terminals, switching to MCP-native commit, and
|
|||||||
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
||||||
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||||
|
|
||||||
|
### Terminal launcher diagnostics (#556)
|
||||||
|
|
||||||
|
When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`),
|
||||||
|
do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||||
|
|
||||||
|
1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns
|
||||||
|
categorized failure: missing cwd, cwd not a directory, missing executable,
|
||||||
|
missing runtime wrapper, missing shell, probe timeout, or session launcher
|
||||||
|
failure.
|
||||||
|
2. Call `gitea_get_shell_health` for the shell circuit-breaker state.
|
||||||
|
3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher
|
||||||
|
before allowing git/pytest-oriented work; on failure it returns
|
||||||
|
`BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics.
|
||||||
|
4. Emit the canonical `blocked-diagnose-report.md` template and stop.
|
||||||
|
|
||||||
### Create an issue / child issues
|
### Create an issue / child issues
|
||||||
|
|
||||||
- **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.`
|
||||||
|
|
||||||
@@ -665,10 +739,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.`
|
||||||
|
|
||||||
@@ -716,6 +790,53 @@ merger handoff.
|
|||||||
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
||||||
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||||
|
|
||||||
|
### Post-merge merged cleanup ownership (#523)
|
||||||
|
|
||||||
|
Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not
|
||||||
|
author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
||||||
|
`gitea_reconcile_merged_cleanups`.
|
||||||
|
|
||||||
|
- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` /
|
||||||
|
`reconciliation_cleanup`).
|
||||||
|
- **Namespace:** reconciler MCP server; stable control checkout is allowed for
|
||||||
|
this role (branches-only author guard does not apply).
|
||||||
|
- **Steps:**
|
||||||
|
1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`.
|
||||||
|
2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`.
|
||||||
|
3. Execute only after audit/authorization gates when remote branch delete or
|
||||||
|
worktree removal is required (`dry_run=False`, `execute_confirmed=True`,
|
||||||
|
and `gitea.branch.delete` when deleting remotes).
|
||||||
|
- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged
|
||||||
|
closed PRs must not be cleaned.
|
||||||
|
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||||
|
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||||
|
|
||||||
|
### Superseded PR / satisfied issue reconciliation (#525)
|
||||||
|
|
||||||
|
Closing duplicate or superseded PRs after a canonical PR has merged is
|
||||||
|
**reconciler** work. Do not switch to `prgs-author` only to close the duplicate
|
||||||
|
PR, close the satisfied issue, or file a narrow follow-up discovered during
|
||||||
|
reconciliation.
|
||||||
|
|
||||||
|
- **Profile:** `prgs-reconciler`.
|
||||||
|
- **Tasks:** `reconcile_close_superseded_pr`,
|
||||||
|
`reconcile_close_satisfied_issue`, and
|
||||||
|
`reconcile_create_followup_issue`.
|
||||||
|
- **Tool:** `gitea_reconcile_superseded_by_merged_pr`.
|
||||||
|
- **Required proof:**
|
||||||
|
1. Live target PR state is open, but it is not independently required and no
|
||||||
|
mergeable work remains.
|
||||||
|
2. Live superseding PR state is closed and merged.
|
||||||
|
3. Superseding PR head is an ancestor of freshly fetched `master`.
|
||||||
|
4. Merge commit SHA is recorded.
|
||||||
|
5. Canonical close comment cites the superseding PR and merge commit.
|
||||||
|
6. Linked issue closure is attempted only when the issue is open and
|
||||||
|
explicitly satisfied by the superseding PR.
|
||||||
|
- **Fail closed:** missing ancestry proof, missing canonical comment,
|
||||||
|
mergeable target PR, same target/superseding PR, or missing
|
||||||
|
`gitea.pr.close` / `gitea.issue.close` capability.
|
||||||
|
- **Prompt:** `As prgs-reconciler, reconcile PR #N as superseded by merged PR #M; post the canonical close comment, close the superseded PR, and close issue #K only if the merged PR fully satisfies it.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
|
|
||||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||||
@@ -736,7 +857,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 |
|
||||||
@@ -786,6 +907,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,
|
||||||
@@ -818,6 +960,161 @@ 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.
|
||||||
|
|
||||||
|
## Two-comment workflow reporting (#507)
|
||||||
|
|
||||||
|
After meaningful controller/workflow work, post **two separate Gitea comments**
|
||||||
|
(not one combined blob):
|
||||||
|
|
||||||
|
1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the
|
||||||
|
next LLM/controller (proof-heavy; may be long).
|
||||||
|
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||||
|
|
||||||
|
The ledger must answer: what is true now, what changed, what is blocked,
|
||||||
|
who/what acts next — and must **separate**:
|
||||||
|
|
||||||
|
- local verdict/state
|
||||||
|
- server-side Gitea state
|
||||||
|
- attempted-but-blocked mutations
|
||||||
|
- completed mutations
|
||||||
|
|
||||||
|
Use precise state phrases (`APPROVED review posted to Gitea`,
|
||||||
|
`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`,
|
||||||
|
`no server-side state changed`, `lease attempt blocked`) instead of ambiguous
|
||||||
|
standalone words (`approved`, `merged`, `ready`, `blocked`, `done`).
|
||||||
|
|
||||||
|
The ledger must include a **blocker classification** from:
|
||||||
|
`code blocker`, `test blocker`, `merge conflict`, `stale head`,
|
||||||
|
`permission/capability blocker`, `environment/tooling blocker`,
|
||||||
|
`process/rule blocker`, `queue/lease blocker`,
|
||||||
|
`duplicate/canonicalization blocker`, `no blocker`.
|
||||||
|
|
||||||
|
Templates: [`two-comment-workflow.md`](two-comment-workflow.md).
|
||||||
|
Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md).
|
||||||
|
|
||||||
|
Validation: `thread_state_ledger_validator.py` checks tagged comments at post
|
||||||
|
time (`gitea_create_issue_comment`) and tagged final reports via
|
||||||
|
`assess_final_report_validator`. Legacy `## Controller Handoff` final reports
|
||||||
|
remain valid during transition; the tagged pair is required for new workflow
|
||||||
|
comments.
|
||||||
|
|
||||||
|
Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger
|
||||||
|
consistency, #505 CTH umbrella, #496 workflow comment gate when merged.
|
||||||
|
|
||||||
|
## Canonical comment validation (#496)
|
||||||
|
|
||||||
|
Workflow-changing issue/PR/review comments must carry durable next-action
|
||||||
|
state. Casual discussion is still allowed.
|
||||||
|
|
||||||
|
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
|
||||||
|
**before** posting through:
|
||||||
|
|
||||||
|
- `gitea_create_issue_comment`
|
||||||
|
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
|
||||||
|
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
|
||||||
|
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
|
||||||
|
|
||||||
|
Detection examples:
|
||||||
|
|
||||||
|
- **Allowed:** `Thanks, I will check this.`
|
||||||
|
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
|
||||||
|
|
||||||
|
When validation fails, the tool returns `canonical_comment_validation` with
|
||||||
|
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
|
||||||
|
`suggested_template`. **No Gitea API call is made.**
|
||||||
|
|
||||||
|
Minimum workflow comment fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STATE:
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
NEXT_ACTION:
|
||||||
|
NEXT_PROMPT:
|
||||||
|
WHY:
|
||||||
|
```
|
||||||
|
|
||||||
|
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
|
||||||
|
`reconciler`, `user`.
|
||||||
|
|
||||||
|
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
|
||||||
|
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
|
||||||
|
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
|
||||||
|
|
||||||
|
Special states:
|
||||||
|
|
||||||
|
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
|
||||||
|
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
|
||||||
|
- `STATE: ready-to-merge` — requires approval proof and head SHA in
|
||||||
|
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
|
||||||
|
|
||||||
|
Final reports must not claim a comment was posted when
|
||||||
|
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||||
|
|
||||||
|
## Stale #332 review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
#332 hard-stops a reviewer session after a terminal live review mutation
|
||||||
|
(`approve` / `request_changes`). After #559 those locks are **durable** under
|
||||||
|
`~/.cache/gitea-tools/session-state/review_decision_lock-<profile>.json`, so they
|
||||||
|
can outlive the PR they protected.
|
||||||
|
|
||||||
|
### When cleanup is **allowed**
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` from a **reviewer** profile when:
|
||||||
|
|
||||||
|
1. A durable review-decision lock has a terminal live mutation, **and**
|
||||||
|
2. Live Gitea state shows that terminal PR is already **merged** or **closed**
|
||||||
|
(so no same-PR merge sequence remains), **and**
|
||||||
|
3. The active profile identity matches the lock, **and**
|
||||||
|
4. Authenticated identity can be verified.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1) Assess only (default)
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=prgs, ...)
|
||||||
|
|
||||||
|
# 2) Apply after is_moot=true and cleanup_allowed=true
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<merged-or-closed-pr>,
|
||||||
|
remote=prgs,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful apply:
|
||||||
|
|
||||||
|
* clears in-memory + durable decision lock for that profile
|
||||||
|
* returns a structured `audit` payload
|
||||||
|
* posts an audit comment on the mooted PR when `gitea.pr.comment` is allowed
|
||||||
|
(`post_audit_comment=true` default)
|
||||||
|
|
||||||
|
Same-profile auto-expire: if `gitea_merge_pr` succeeds on a PR that this same
|
||||||
|
profile just approved, the decision lock is cleared after merge. Cross-profile
|
||||||
|
locks (reviewer vs merger) still require the cleanup tool.
|
||||||
|
|
||||||
|
### When cleanup is **forbidden**
|
||||||
|
|
||||||
|
Do **not** clear when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active #332 hard-stop — continue merge
|
||||||
|
for that approve, or stop after request_changes)
|
||||||
|
* live PR state cannot be fetched (fail closed)
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* identity cannot be verified
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
**Never** use manual deletion of session-state files as the normal workflow.
|
||||||
|
**Never** use cleanup to skip review of an open PR or to bypass eligibility /
|
||||||
|
mark-ready / submit gates on active work.
|
||||||
|
|
||||||
|
### Related tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `gitea_cleanup_stale_review_decision_lock` | Moot decision-lock cleanup (#594) |
|
||||||
|
| `gitea_authorize_review_correction` | Operator-approved correction after a **mistaken** live review on still-active work (#211) |
|
||||||
|
| `gitea_cleanup_post_merge_moot_lease` | Moot **lease** cleanup after merge (#515) — different object |
|
||||||
|
|
||||||
## 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,
|
||||||
@@ -929,6 +1226,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.
|
||||||
@@ -939,6 +1237,21 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||||
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
|
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
|
||||||
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
|
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
|
||||||
|
- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494).
|
||||||
|
|
||||||
|
## Canonical state handoff ledger (#494)
|
||||||
|
|
||||||
|
Gitea comments and final reports must make continuation obvious without chat
|
||||||
|
history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for:
|
||||||
|
|
||||||
|
- discussion → issue → PR → review → merge → reconcile lifecycle
|
||||||
|
- templates for discussion, issue, PR, and queue-controller state comments
|
||||||
|
- final-report requirements: Current status, Next actor, Next action, Next prompt
|
||||||
|
- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial
|
||||||
|
exceptions)
|
||||||
|
|
||||||
|
Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired
|
||||||
|
through `assess_final_report_validator`.
|
||||||
|
|
||||||
## PR-only queue cleanup mode (#390)
|
## PR-only queue cleanup mode (#390)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
-1
@@ -41,7 +41,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||||
| Reviewer workflow prompts | PR review prompt (review-only; no merge). |
|
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||||
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
||||||
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
|
||||||
|
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
|
||||||
|
|
||||||
|
```
|
||||||
|
client is closing: EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subsequent call to that same namespace returns the same error, including
|
||||||
|
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
|
||||||
|
servers registered with the same client (for example `context7`) keep working,
|
||||||
|
so this is **not** a global MCP-client outage.
|
||||||
|
|
||||||
|
## Why this is not a code defect
|
||||||
|
|
||||||
|
This failure is a **transport-level** condition in the IDE / MCP client manager,
|
||||||
|
not a missing or broken tool:
|
||||||
|
|
||||||
|
- The tool can be present and registered in the Python `FastMCP` tool manager.
|
||||||
|
- Direct Python inspection of the server confirms the tool exists.
|
||||||
|
- Running the server manually and sending JSON-RPC over stdio works fine
|
||||||
|
(offline spawn) — that path does **not** prove the IDE namespace is healthy.
|
||||||
|
|
||||||
|
The client manager entered a closed state after the backing subprocess for that
|
||||||
|
namespace terminated (or was killed) behind its back. Once closed, the client
|
||||||
|
does **not** re-spawn the child on the next tool call — it just replays
|
||||||
|
`client is closing: EOF`. The OS process may even still be alive if a parent
|
||||||
|
language-server process is holding the stdio pipes open.
|
||||||
|
|
||||||
|
This is the canonical "registered in FastMCP ≠ callable through the namespace"
|
||||||
|
false-ready state. It is distinct from the **stale-runtime** family in #531 /
|
||||||
|
#544, where the process is reachable but running behind `master`; that case is
|
||||||
|
detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in
|
||||||
|
`gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one,
|
||||||
|
so the `ps` check alone will not surface it.
|
||||||
|
|
||||||
|
## Recovery path (canonical — client reconnect only)
|
||||||
|
|
||||||
|
Do the steps in order. Stop as soon as a live **client-namespace** call succeeds.
|
||||||
|
|
||||||
|
1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a
|
||||||
|
different MCP server (e.g. `context7`).
|
||||||
|
- Only the Gitea namespace fails → single-namespace transport close. Continue.
|
||||||
|
- Every server fails → restart the whole MCP client, not just one namespace.
|
||||||
|
|
||||||
|
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
|
||||||
|
client MCP-reconnect action for that server entry (in Claude Code:
|
||||||
|
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
|
||||||
|
client to spawn a fresh subprocess and re-open the pipe. This clears the
|
||||||
|
closed-client state that a bare `kill`/respawn from a terminal does **not**.
|
||||||
|
|
||||||
|
3. **Do not "fix" it by importing the server or poking the process.** Reaching
|
||||||
|
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
|
||||||
|
killing PIDs to force a respawn, or touching MCP config mtimes does **not**
|
||||||
|
restore the *client's* view of the namespace and violates the daemon-import
|
||||||
|
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
|
||||||
|
is a **client reconnect / relaunch**.
|
||||||
|
|
||||||
|
4. **Verify through the same path the workflow will use.** After reconnect, call
|
||||||
|
the specific tool the blocked workflow needs — not just any tool — through
|
||||||
|
the target namespace. For a merge that means calling the merger-authorized
|
||||||
|
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
|
||||||
|
namespace does **not** prove another namespace or another tool is callable.
|
||||||
|
Record success with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(..., probe_source="client_namespace")
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **If reconnect does not clear it,** relaunch the client entirely, then repeat
|
||||||
|
step 4. If EOF persists after a full relaunch, the backing subprocess is
|
||||||
|
failing to start — inspect its stderr / launch config (command path, venv,
|
||||||
|
`*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still
|
||||||
|
do not use PID kill or config-touch as the primary recovery.
|
||||||
|
|
||||||
|
## Diagnostics to capture when reporting EOF
|
||||||
|
|
||||||
|
Include all of these so the failure is actionable and reproducible:
|
||||||
|
|
||||||
|
- **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
|
||||||
|
`gitea-merger` / `gitea-tools`).
|
||||||
|
- **Tool** that was called and the **exact** error string.
|
||||||
|
- **PID** of the backing process (if any) and whether it was still alive
|
||||||
|
(informational only — not a recovery action).
|
||||||
|
- **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
|
||||||
|
worktree binding such as `GITEA_AUTHOR_WORKTREE`).
|
||||||
|
- **Config path** the client launched the server from.
|
||||||
|
- Result of the **cross-server control** call (did `context7` succeed?).
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
|
||||||
|
subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
|
||||||
|
classifies with `probe_source=offline_spawn`. That is useful for offline
|
||||||
|
launch/registration debugging. It is **not** proof the IDE-managed namespace is
|
||||||
|
healthy. See `docs/mcp-namespace-health.md`.
|
||||||
|
|
||||||
|
## Do-not list during EOF recovery
|
||||||
|
|
||||||
|
- Do **not** retry a blocked merge/adoption until the required tool is confirmed
|
||||||
|
callable through the merger-authorized **client** namespace (see #543).
|
||||||
|
- Do **not** clean, reset, or rebind a **foreign** worktree to work around the
|
||||||
|
error.
|
||||||
|
- Do **not** bypass the namespace with direct imports, raw API/curl, or
|
||||||
|
in-memory state restoration.
|
||||||
|
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||||
|
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||||
|
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||||
|
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MCP namespace health diagnostics (#543)
|
||||||
|
|
||||||
|
Gitea MCP tools can be registered in the Python FastMCP server while the IDE's
|
||||||
|
live MCP namespace is still unusable. The failure usually appears as
|
||||||
|
`client is closing: EOF`, `transport closed`, or an empty response when calling
|
||||||
|
a tool such as `gitea_whoami`.
|
||||||
|
|
||||||
|
Do not treat static tool registration as proof that review or merge workflows
|
||||||
|
can proceed. A reviewer or merger flow must have **client-namespace** evidence
|
||||||
|
that the required tool is callable through the configured IDE MCP namespace.
|
||||||
|
|
||||||
|
## Probe sources (do not confuse them)
|
||||||
|
|
||||||
|
| Source | How obtained | Proves IDE namespace? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** |
|
||||||
|
| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) |
|
||||||
|
|
||||||
|
`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets
|
||||||
|
`ide_namespace_proven=true` for `client_namespace` success. Offline spawn
|
||||||
|
success never clears review/merge mutation gates.
|
||||||
|
|
||||||
|
## Client-namespace health check (canonical)
|
||||||
|
|
||||||
|
1. Through the IDE client, call a cheap tool on the target namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`).
|
||||||
|
2. Feed the live result into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(
|
||||||
|
namespace="gitea-merger", # or reviewer / author / tools
|
||||||
|
registered_tools=[...], # optional static list
|
||||||
|
probe_result={"success": true, "result": {...}},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. A healthy client-namespace assessment is recorded in the MCP session and
|
||||||
|
consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates.
|
||||||
|
4. If the probe fails with EOF, recover via **client reconnect only** — see
|
||||||
|
`docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP
|
||||||
|
config mtimes as a recovery procedure.
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This script spawns a **separate** server process from config, performs
|
||||||
|
JSON-RPC `initialize` → `tools/list` → `tools/call`, and classifies the
|
||||||
|
result with `probe_source=offline_spawn`. Use it for offline debugging of
|
||||||
|
launch command / registration. It does **not** prove the IDE-managed
|
||||||
|
namespace is healthy.
|
||||||
|
|
||||||
|
By default the script checks:
|
||||||
|
|
||||||
|
| Namespace | Required tool |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea-author` | `gitea_whoami` |
|
||||||
|
| `gitea-reviewer` | `gitea_whoami` |
|
||||||
|
| `gitea-merger` | `gitea_whoami` |
|
||||||
|
| `gitea-tools` | `gitea_list_profiles` |
|
||||||
|
|
||||||
|
## Recovery (canonical)
|
||||||
|
|
||||||
|
When a namespace returns EOF, follow
|
||||||
|
`docs/mcp-namespace-eof-recovery.md` in order:
|
||||||
|
|
||||||
|
1. Confirm blast radius (Gitea namespace vs all MCP servers).
|
||||||
|
2. **Reconnect the namespace through the client** (IDE reconnect / relaunch).
|
||||||
|
3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime
|
||||||
|
touches — those do not restore the client's closed transport.
|
||||||
|
4. Re-verify the **specific** required tool through the target namespace.
|
||||||
|
5. Resume review/merge only after a successful `client_namespace` assessment.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
1. **State machine (read-only):** feed `blocks_merge_workflow` from a
|
||||||
|
`client_namespace` assessment into
|
||||||
|
`gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
|
||||||
|
2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
|
||||||
|
`_live_namespace_health_gate` and fail closed when the session has a
|
||||||
|
recorded unhealthy or non-client probe for the required namespace
|
||||||
|
(`gitea-reviewer` for review, `gitea-merger` for merge).
|
||||||
|
|
||||||
|
When blocked, repair the IDE namespace and re-record a healthy
|
||||||
|
`client_namespace` assessment before retrying the mutation.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Canonical state handoff ledger (#494)
|
||||||
|
|
||||||
|
Gitea is the system of record for continuation. Every discussion, issue, and PR
|
||||||
|
should answer: current state, last proof, who acts next, and the exact prompt for
|
||||||
|
the next role.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
1. Controller opens a discussion.
|
||||||
|
2. Discussion accumulates substantive comments (default minimum: five).
|
||||||
|
3. Controller posts a discussion summary when ready.
|
||||||
|
4. Controller creates linked issues from the summary.
|
||||||
|
5. Author locks issue, implements, opens PR.
|
||||||
|
6. Reviewer reviews at current head.
|
||||||
|
7. Merger merges after formal approval.
|
||||||
|
8. Reconciler closes superseded or already-landed PRs.
|
||||||
|
9. Issue/PR/discussion state comments make the next action obvious at every step.
|
||||||
|
|
||||||
|
## Discussion rules
|
||||||
|
|
||||||
|
- Do **not** convert a discussion into issues until it has at least **five
|
||||||
|
substantive comments**, unless the discussion state comment marks
|
||||||
|
`URGENCY: urgent` or `URGENCY: trivial`.
|
||||||
|
- Substantive comment types: proposal, risk/concern, acceptance criteria,
|
||||||
|
implementation approach, dependency/sequence, summary.
|
||||||
|
- Before issue creation, post a **discussion summary** with decision, issues to
|
||||||
|
create, non-goals, unresolved questions, and next prompt.
|
||||||
|
- Created issues must link back to the discussion; the discussion must link
|
||||||
|
forward to created issues.
|
||||||
|
|
||||||
|
## State comment templates
|
||||||
|
|
||||||
|
Use `state_handoff_ledger.py` helpers or copy the canonical blocks:
|
||||||
|
|
||||||
|
- `render_discussion_state_comment(...)`
|
||||||
|
- `render_discussion_summary_comment(...)`
|
||||||
|
- `render_issue_state_comment(...)`
|
||||||
|
- `render_pr_state_comment(...)`
|
||||||
|
- `render_queue_controller_report(...)`
|
||||||
|
|
||||||
|
Post state comments as the **latest canonical update** on the object. Do not
|
||||||
|
bury state inside PR bodies only.
|
||||||
|
|
||||||
|
## Final report requirements
|
||||||
|
|
||||||
|
Every final report must include a `Controller Handoff` section with:
|
||||||
|
|
||||||
|
- **Current status** — live state after this session
|
||||||
|
- **Next actor** — `author`, `reviewer`, `merger`, `reconciler`, or `controller`
|
||||||
|
- **Next action** — one imperative step
|
||||||
|
- **Next prompt** — ready-to-paste prompt for the next role
|
||||||
|
|
||||||
|
`assess_final_report_next_action_handoff` and
|
||||||
|
`assess_contradictory_state_handoff` enforce these fields and reject
|
||||||
|
contradictory claims (for example, ready-to-merge without approval, issue done
|
||||||
|
without PR proof, discussion complete without summary).
|
||||||
|
|
||||||
|
## Queue controller selection (priority order)
|
||||||
|
|
||||||
|
1. Merge clean approved PRs at current head.
|
||||||
|
2. Review PRs needing review.
|
||||||
|
3. Reconcile superseded or already-landed duplicates.
|
||||||
|
4. Continue blocked-but-now-unblocked issues.
|
||||||
|
5. Create issues from completed discussions.
|
||||||
|
6. Start new author work only when higher-priority queue items are clear.
|
||||||
|
|
||||||
|
For each candidate object, read the **latest canonical state comment** before
|
||||||
|
choosing an action. Output the exact next-role prompt in the controller report.
|
||||||
|
|
||||||
|
## Example workflow states
|
||||||
|
|
||||||
|
| State | Next actor | Typical next action |
|
||||||
|
|-------|------------|---------------------|
|
||||||
|
| Discussion needs more comments | controller | Facilitate discussion until five substantive comments or urgent/trivial exception |
|
||||||
|
| Issue ready for author | author | Lock issue and implement in `branches/` worktree |
|
||||||
|
| PR needs review | reviewer | Review at pinned head in reviewer worktree |
|
||||||
|
| PR approved at head | merger | Merge with merger profile after eligibility proof |
|
||||||
|
| PR superseded | reconciler | Close duplicate/already-landed PR with proof |
|
||||||
|
| Issue blocked | controller | Post issue state comment with blockers and next prompt |
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Chat history is not the source of truth.
|
||||||
|
- Do not weaken author/reviewer/merger/reconciler separation.
|
||||||
|
- Do not replace Gitea issues, PRs, or canonical workflow files under
|
||||||
|
`skills/llm-project-workflow/`.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Two-comment workflow: Controller Handoff + Thread State Ledger
|
||||||
|
|
||||||
|
After meaningful controller/workflow work, post **two separate Gitea comments**:
|
||||||
|
|
||||||
|
1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next
|
||||||
|
LLM/controller session (proof-heavy; may be long).
|
||||||
|
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||||
|
|
||||||
|
The ledger is the concise source of truth. The handoff is the detailed
|
||||||
|
continuation artifact. This complements CTH (#505) and lifecycle state
|
||||||
|
comments (#494/#495) without replacing them.
|
||||||
|
|
||||||
|
## Controller Handoff template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[CONTROLLER HANDOFF] PR #___ / Issue #___ — <short title>
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
This comment is the operational handoff for the next controller/LLM session.
|
||||||
|
|
||||||
|
Identity/profile:
|
||||||
|
- Active profile:
|
||||||
|
- Authenticated identity:
|
||||||
|
- Role:
|
||||||
|
- Self-review / role-conflict proof:
|
||||||
|
|
||||||
|
Target:
|
||||||
|
- Repo:
|
||||||
|
- Issue:
|
||||||
|
- PR:
|
||||||
|
- Branch:
|
||||||
|
- Pinned head SHA:
|
||||||
|
- Worktree:
|
||||||
|
|
||||||
|
Work performed:
|
||||||
|
- <step 1>
|
||||||
|
- <step 2>
|
||||||
|
|
||||||
|
Files touched or reviewed:
|
||||||
|
- `<file>` — <why it matters>
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- `<command>` → <result>
|
||||||
|
- Full suite: <result or not run + reason>
|
||||||
|
|
||||||
|
Server-side mutation ledger:
|
||||||
|
- <mutation 1, including tool/action/comment id if available>
|
||||||
|
- Or: none — no server-side state changed
|
||||||
|
|
||||||
|
Local-only changes:
|
||||||
|
- <worktree created, files edited locally, etc.>
|
||||||
|
- Or: none
|
||||||
|
|
||||||
|
Blockers:
|
||||||
|
- <none>
|
||||||
|
- Or: <exact blocker, exact gate, exact reason>
|
||||||
|
|
||||||
|
Controller prompt for next session:
|
||||||
|
```markdown
|
||||||
|
<ready-to-paste prompt>
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Thread State Ledger template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line>
|
||||||
|
|
||||||
|
What is true now:
|
||||||
|
- PR state:
|
||||||
|
- Issue state:
|
||||||
|
- Current head SHA:
|
||||||
|
- Server-side decision state:
|
||||||
|
- Local verdict/state:
|
||||||
|
- Latest known validation:
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
- <short summary since prior ledger>
|
||||||
|
|
||||||
|
What is blocked:
|
||||||
|
- Blocker classification: <see list below>
|
||||||
|
|
||||||
|
Who/what acts next:
|
||||||
|
- Next actor:
|
||||||
|
- Required action:
|
||||||
|
- Do not do:
|
||||||
|
- Resume from:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocker classifications
|
||||||
|
|
||||||
|
- code blocker
|
||||||
|
- test blocker
|
||||||
|
- merge conflict
|
||||||
|
- stale head
|
||||||
|
- permission/capability blocker
|
||||||
|
- environment/tooling blocker
|
||||||
|
- process/rule blocker
|
||||||
|
- queue/lease blocker
|
||||||
|
- duplicate/canonicalization blocker
|
||||||
|
- no blocker
|
||||||
|
|
||||||
|
### Precise state language
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
- `APPROVE verdict prepared locally`
|
||||||
|
- `APPROVED review posted to Gitea`
|
||||||
|
- `REQUEST_CHANGES posted to Gitea`
|
||||||
|
- `merge performed` / `merge not performed`
|
||||||
|
- `lease acquired` / `lease attempt blocked`
|
||||||
|
- `server-side state changed` / `no server-side state changed`
|
||||||
|
|
||||||
|
Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`,
|
||||||
|
`done`) without proof and server-side state separation.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `thread_state_ledger_validator.py` — comment and final-report checks
|
||||||
|
- `gitea_create_issue_comment` — fail-closed gate on tagged comments
|
||||||
|
- `assess_final_report_validator` — `shared.two_comment_workflow` rule
|
||||||
|
|
||||||
|
Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Web UI deployment boundary (#435)
|
||||||
|
|
||||||
|
The MCP Control Plane web UI is an **internal operator console**, not a
|
||||||
|
customer-facing application. The MVP assumes local or trusted-network access
|
||||||
|
only.
|
||||||
|
|
||||||
|
## MVP deployment model
|
||||||
|
|
||||||
|
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||||
|
- **Authentication:** none in MVP — protection comes from network placement
|
||||||
|
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||||
|
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||||
|
embedded in HTML, JavaScript, or browser storage
|
||||||
|
|
||||||
|
Do **not** expose the UI on the public internet without an access layer.
|
||||||
|
|
||||||
|
## Beyond localhost
|
||||||
|
|
||||||
|
If the UI must be reachable outside the operator laptop:
|
||||||
|
|
||||||
|
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
|
||||||
|
authenticated staff reach the service.
|
||||||
|
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
|
||||||
|
without understanding the exposure.
|
||||||
|
3. Set explicit override env vars only after access controls are in place:
|
||||||
|
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
|
||||||
|
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
|
||||||
|
|
||||||
|
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
|
||||||
|
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
|
||||||
|
| `WEBUI_PORT` | `8765` | Listen port |
|
||||||
|
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
|
||||||
|
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
|
||||||
|
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
|
||||||
|
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
|
||||||
|
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
|
||||||
|
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
|
||||||
|
|
||||||
|
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
|
||||||
|
the server when a page needs live Gitea data (e.g. `/queue`). They are not
|
||||||
|
shipped to the browser.
|
||||||
|
|
||||||
|
## Health / deployment metadata
|
||||||
|
|
||||||
|
`GET /health` includes a `deployment` object with bind disposition, runtime
|
||||||
|
assumption paths, and the client-secret policy. Use it to verify an instance is
|
||||||
|
configured for internal-only operation.
|
||||||
|
|
||||||
|
## Non-goals (MVP)
|
||||||
|
|
||||||
|
- Full SSO or session login in the UI
|
||||||
|
- Hosting on the public internet without Access/VPN/WARP
|
||||||
|
- Embedding Gitea tokens in the frontend bundle
|
||||||
+114
-14
@@ -29,6 +29,13 @@ Optional environment variables:
|
|||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||||
| `WEBUI_PORT` | `8765` | Listen port |
|
| `WEBUI_PORT` | `8765` | Listen port |
|
||||||
|
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
|
||||||
|
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
|
||||||
|
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
|
||||||
|
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
|
||||||
|
|
||||||
|
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||||
|
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||||
|
|
||||||
## Routes (MVP)
|
## Routes (MVP)
|
||||||
|
|
||||||
@@ -45,13 +52,27 @@ Optional environment variables:
|
|||||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||||
| `/api/runtime` | JSON runtime health export |
|
| `/api/runtime` | JSON runtime health export |
|
||||||
| `/audit` | Stub — report audit paste (#431) |
|
| `/audit` | Report audit paste + validator preview (#431) |
|
||||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
||||||
|
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
||||||
|
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
||||||
|
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
|
||||||
|
| `/api/actions` | JSON action registry with capability metadata |
|
||||||
|
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||||
| `/leases` | Lease and collision visibility (#433) |
|
| `/leases` | Lease and collision visibility (#433) |
|
||||||
| `/api/leases` | JSON lease/collision export |
|
| `/api/leases` | JSON lease/collision export |
|
||||||
|
|
||||||
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||||
`read-only-mvp`.
|
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||||
|
local validator preview only (no Gitea mutations, no server-side storage).
|
||||||
|
|
||||||
|
## Report audit (#431)
|
||||||
|
|
||||||
|
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||||
|
reuses `final_report_validator` and review schema checks to surface missing
|
||||||
|
proof fields, wrong validation vocabulary, mutation contradictions, and a
|
||||||
|
suggested next prompt or issue-comment draft. Task kind can be auto-detected
|
||||||
|
or selected explicitly.
|
||||||
|
|
||||||
## Project registry (#427)
|
## Project registry (#427)
|
||||||
|
|
||||||
@@ -84,14 +105,25 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
|||||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||||
instead of an empty queue (fail closed).
|
instead of an empty queue (fail closed).
|
||||||
|
|
||||||
## Runtime health (#430)
|
## Gated actions (#434)
|
||||||
|
|
||||||
|
`/actions` registers future write actions (claim, comment, review, merge,
|
||||||
|
delete branch, create PR/issue). Each entry declares the MCP tool, required
|
||||||
|
permission, and profile role from `task_capability_map.py` — aligned with
|
||||||
|
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
|
||||||
|
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
|
||||||
|
MCP tools.
|
||||||
|
|
||||||
|
## Worktree hygiene (#432)
|
||||||
|
|
||||||
|
`/worktrees` scans local `branches/` directories and registered git worktrees.
|
||||||
|
Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`,
|
||||||
|
`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees
|
||||||
|
referenced by the issue lock file are flagged as anomalies (#404). The page
|
||||||
|
includes a copy/paste canonical cleanup prompt only — no deletion actions.
|
||||||
|
|
||||||
|
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
|
||||||
|
|
||||||
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
|
||||||
project: active profile and role kind, authenticated identity (when credentials
|
|
||||||
are available), config model/mode, local vs remote `master` SHA sync, shell
|
|
||||||
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
|
||||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
|
||||||
no tokens or MCP restart actions are exposed.
|
|
||||||
## Lease visibility (#433)
|
## Lease visibility (#433)
|
||||||
|
|
||||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||||
@@ -100,9 +132,77 @@ in-progress claim inventory (#268), reviewer PR lease comments when present
|
|||||||
and duplicate local branches per issue. Links to collision-history backend
|
and duplicate local branches per issue. Links to collision-history backend
|
||||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||||
|
|
||||||
|
## Runtime health (#430)
|
||||||
|
|
||||||
|
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||||
|
project: active profile and role kind, authenticated identity (when credentials
|
||||||
|
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||||
|
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||||
|
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||||
|
no tokens or MCP restart actions are exposed.
|
||||||
|
|
||||||
|
## Deployment boundary (#435)
|
||||||
|
|
||||||
|
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||||
|
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
|
||||||
|
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_runtime_health.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||||
```
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
|
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
|
||||||
|
|
||||||
|
## Tests (#436)
|
||||||
|
|
||||||
|
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-webui
|
||||||
|
```
|
||||||
|
|
||||||
|
CI / Jenkins multibranch can call the path-filtered gate (runs only when the
|
||||||
|
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/ci-webui-check
|
||||||
|
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
|
||||||
|
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
|
||||||
|
credentials, so CI can run without MCP daemon credential access. Set
|
||||||
|
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.
|
||||||
|
|
||||||
|
Or invoke unittest directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lease visibility (#433)
|
||||||
|
|
||||||
|
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||||
|
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||||
|
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||||
|
and duplicate local branches per issue. Links to collision-history backend
|
||||||
|
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||||
|
|
||||||
|
## Runtime health (#430)
|
||||||
|
|
||||||
|
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||||
|
project: active profile and role kind, authenticated identity (when credentials
|
||||||
|
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||||
|
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||||
|
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||||
|
no tokens or MCP restart actions are exposed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,7 @@
|
|||||||
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
||||||
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
||||||
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
||||||
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
8. **Terminal review hard-stop (#332)** — After a terminal live review mutation, only same-PR merge after `approve` may continue. Durable locks (#559) must not be deleted by hand.
|
||||||
9. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
9. **Stale decision-lock cleanup (#594)** — `gitea_cleanup_stale_review_decision_lock` may clear a durable #332 lock **only** when the last terminal mutation's PR is live-state **merged or closed**, identity/profile gates pass, and apply uses a reviewer-capable profile. Open/ambiguous locks stay fail-closed. Successful cleanup records a durable audit trail.
|
||||||
|
10. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||||
|
11. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Workflow skill mount across runtimes (#551)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Controller prompts require **`gitea-workflow`**, but:
|
||||||
|
|
||||||
|
- Claude may load `~/.claude/skills/gitea-workflow`
|
||||||
|
- Codex often has **no** `~/.codex/skills/gitea-workflow`
|
||||||
|
- The portable package in-repo is `skills/llm-project-workflow`
|
||||||
|
- `mcp_list_project_skills` historically listed operational guides only, not
|
||||||
|
the workflow router
|
||||||
|
|
||||||
|
Sessions then either **block** incorrectly or **proceed without** the workflow
|
||||||
|
wall.
|
||||||
|
|
||||||
|
## Canonical names (must resolve to the same skill)
|
||||||
|
|
||||||
|
| Name | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `gitea-workflow` | **Primary** controller / Codex skill name |
|
||||||
|
| `llm-project-workflow` | Portable in-repo package name |
|
||||||
|
| `git-pr-workflows` | Legacy alias |
|
||||||
|
|
||||||
|
Source of truth: `skills/llm-project-workflow/SKILL.md`
|
||||||
|
In-repo alias stub: `skills/gitea-workflow/SKILL.md`
|
||||||
|
|
||||||
|
## Codex install
|
||||||
|
|
||||||
|
From a `branches/` worktree (or any clone of the repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-codex-workflow-skill.sh
|
||||||
|
# optional:
|
||||||
|
./scripts/install-codex-workflow-skill.sh --dry-run
|
||||||
|
./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills"
|
||||||
|
```
|
||||||
|
|
||||||
|
This symlinks the portable package under all three names. **Restart Codex**
|
||||||
|
after install.
|
||||||
|
|
||||||
|
## MCP discovery
|
||||||
|
|
||||||
|
- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`,
|
||||||
|
and `git-pr-workflows`.
|
||||||
|
- `mcp_get_skill_guide("<name>")` returns the same router steps for each.
|
||||||
|
- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and
|
||||||
|
reports Codex mount status.
|
||||||
|
|
||||||
|
## Preflight rule
|
||||||
|
|
||||||
|
Before any git or Gitea mutation:
|
||||||
|
|
||||||
|
1. Call `mcp_check_workflow_skill_preflight`.
|
||||||
|
2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do
|
||||||
|
not mutate.
|
||||||
|
3. Load the skill by **any** canonical name and follow the router.
|
||||||
|
|
||||||
|
Missing Codex mount alone does not block if the in-repo skill is present and
|
||||||
|
loaded via MCP/docs; operators should still install the Codex symlink so
|
||||||
|
prompt names resolve natively.
|
||||||
|
|
||||||
|
## Controller prompts
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`).
|
||||||
|
|
||||||
|
Do not require a name that is only available on one runtime.
|
||||||
@@ -11,8 +11,12 @@ import inspect
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import branch_cleanup_guard
|
||||||
import issue_acceptance_gate
|
import issue_acceptance_gate
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
|
import merger_lease_adoption
|
||||||
|
import reviewer_handoff_consistency
|
||||||
|
import thread_state_ledger_validator
|
||||||
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 (
|
||||||
@@ -24,35 +28,45 @@ from review_proofs import (
|
|||||||
assess_review_mutation_final_report,
|
assess_review_mutation_final_report,
|
||||||
assess_validation_report,
|
assess_validation_report,
|
||||||
)
|
)
|
||||||
|
from state_handoff_ledger import assess_state_handoff_ledger_report
|
||||||
from validation_status_vocabulary import assess_validation_status_vocabulary
|
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",
|
||||||
"issue_filing",
|
"issue_filing",
|
||||||
"issue_selection",
|
"issue_selection",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
"controller_close",
|
||||||
})
|
})
|
||||||
|
|
||||||
_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",
|
||||||
"create_issue": "issue_filing",
|
"create_issue": "issue_filing",
|
||||||
|
"close_issue": "controller_close",
|
||||||
|
"close-issue": "controller_close",
|
||||||
|
"controller-close": "controller_close",
|
||||||
}
|
}
|
||||||
|
|
||||||
_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",
|
||||||
"issue_filing": "issue_filing",
|
"issue_filing": "issue_filing",
|
||||||
"issue_selection": None,
|
"issue_selection": None,
|
||||||
"inventory": "inventory",
|
"inventory": "inventory",
|
||||||
|
"controller_close": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||||
@@ -194,6 +208,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,
|
||||||
@@ -335,6 +366,38 @@ def _rule_shared_controller_handoff(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_state_handoff_next_action(report_text: str) -> list[dict[str, str]]:
|
||||||
|
result = assess_state_handoff_ledger_report(report_text)
|
||||||
|
if result.get("complete"):
|
||||||
|
return []
|
||||||
|
findings: list[dict[str, str]] = []
|
||||||
|
next_action = result.get("next_action") or {}
|
||||||
|
contradictions = result.get("contradictions") or {}
|
||||||
|
if next_action.get("block"):
|
||||||
|
findings.extend(
|
||||||
|
_findings_from_reasons(
|
||||||
|
"shared.state_handoff_next_action",
|
||||||
|
next_action.get("reasons") or [],
|
||||||
|
field="Next action handoff",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=next_action.get("safe_next_action")
|
||||||
|
or "add next-action handoff fields to Controller Handoff",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if contradictions.get("block"):
|
||||||
|
findings.extend(
|
||||||
|
_findings_from_reasons(
|
||||||
|
"shared.state_handoff_contradiction",
|
||||||
|
contradictions.get("reasons") or [],
|
||||||
|
field="State handoff",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=contradictions.get("safe_next_action")
|
||||||
|
or "resolve contradictory state claims before submission",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
|
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
|
||||||
result = assess_email_disclosure(report_text)
|
result = assess_email_disclosure(report_text)
|
||||||
if result.get("proven"):
|
if result.get("proven"):
|
||||||
@@ -348,6 +411,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,
|
||||||
*,
|
*,
|
||||||
@@ -565,6 +665,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
|
||||||
|
|
||||||
@@ -715,6 +836,46 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, str]]:
|
||||||
|
from premerge_baseline_proof import assess_premerge_baseline_proof
|
||||||
|
|
||||||
|
result = assess_premerge_baseline_proof(report_text)
|
||||||
|
if not result.get("block"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"reviewer.premerge_baseline_proof",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Pre-merge baseline proof",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"prove the failure on the PR pre-merge base commit or a known-failure "
|
||||||
|
"record predating the PR; current-master reproduction is not baseline proof"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_post_merge_validation(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block active approval on an already-merged/closed PR, and post-merge moot
|
||||||
|
validation claimed without merged-state + merge-commit proof (#529)."""
|
||||||
|
from post_merge_validation import assess_post_merge_validation
|
||||||
|
|
||||||
|
result = assess_post_merge_validation(report_text)
|
||||||
|
if not result.get("block"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"reviewer.post_merge_validation",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Validation status",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"record post-merge moot validation instead of an active approval; "
|
||||||
|
"cite PR state merged/closed and the merge commit SHA"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_status_vocabulary(
|
def _rule_reviewer_validation_status_vocabulary(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -1059,6 +1220,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,
|
||||||
*,
|
*,
|
||||||
@@ -1110,6 +1290,24 @@ def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_manual_lease_proof_handoff(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block merge/review handoffs that claim unsafe lease seeding (#535)."""
|
||||||
|
result = merger_lease_adoption.assess_manual_lease_proof_handoff(report_text)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"shared.manual_lease_proof_handoff",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Merge mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=(
|
||||||
|
"use gitea_adopt_merger_pr_lease or gitea_acquire_reviewer_pr_lease; "
|
||||||
|
"cite lease_proof_source / adoption_comment_id; never claim manual "
|
||||||
|
"seeded/injected lease proof"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
||||||
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
||||||
if not result.get("applicable") or result.get("valid"):
|
if not result.get("applicable") or result.get("valid"):
|
||||||
@@ -1142,6 +1340,17 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_raw_branch_delete_bypass(report_text: str) -> list[dict[str, str]]:
|
||||||
|
result = branch_cleanup_guard.assess_raw_branch_delete_report(report_text)
|
||||||
|
if not result["block"]:
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"shared.raw_branch_delete_bypass",
|
||||||
|
result["reasons"],
|
||||||
|
field="Cleanup mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result["safe_next_action"],
|
||||||
|
)
|
||||||
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
||||||
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
||||||
if not report_text.strip():
|
if not report_text.strip():
|
||||||
@@ -1225,6 +1434,29 @@ def _rule_reviewer_review_mutation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_two_comment_workflow(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""#507: tagged Controller Handoff must pair with Thread State Ledger."""
|
||||||
|
return thread_state_ledger_validator.findings_for_final_report(report_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]:
|
||||||
|
from canonical_state_comments import validate_final_report_state_update
|
||||||
|
|
||||||
|
result = validate_final_report_state_update(report_text)
|
||||||
|
if not result.get("applicable") or result.get("valid"):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
validator_finding(
|
||||||
|
"shared.canonical_state_update",
|
||||||
|
"block",
|
||||||
|
"Canonical state update",
|
||||||
|
reason,
|
||||||
|
"include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT",
|
||||||
|
)
|
||||||
|
for reason in (result.get("reasons") or ["invalid canonical state update"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
|
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
|
||||||
|
|
||||||
@@ -1272,17 +1504,30 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
|||||||
_SHARED_ISSUE_LOCK_RULES = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
|
_rule_shared_manual_lease_proof_handoff,
|
||||||
_rule_shared_author_reviewer_same_run,
|
_rule_shared_author_reviewer_same_run,
|
||||||
|
_rule_shared_raw_branch_delete_bypass,
|
||||||
|
_rule_shared_canonical_state_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_SHARED_TWO_COMMENT_RULES = (
|
||||||
|
_rule_shared_two_comment_workflow,
|
||||||
|
)
|
||||||
_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_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_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,
|
||||||
@@ -1294,12 +1539,15 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_validation_structured,
|
_rule_reviewer_validation_structured,
|
||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
_rule_reviewer_post_merge_validation,
|
||||||
_rule_reviewer_validation_status_vocabulary,
|
_rule_reviewer_validation_status_vocabulary,
|
||||||
_rule_reviewer_main_checkout_baseline,
|
_rule_reviewer_main_checkout_baseline,
|
||||||
_rule_reviewer_main_checkout_path,
|
_rule_reviewer_main_checkout_path,
|
||||||
_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,
|
||||||
@@ -1308,9 +1556,23 @@ _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_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_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,
|
||||||
@@ -1318,6 +1580,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reconcile_linked_issue_live,
|
_rule_reconcile_linked_issue_live,
|
||||||
_rule_reconcile_close_proof,
|
_rule_reconcile_close_proof,
|
||||||
_rule_reconcile_pagination_proof,
|
_rule_reconcile_pagination_proof,
|
||||||
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
_rule_reviewer_git_fetch_readonly,
|
_rule_reviewer_git_fetch_readonly,
|
||||||
_rule_reviewer_legacy_workspace_mutations,
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
@@ -1325,35 +1588,58 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
],
|
],
|
||||||
"author_issue": [
|
"author_issue": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
],
|
],
|
||||||
"work_issue": [
|
"work_issue": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_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,
|
||||||
],
|
],
|
||||||
"issue_filing": [
|
"issue_filing": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_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_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reconcile_pagination_proof,
|
_rule_reconcile_pagination_proof,
|
||||||
],
|
],
|
||||||
"issue_selection": [
|
"issue_selection": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
|
_rule_shared_state_handoff_next_action,
|
||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
|
*_SHARED_TWO_COMMENT_RULES,
|
||||||
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
|
# Controller issue closure (#529): a closure report must not bury an
|
||||||
|
# unproven non-zero suite exit as an "expected pre-existing failure".
|
||||||
|
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||||
|
# full reviewer/author handoff schema.
|
||||||
|
"controller_close": [
|
||||||
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2154
-116
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
"""Pre-create issue content gate (#582).
|
||||||
|
|
||||||
|
Blocks issue creation when the title/body is a *vague reference* to
|
||||||
|
out-of-band content the LLM cannot prove it has ("the drafted issue",
|
||||||
|
"the prepared issue", "the issue we discussed", "the previous draft")
|
||||||
|
with no durable source pointer.
|
||||||
|
|
||||||
|
The rule from #582: an LLM must not fabricate an issue from stale chat
|
||||||
|
memory. When the requested issue content is a vague reference and no
|
||||||
|
durable source pointer (existing issue/PR/comment, checked-in file, URL,
|
||||||
|
or scratchpad path) is present, the gate fails closed and the caller must
|
||||||
|
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.
|
||||||
|
|
||||||
|
This gate intentionally does NOT require a non-empty body: title-only
|
||||||
|
issue creation remains allowed for callers that explicitly want it. It
|
||||||
|
only blocks content that is a placeholder reference to something the
|
||||||
|
current context does not contain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# Vague reference phrases that point at out-of-band draft content. Stored
|
||||||
|
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
|
||||||
|
# can be matched against normalized field text.
|
||||||
|
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
|
||||||
|
"the drafted issue",
|
||||||
|
"drafted issue",
|
||||||
|
"the prepared issue",
|
||||||
|
"prepared issue",
|
||||||
|
"the issue we discussed",
|
||||||
|
"the issue we talked about",
|
||||||
|
"the one we discussed",
|
||||||
|
"the previous draft",
|
||||||
|
"previous draft",
|
||||||
|
"the draft above",
|
||||||
|
"the issue above",
|
||||||
|
"as discussed",
|
||||||
|
"as we discussed",
|
||||||
|
"as previously discussed",
|
||||||
|
"per our discussion",
|
||||||
|
"per our conversation",
|
||||||
|
"per our chat",
|
||||||
|
"see above",
|
||||||
|
"same as before",
|
||||||
|
"the issue from earlier",
|
||||||
|
"the issue i drafted",
|
||||||
|
"the issue you drafted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A field that only restates a vague phrase (optionally with a leading verb
|
||||||
|
# such as "create"/"add"/"open"/"file") is still a vague reference.
|
||||||
|
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")
|
||||||
|
|
||||||
|
# Durable source pointers: if any of these appears, the content points at a
|
||||||
|
# retrievable source of truth and is NOT treated as a fabricated reference.
|
||||||
|
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
|
||||||
|
re.compile(r"#\d+"), # #402
|
||||||
|
re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I), # issue 402 / PR #17
|
||||||
|
re.compile(r"\bcomment\s+#?\d+", re.I), # comment 8155
|
||||||
|
re.compile(r"https?://\S+", re.I), # URL
|
||||||
|
re.compile( # checked-in file
|
||||||
|
r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
|
||||||
|
),
|
||||||
|
re.compile(r"\bscratchpad\b", re.I), # scratchpad path
|
||||||
|
re.compile(r"(?:^|\s)/[\w./-]+"), # absolute path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Lowercase, punctuation-stripped, whitespace-collapsed text."""
|
||||||
|
norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
|
||||||
|
norm = re.sub(r"[^\w\s]", " ", norm)
|
||||||
|
norm = re.sub(r"\s+", " ", norm).strip()
|
||||||
|
return norm
|
||||||
|
|
||||||
|
|
||||||
|
def has_durable_source_pointer(text: str) -> bool:
|
||||||
|
"""True when the raw text references a durable, retrievable source."""
|
||||||
|
raw = text or ""
|
||||||
|
return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)
|
||||||
|
|
||||||
|
|
||||||
|
def find_vague_reference(text: str) -> str | None:
|
||||||
|
"""Return the vague phrase a field is dominated by, else ``None``.
|
||||||
|
|
||||||
|
A field is a vague reference only when it contains a known vague phrase,
|
||||||
|
carries no durable source pointer, and the phrase dominates the field
|
||||||
|
(the field is essentially just the phrase). Long, specific bodies that
|
||||||
|
merely contain "as discussed" in passing are not blocked.
|
||||||
|
"""
|
||||||
|
if has_durable_source_pointer(text):
|
||||||
|
return None
|
||||||
|
norm = normalize_text(text)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
stripped = norm
|
||||||
|
for verb in _LEADING_VERBS:
|
||||||
|
if stripped.startswith(verb + " "):
|
||||||
|
stripped = stripped[len(verb) + 1:]
|
||||||
|
for phrase in VAGUE_REFERENCE_PHRASES:
|
||||||
|
if phrase in norm and len(stripped) <= len(phrase) + 12:
|
||||||
|
return phrase
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_issue_content(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate whether proposed issue content is durable enough to create.
|
||||||
|
|
||||||
|
Returns a dict with ``complete`` (bool), ``missing_fields``,
|
||||||
|
``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
|
||||||
|
``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
|
||||||
|
operator override that forces ``complete`` True.
|
||||||
|
"""
|
||||||
|
t = (title or "").strip()
|
||||||
|
b = (body or "").strip()
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
vague_fields: list[str] = []
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if not t:
|
||||||
|
missing_fields.append("title")
|
||||||
|
reasons.append("issue title is required")
|
||||||
|
else:
|
||||||
|
phrase = find_vague_reference(t)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("title")
|
||||||
|
reasons.append(
|
||||||
|
f"title is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if b:
|
||||||
|
phrase = find_vague_reference(b)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("body")
|
||||||
|
reasons.append(
|
||||||
|
f"body is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
complete = allow_incomplete or (not missing_fields and not vague_fields)
|
||||||
|
return {
|
||||||
|
"complete": complete,
|
||||||
|
"missing_fields": missing_fields,
|
||||||
|
"vague_fields": vague_fields,
|
||||||
|
"reasons": reasons,
|
||||||
|
"diagnose": "; ".join(reasons),
|
||||||
|
"has_durable_source_pointer": has_durable_source_pointer(b)
|
||||||
|
or has_durable_source_pointer(t),
|
||||||
|
"override_applied": bool(
|
||||||
|
allow_incomplete and (missing_fields or vague_fields)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pre_create_issue_content_gate(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Content gate wrapper mirroring the duplicate gate shape.
|
||||||
|
|
||||||
|
``performed`` is True when the content is durable enough to create (or an
|
||||||
|
explicit override was supplied). When False, the caller must return
|
||||||
|
BLOCKED + DIAGNOSE and must not create the issue.
|
||||||
|
"""
|
||||||
|
assessment = assess_issue_content(
|
||||||
|
title, body, allow_incomplete=allow_incomplete
|
||||||
|
)
|
||||||
|
assessment["performed"] = assessment["complete"]
|
||||||
|
return assessment
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Durable validation-outcome labels (#529): the four canonical distinctions a
|
||||||
|
# reviewer report can carry. These are orthogonal to the single active status:*
|
||||||
|
# label, so they use their own prefix and are not subject to the one-status
|
||||||
|
# invariant.
|
||||||
|
VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("validation:clean-pass", "0e8a16", "Validation was a clean pass"),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:baseline-accepted",
|
||||||
|
"fbca04",
|
||||||
|
"Validation passed with a baseline-proven unrelated failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:blocked",
|
||||||
|
"b60205",
|
||||||
|
"Validation blocked by an unresolved failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:post-merge-moot",
|
||||||
|
"5319e7",
|
||||||
|
"Validation is post-merge moot (PR already merged/closed before review)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_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)
|
||||||
|
VALIDATION_LABELS: frozenset[str] = frozenset(
|
||||||
|
spec.name for spec in VALIDATION_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,168 @@
|
|||||||
|
"""Master-parity staleness gate (#420).
|
||||||
|
|
||||||
|
The Gitea MCP server loads its capability-gate code and workflow logic into
|
||||||
|
memory when the process starts. When ``master`` advances -- for example a newly
|
||||||
|
merged security gate such as the branch-delete capability gate (#408/#410) --
|
||||||
|
the running process keeps executing the *old* code until it is restarted. A
|
||||||
|
stale server can therefore still perform a mutation that the updated codebase
|
||||||
|
would forbid.
|
||||||
|
|
||||||
|
Runtime profile/config data is already read live from disk on every call
|
||||||
|
(``gitea_config.load_config`` re-reads the JSON file each time), so profile
|
||||||
|
``allowed_operations`` changes take effect immediately without a restart. The
|
||||||
|
gap this module closes is *code* parity: it captures the server process's
|
||||||
|
source-tree commit at startup and detects, at mutation time, when the on-disk
|
||||||
|
``master`` HEAD has advanced past it. Detected staleness fails closed with a
|
||||||
|
restart-required recovery report, while read-only operations stay allowed so a
|
||||||
|
stale server can still be inspected.
|
||||||
|
|
||||||
|
The core assessment is pure -- callers inject the observed HEAD SHAs -- so the
|
||||||
|
logic is fully unit-testable without a git checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Environment escape hatches (ops + tests):
|
||||||
|
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||||
|
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||||
|
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||||
|
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||||
|
|
||||||
|
|
||||||
|
def read_git_head(root: str) -> str | None:
|
||||||
|
"""Return the current ``HEAD`` commit SHA of *root*, or ``None``.
|
||||||
|
|
||||||
|
``None`` means the SHA could not be determined (not a git checkout, git
|
||||||
|
unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD``
|
||||||
|
takes precedence so the gate can be exercised deterministically.
|
||||||
|
"""
|
||||||
|
forced = os.environ.get(ENV_TEST_CURRENT_HEAD)
|
||||||
|
if forced is not None:
|
||||||
|
return forced.strip() or None
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", root, "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if res.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||||
|
"""Capture the process source-tree baseline once at server startup.
|
||||||
|
|
||||||
|
*head* may be injected (tests); otherwise it is read from *root*. The result
|
||||||
|
is an opaque baseline handed back to :func:`assess_master_parity`.
|
||||||
|
"""
|
||||||
|
startup_head = head if head is not None else read_git_head(root)
|
||||||
|
return {"root": root, "startup_head": startup_head}
|
||||||
|
|
||||||
|
|
||||||
|
def _short(sha: str | None) -> str:
|
||||||
|
return sha[:12] if sha else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||||
|
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||||
|
|
||||||
|
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||||
|
|
||||||
|
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||||
|
could not be determined, which is not treated as stale).
|
||||||
|
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||||
|
running process.
|
||||||
|
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||||
|
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||||
|
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||||
|
"""
|
||||||
|
startup_head = (startup or {}).get("startup_head")
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if startup_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"startup commit was not captured; code parity cannot be enforced")
|
||||||
|
return _result(True, False, False, startup_head, current_head, reasons)
|
||||||
|
|
||||||
|
if current_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"current workspace HEAD could not be read; code parity cannot be "
|
||||||
|
"enforced")
|
||||||
|
return _result(True, False, False, startup_head, current_head, reasons)
|
||||||
|
|
||||||
|
if startup_head == current_head:
|
||||||
|
return _result(True, False, True, startup_head, current_head, reasons)
|
||||||
|
|
||||||
|
reasons.append(
|
||||||
|
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||||
|
f"master is now {_short(current_head)}; restart the server to load the "
|
||||||
|
f"current capability gates")
|
||||||
|
return _result(False, True, True, startup_head, current_head, reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||||
|
return {
|
||||||
|
"in_parity": in_parity,
|
||||||
|
"stale": stale,
|
||||||
|
"restart_required": stale,
|
||||||
|
"determinable": determinable,
|
||||||
|
"startup_head": startup_head,
|
||||||
|
"current_head": current_head,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gate_disabled() -> bool:
|
||||||
|
"""Whether the parity gate is disabled by env escape hatch."""
|
||||||
|
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def parity_block_reasons(assessment: dict) -> list[str]:
|
||||||
|
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||||
|
|
||||||
|
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||||
|
reasons; only a definitively stale server blocks.
|
||||||
|
"""
|
||||||
|
if gate_disabled():
|
||||||
|
return []
|
||||||
|
if assessment.get("stale"):
|
||||||
|
return list(assessment.get("reasons") or
|
||||||
|
["server code is stale relative to master (fail closed)"])
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def parity_report(assessment: dict) -> dict:
|
||||||
|
"""Structured stale-server report for permission-block payloads."""
|
||||||
|
return {
|
||||||
|
"kind": "server_stale",
|
||||||
|
"restart_required": True,
|
||||||
|
"startup_head": assessment.get("startup_head"),
|
||||||
|
"current_head": assessment.get("current_head"),
|
||||||
|
"reasons": list(assessment.get("reasons") or []),
|
||||||
|
"recovery": [
|
||||||
|
"The running MCP server is executing code older than the current "
|
||||||
|
"master and may not enforce newly merged capability gates.",
|
||||||
|
"Restart the Gitea MCP server so it reloads master's capability "
|
||||||
|
"gates and execution profiles before retrying the mutation.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_parity(assessment: dict) -> str:
|
||||||
|
"""One-line human summary for logs / runtime context."""
|
||||||
|
if assessment.get("stale"):
|
||||||
|
return (f"STALE: started {_short(assessment.get('startup_head'))}, "
|
||||||
|
f"master now {_short(assessment.get('current_head'))} "
|
||||||
|
f"(restart required)")
|
||||||
|
if not assessment.get("determinable"):
|
||||||
|
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||||
|
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||||
+38
-1
@@ -115,7 +115,15 @@ Workflow:
|
|||||||
}
|
}
|
||||||
|
|
||||||
show_reviewer_prompts() {
|
show_reviewer_prompts() {
|
||||||
print_prompt_block "Reviewer — PR review" \
|
while true; do
|
||||||
|
printf '\n--- Reviewer workflow prompts ---\n'
|
||||||
|
printf ' 1) Standard PR review\n'
|
||||||
|
printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n'
|
||||||
|
printf ' 0) Back\n'
|
||||||
|
read -r -p 'Choice: ' choice
|
||||||
|
case "$choice" in
|
||||||
|
1)
|
||||||
|
print_prompt_block "Reviewer — PR review" \
|
||||||
"You are the REVIEWER session for <org>/<repo>.
|
"You are the REVIEWER session for <org>/<repo>.
|
||||||
|
|
||||||
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
|
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
|
||||||
@@ -126,6 +134,35 @@ Workflow:
|
|||||||
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
|
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
|
||||||
4. gitea_review_pr with approve, request-changes, or comment as warranted.
|
4. gitea_review_pr with approve, request-changes, or comment as warranted.
|
||||||
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
|
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
print_prompt_block "Reviewer — skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" \
|
||||||
|
"You are the REVIEWER session for <org>/<repo>.
|
||||||
|
|
||||||
|
Goal: review PR #<P> only far enough to determine whether the current head already has a non-stale REQUEST_CHANGES verdict. If it does, do NOT submit another terminal review mutation — produce an author handoff and stop.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. gitea_view_pr; pin the current head SHA.
|
||||||
|
2. Load prior reviews; find the latest REQUEST_CHANGES and the head SHA it was bound to.
|
||||||
|
3. If that REQUEST_CHANGES is still bound to the current head (non-stale), do not submit another terminal review mutation. Confirm the binding and summarize the blockers.
|
||||||
|
4. Run only the diagnostics needed to produce a useful author handoff — no full re-review.
|
||||||
|
5. Duplicate/supersession check: confirm no newer canonical PR or superseding head changes the decision.
|
||||||
|
6. Stop — no new review mutation.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
- selected PR and issue
|
||||||
|
- current head SHA
|
||||||
|
- prior REQUEST_CHANGES proof (review id + bound head SHA)
|
||||||
|
- duplicate/supersession analysis
|
||||||
|
- validation summary
|
||||||
|
- why no new review mutation was submitted
|
||||||
|
- corrected mutation ledger, including local worktree create/remove if used
|
||||||
|
- author-ready fix prompt"
|
||||||
|
;;
|
||||||
|
0) return ;;
|
||||||
|
*) printf 'Invalid choice.\n' ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
show_merger_prompts() {
|
show_merger_prompts() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Assess live MCP namespace health without trusting static registration.
|
||||||
|
|
||||||
|
The IDE/client namespace can fail with EOF even when this Python process still
|
||||||
|
registers the Gitea tools with FastMCP. These helpers keep that distinction
|
||||||
|
explicit so reviewer/merger flows can fail closed on the live path.
|
||||||
|
|
||||||
|
Probe sources
|
||||||
|
-------------
|
||||||
|
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
|
||||||
|
only source that can prove the workflow namespace is healthy).
|
||||||
|
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
|
||||||
|
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
|
||||||
|
IDE-managed namespace is callable.
|
||||||
|
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_NAMESPACE_TOOLS = {
|
||||||
|
"gitea-author": "gitea_whoami",
|
||||||
|
"gitea-reviewer": "gitea_whoami",
|
||||||
|
"gitea-merger": "gitea_whoami",
|
||||||
|
"gitea-tools": "gitea_list_profiles",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
|
||||||
|
|
||||||
|
# Namespaces that must be healthy for a given mutation task.
|
||||||
|
TASK_REQUIRED_NAMESPACES = {
|
||||||
|
"review_pr": "gitea-reviewer",
|
||||||
|
"submit_review": "gitea-reviewer",
|
||||||
|
"merge_pr": "gitea-merger",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROBE_SOURCE_CLIENT = "client_namespace"
|
||||||
|
PROBE_SOURCE_OFFLINE = "offline_spawn"
|
||||||
|
PROBE_SOURCE_UNKNOWN = "unknown"
|
||||||
|
VALID_PROBE_SOURCES = frozenset(
|
||||||
|
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
|
||||||
|
)
|
||||||
|
|
||||||
|
EOF_PATTERNS = (
|
||||||
|
"client is closing: eof",
|
||||||
|
"transport closed",
|
||||||
|
"connection closed",
|
||||||
|
"broken pipe",
|
||||||
|
"end of file",
|
||||||
|
"eof",
|
||||||
|
)
|
||||||
|
|
||||||
|
SAFE_ENV_KEYS = (
|
||||||
|
"GITEA_MCP_PROFILE",
|
||||||
|
"GITEA_PROFILE_NAME",
|
||||||
|
"GITEA_SERVICE",
|
||||||
|
"GITEA_EXECUTION_ROLE",
|
||||||
|
"GITEA_MCP_CONFIG",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(v) for v in value]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_eof(text: str | None) -> bool:
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
return any(pattern in lowered for pattern in EOF_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_probe_source(probe_source: str | None) -> str:
|
||||||
|
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
|
||||||
|
if raw in VALID_PROBE_SOURCES:
|
||||||
|
return raw
|
||||||
|
return PROBE_SOURCE_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
if not process:
|
||||||
|
return {}
|
||||||
|
env = process.get("env") or process.get("environment") or {}
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: str(env[key])
|
||||||
|
for key in SAFE_ENV_KEYS
|
||||||
|
if key in env and env[key] not in (None, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_namespace_probe(
|
||||||
|
namespace: str,
|
||||||
|
*,
|
||||||
|
required_tool: str | None = None,
|
||||||
|
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
probe_result: dict[str, Any] | None = None,
|
||||||
|
process: dict[str, Any] | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
configured: bool = True,
|
||||||
|
probe_source: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify whether a required tool is callable through a live namespace.
|
||||||
|
|
||||||
|
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
||||||
|
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
||||||
|
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
||||||
|
"""
|
||||||
|
ns = (namespace or "").strip()
|
||||||
|
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
||||||
|
source = _normalize_probe_source(probe_source)
|
||||||
|
registered_list = _as_list(registered_tools)
|
||||||
|
registered = None if registered_list is None else tool in registered_list
|
||||||
|
|
||||||
|
probe = probe_result or {}
|
||||||
|
probe_success = bool(probe.get("success"))
|
||||||
|
error_message = str(
|
||||||
|
probe.get("error")
|
||||||
|
or probe.get("message")
|
||||||
|
or probe.get("stderr")
|
||||||
|
or probe.get("exception")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
error_type = str(probe.get("error_type") or "").strip()
|
||||||
|
if not error_type and error_message:
|
||||||
|
if _contains_eof(error_message):
|
||||||
|
error_type = "namespace_eof"
|
||||||
|
elif "timeout" in error_message.lower():
|
||||||
|
error_type = "namespace_timeout"
|
||||||
|
else:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
error_type = "namespace_not_configured"
|
||||||
|
elif registered is False:
|
||||||
|
error_type = "tool_missing"
|
||||||
|
elif not probe_result:
|
||||||
|
error_type = "live_probe_missing"
|
||||||
|
elif not probe_success and not error_type:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
callable_live = bool(configured and probe_result and probe_success)
|
||||||
|
# Probe-path health (spawn or client). IDE-proven only for client path.
|
||||||
|
healthy = bool(configured and registered is not False and callable_live)
|
||||||
|
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
|
||||||
|
process_pid = process.get("pid") if isinstance(process, dict) else None
|
||||||
|
profile_name = profile or (
|
||||||
|
process.get("profile") if isinstance(process, dict) else None
|
||||||
|
)
|
||||||
|
env_summary = _safe_env_summary(process)
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not configured:
|
||||||
|
reasons.append(f"MCP namespace '{ns}' is not configured.")
|
||||||
|
if registered is False:
|
||||||
|
reasons.append(
|
||||||
|
f"Required tool '{tool}' is not registered in namespace '{ns}'."
|
||||||
|
)
|
||||||
|
if error_type == "live_probe_missing":
|
||||||
|
reasons.append(
|
||||||
|
f"No live client invocation proof was supplied for '{ns}.{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_eof":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_timeout":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_call_failed":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
if source == PROBE_SOURCE_OFFLINE:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
|
||||||
|
"not prove the IDE-managed MCP namespace is healthy."
|
||||||
|
)
|
||||||
|
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source unspecified; treat as not IDE-namespace proof unless "
|
||||||
|
"re-supplied with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
|
||||||
|
remediation = []
|
||||||
|
if not healthy or not ide_namespace_proven:
|
||||||
|
remediation.append(
|
||||||
|
"Reconnect the IDE MCP client namespace (client reconnect / "
|
||||||
|
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
|
||||||
|
"record the result with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
remediation.append(
|
||||||
|
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
|
||||||
|
"shell kill/PID respawn as proof the IDE namespace is repaired."
|
||||||
|
)
|
||||||
|
if process_pid and source != PROBE_SOURCE_OFFLINE:
|
||||||
|
remediation.append(
|
||||||
|
f"Diagnostics may include PID {process_pid}; process details "
|
||||||
|
"are informational only — recovery is client-layer reconnect."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remediation.append(
|
||||||
|
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
|
||||||
|
f"(probe_source={source})."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client-namespace broken health blocks review/merge. Offline probes never
|
||||||
|
# authorize mutations and only block when they report unhealthy (still
|
||||||
|
# fail-closed for known bad spawn evidence).
|
||||||
|
blocks = False
|
||||||
|
if source == PROBE_SOURCE_CLIENT:
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
elif source == PROBE_SOURCE_OFFLINE:
|
||||||
|
# Offline never proves IDE health; never unblock. Unhealthy offline
|
||||||
|
# still surfaces as a soft diagnostic, not a mutation-ledger block.
|
||||||
|
blocks = False
|
||||||
|
else:
|
||||||
|
# Unknown source: only block when evidence is unhealthy (fail closed
|
||||||
|
# on bad data without treating success as IDE proof).
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": healthy,
|
||||||
|
"healthy": healthy,
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"configured": configured,
|
||||||
|
"registered_tools_checked": registered_list is not None,
|
||||||
|
"required_tool_registered": registered,
|
||||||
|
"required_tool_callable": callable_live,
|
||||||
|
"probe_source": source,
|
||||||
|
"ide_namespace_proven": ide_namespace_proven,
|
||||||
|
"error_type": None if healthy else error_type,
|
||||||
|
"error_message": error_message or None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"remediation": remediation,
|
||||||
|
"diagnostics": {
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"process_pid": process_pid,
|
||||||
|
"profile": profile_name,
|
||||||
|
"env": env_summary,
|
||||||
|
"config_path": config_path,
|
||||||
|
"probe_source": source,
|
||||||
|
},
|
||||||
|
"blocks_merge_workflow": blocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
|
||||||
|
"""Return whether a broken namespace must block a workflow task."""
|
||||||
|
if healthy:
|
||||||
|
return False
|
||||||
|
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
|
||||||
|
|
||||||
|
|
||||||
|
def required_namespace_for_task(task: str) -> str | None:
|
||||||
|
"""Map a mutation task to the MCP namespace that must be healthy."""
|
||||||
|
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_gate_from_session(
|
||||||
|
task: str,
|
||||||
|
session_health: dict[str, dict[str, Any]] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fail-closed gate using recorded client-namespace health assessments.
|
||||||
|
|
||||||
|
* Missing session entry → no gate (caller has not assessed yet).
|
||||||
|
* Client-namespace unhealthy / not IDE-proven → block mutation.
|
||||||
|
* Offline-only session entries never authorize mutations.
|
||||||
|
"""
|
||||||
|
ns = required_namespace_for_task(task)
|
||||||
|
if not ns:
|
||||||
|
return []
|
||||||
|
store = session_health or {}
|
||||||
|
entry = store.get(ns)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
source = _normalize_probe_source(entry.get("probe_source"))
|
||||||
|
if source != PROBE_SOURCE_CLIENT:
|
||||||
|
return [
|
||||||
|
f"recorded namespace health for '{ns}' is probe_source={source}, "
|
||||||
|
"not client_namespace; re-probe through the IDE-managed path "
|
||||||
|
f"before {(task or 'mutation')}"
|
||||||
|
]
|
||||||
|
if entry.get("ide_namespace_proven") and entry.get("healthy"):
|
||||||
|
return []
|
||||||
|
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
|
||||||
|
detail = entry.get("error_type") or "unhealthy"
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail} "
|
||||||
|
f"(probe_source={source}); repair the IDE namespace before "
|
||||||
|
f"{(task or 'mutation')} (fail closed, #543)"
|
||||||
|
]
|
||||||
|
if not entry.get("ide_namespace_proven"):
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
|
||||||
|
"client_namespace probe before mutation (fail closed, #543)"
|
||||||
|
]
|
||||||
|
return []
|
||||||
@@ -6,6 +6,9 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||||
|
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||||
|
|
||||||
from role_session_router import (
|
from role_session_router import (
|
||||||
python_bytes_have_conflict_markers,
|
python_bytes_have_conflict_markers,
|
||||||
skip_python_scan_walk_root,
|
skip_python_scan_walk_root,
|
||||||
@@ -37,6 +40,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:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ DEFAULT_TTL_HOURS = 4.0
|
|||||||
|
|
||||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||||
KIND_DECISION_LOCK = "review_decision_lock"
|
KIND_DECISION_LOCK = "review_decision_lock"
|
||||||
|
KIND_REVIEW_DRAFT = "review_draft"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||||
|
|||||||
+437
-8
@@ -17,6 +17,12 @@ 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_NAME_RE = re.compile(
|
||||||
|
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_linked_issue(title: str, body: str) -> int | None:
|
def extract_linked_issue(title: str, body: str) -> int | None:
|
||||||
@@ -36,6 +42,302 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
|||||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_issue_marker(*values: str | None) -> int | None:
|
||||||
|
"""Return the first issue marker found in branch/path-like values."""
|
||||||
|
for value in values:
|
||||||
|
match = ISSUE_MARKER_RE.search(value or "")
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||||
|
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
current: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal current
|
||||||
|
if current:
|
||||||
|
entries.append(current)
|
||||||
|
current = None
|
||||||
|
|
||||||
|
for raw_line in (res.stdout or "").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
flush()
|
||||||
|
current = {"path": line[9:].strip()}
|
||||||
|
elif current is not None and line.startswith("HEAD "):
|
||||||
|
current["head_sha"] = line[5:].strip()
|
||||||
|
elif current is not None and line.startswith("branch "):
|
||||||
|
ref = line[7:].strip()
|
||||||
|
current["branch_ref"] = ref
|
||||||
|
current["branch"] = (
|
||||||
|
ref[len("refs/heads/"):]
|
||||||
|
if ref.startswith("refs/heads/")
|
||||||
|
else ref
|
||||||
|
)
|
||||||
|
elif current is not None and line == "detached":
|
||||||
|
current["detached"] = True
|
||||||
|
flush()
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||||
|
"""Parse ``git worktree list --porcelain`` into structured records."""
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
records: list[dict[str, str | None]] = []
|
||||||
|
current: dict[str, str | None] = {}
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
if current.get("worktree"):
|
||||||
|
records.append(current)
|
||||||
|
current = {}
|
||||||
|
continue
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
if current.get("worktree"):
|
||||||
|
records.append(current)
|
||||||
|
current = {"worktree": line[9:].strip(), "branch": None, "head": None}
|
||||||
|
elif line.startswith("branch ") and current:
|
||||||
|
ref = line[7:].strip()
|
||||||
|
current["branch"] = (
|
||||||
|
ref[11:] if ref.startswith("refs/heads/") else ref
|
||||||
|
)
|
||||||
|
elif line.startswith("HEAD ") and current:
|
||||||
|
current["head"] = line[5:].strip()
|
||||||
|
elif line == "detached" and current:
|
||||||
|
current["branch"] = None
|
||||||
|
if current.get("worktree"):
|
||||||
|
records.append(current)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
||||||
|
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for entry in list_local_worktrees(project_root):
|
||||||
|
branch = entry.get("branch")
|
||||||
|
path = entry.get("path")
|
||||||
|
if branch and path:
|
||||||
|
mapping[str(branch)] = str(path)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||||
|
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||||
|
real_path = os.path.realpath(path)
|
||||||
|
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||||
|
|
||||||
|
|
||||||
|
def _head_is_safe_for_cleanup(
|
||||||
|
*,
|
||||||
|
project_root: str,
|
||||||
|
candidate_head: str | None,
|
||||||
|
pr_head_sha: str | None,
|
||||||
|
target_ref: str | None,
|
||||||
|
) -> bool:
|
||||||
|
if not candidate_head:
|
||||||
|
return False
|
||||||
|
if pr_head_sha and candidate_head == pr_head_sha:
|
||||||
|
return True
|
||||||
|
if target_ref:
|
||||||
|
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cleanup_worktree_state(
|
||||||
|
*,
|
||||||
|
project_root: str,
|
||||||
|
head_branch: str,
|
||||||
|
issue_number: int | None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
target_ref: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||||
|
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||||
|
state = read_local_worktree_state(expected_path)
|
||||||
|
state["worktree_path"] = expected_path
|
||||||
|
state["expected_worktree_path"] = expected_path
|
||||||
|
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||||
|
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||||
|
state["candidate_paths"] = []
|
||||||
|
state["alias_block_reasons"] = []
|
||||||
|
if state.get("exists"):
|
||||||
|
return state
|
||||||
|
|
||||||
|
branch_issue = extract_issue_marker(head_branch)
|
||||||
|
wanted_issue = issue_number or branch_issue
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
unsafe_matches: list[str] = []
|
||||||
|
|
||||||
|
for entry in list_local_worktrees(project_root):
|
||||||
|
path = entry.get("path") or ""
|
||||||
|
if not path or not _is_under_branches(project_root, path):
|
||||||
|
continue
|
||||||
|
branch = entry.get("branch") or ""
|
||||||
|
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||||
|
entry_issue = extract_issue_marker(branch) or path_issue
|
||||||
|
branch_match = branch == head_branch
|
||||||
|
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||||
|
if not (branch_match or issue_match):
|
||||||
|
continue
|
||||||
|
safe_head = _head_is_safe_for_cleanup(
|
||||||
|
project_root=project_root,
|
||||||
|
candidate_head=entry.get("head_sha"),
|
||||||
|
pr_head_sha=pr_head_sha,
|
||||||
|
target_ref=target_ref,
|
||||||
|
)
|
||||||
|
if not safe_head and branch_match and not pr_head_sha and not target_ref:
|
||||||
|
safe_head = True
|
||||||
|
if not safe_head:
|
||||||
|
unsafe_matches.append(path)
|
||||||
|
continue
|
||||||
|
candidates.append(entry)
|
||||||
|
|
||||||
|
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||||
|
if len(candidates) == 1:
|
||||||
|
path = candidates[0]["path"]
|
||||||
|
alias_state = read_local_worktree_state(path)
|
||||||
|
alias_state["worktree_path"] = path
|
||||||
|
alias_state["expected_worktree_path"] = expected_path
|
||||||
|
alias_state["discovered_worktree_path"] = path
|
||||||
|
alias_state["match_type"] = (
|
||||||
|
"branch" if candidates[0].get("branch") == head_branch else "alias"
|
||||||
|
)
|
||||||
|
alias_state["candidate_paths"] = [path]
|
||||||
|
alias_state["alias_block_reasons"] = []
|
||||||
|
alias_state["alias_verified"] = True
|
||||||
|
return alias_state
|
||||||
|
if len(candidates) > 1:
|
||||||
|
state["alias_block_reasons"].append(
|
||||||
|
"ambiguous local worktree aliases: " + ", ".join(
|
||||||
|
sorted(str(entry.get("path")) for entry in candidates)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state["match_type"] = "ambiguous_alias"
|
||||||
|
elif unsafe_matches:
|
||||||
|
state["alias_block_reasons"].append(
|
||||||
|
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||||
|
+ ", ".join(sorted(unsafe_matches))
|
||||||
|
)
|
||||||
|
state["match_type"] = "unsafe_alias"
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
||||||
|
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
||||||
|
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return int(match.group("pr"))
|
||||||
|
|
||||||
|
|
||||||
|
def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||||
|
"""Discover detached reviewer scratch worktrees under ``branches/`` (#534).
|
||||||
|
|
||||||
|
Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
|
||||||
|
These are never treated as author worktree paths.
|
||||||
|
"""
|
||||||
|
root = os.path.realpath(project_root)
|
||||||
|
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||||
|
found: list[dict[str, Any]] = []
|
||||||
|
for record in _git_worktree_porcelain(project_root):
|
||||||
|
path = record.get("worktree")
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
real = os.path.realpath(path)
|
||||||
|
parent = os.path.dirname(real)
|
||||||
|
if parent != branches_root:
|
||||||
|
continue
|
||||||
|
folder = os.path.basename(real)
|
||||||
|
pr_number = parse_reviewer_scratch_folder(folder)
|
||||||
|
if pr_number is None:
|
||||||
|
continue
|
||||||
|
found.append(
|
||||||
|
{
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"worktree_path": real,
|
||||||
|
"folder_name": folder,
|
||||||
|
"current_branch": record.get("branch"),
|
||||||
|
"head_sha": record.get("head"),
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def assess_reviewer_scratch_cleanup(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
worktree_path: str,
|
||||||
|
folder_name: str,
|
||||||
|
pr_merged: bool,
|
||||||
|
pr_closed: bool,
|
||||||
|
worktree_state: dict[str, Any],
|
||||||
|
active_reviewer_lease: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a reviewer scratch worktree is safe to remove (#534)."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
exists = bool(worktree_state.get("exists"))
|
||||||
|
if not (pr_merged or pr_closed):
|
||||||
|
reasons.append("associated PR is still open")
|
||||||
|
if not exists:
|
||||||
|
reasons.append("reviewer scratch worktree not present")
|
||||||
|
if exists and worktree_state.get("clean") is False:
|
||||||
|
dirty = worktree_state.get("dirty_files") or []
|
||||||
|
reasons.append(
|
||||||
|
"reviewer scratch worktree has tracked edits"
|
||||||
|
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||||
|
)
|
||||||
|
if active_reviewer_lease:
|
||||||
|
reasons.append("active reviewer lease still requires this worktree")
|
||||||
|
current_branch = worktree_state.get("current_branch")
|
||||||
|
if current_branch:
|
||||||
|
# Scratch trees are expected detached; a named branch is ambiguous ownership.
|
||||||
|
reasons.append(
|
||||||
|
f"reviewer scratch worktree is on branch '{current_branch}' "
|
||||||
|
"(expected detached HEAD for review-pr scratch trees)"
|
||||||
|
)
|
||||||
|
|
||||||
|
safe = exists and not reasons
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"folder_name": folder_name,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"worktree_exists": exists,
|
||||||
|
"worktree_clean": worktree_state.get("clean"),
|
||||||
|
"safe_to_remove_worktree": safe,
|
||||||
|
"block_reasons": reasons,
|
||||||
|
"recommended_action": (
|
||||||
|
"remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
|
||||||
|
),
|
||||||
|
# Never treat as author worktree cleanup.
|
||||||
|
"author_worktree_cleanup": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||||
if path:
|
if path:
|
||||||
return issue_lock_store.read_lock_file(path.strip())
|
return issue_lock_store.read_lock_file(path.strip())
|
||||||
@@ -176,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:
|
||||||
@@ -186,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}'"
|
||||||
)
|
)
|
||||||
@@ -198,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,
|
||||||
@@ -216,6 +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,
|
||||||
|
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 ""
|
||||||
@@ -224,9 +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"))
|
||||||
worktree_path = resolve_worktree_path(project_root, head_branch)
|
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||||
worktree_state = read_local_worktree_state(worktree_path)
|
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||||
worktree_state["worktree_path"] = worktree_path
|
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||||
|
worktree_state = resolve_cleanup_worktree_state(
|
||||||
|
project_root=project_root,
|
||||||
|
head_branch=head_branch,
|
||||||
|
issue_number=issue_number,
|
||||||
|
pr_head_sha=pr_head_sha,
|
||||||
|
target_ref=target_ref,
|
||||||
|
)
|
||||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||||
|
|
||||||
remote = assess_remote_branch_cleanup(
|
remote = assess_remote_branch_cleanup(
|
||||||
@@ -249,7 +568,7 @@ def build_pr_cleanup_entry(
|
|||||||
)
|
)
|
||||||
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"),
|
||||||
@@ -270,6 +589,9 @@ 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,
|
||||||
|
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)
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
@@ -290,19 +612,65 @@ 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,
|
||||||
|
target_ref=target_ref,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #534: reviewer scratch worktrees are reported separately from author cleanup.
|
||||||
|
lease_map = active_reviewer_leases or {}
|
||||||
|
state_map = pr_states or {}
|
||||||
|
open_pr_numbers = {
|
||||||
|
int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
|
||||||
|
}
|
||||||
|
closed_by_number = {
|
||||||
|
int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
|
||||||
|
}
|
||||||
|
scratch_entries: list[dict[str, Any]] = []
|
||||||
|
for scratch in discover_reviewer_scratch_worktrees(project_root):
|
||||||
|
pr_number = int(scratch["pr_number"])
|
||||||
|
state_info = state_map.get(pr_number) or {}
|
||||||
|
closed_pr = closed_by_number.get(pr_number)
|
||||||
|
if closed_pr is not None:
|
||||||
|
pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
|
||||||
|
pr_closed = True
|
||||||
|
elif pr_number in open_pr_numbers:
|
||||||
|
pr_merged = False
|
||||||
|
pr_closed = False
|
||||||
|
else:
|
||||||
|
pr_merged = bool(state_info.get("merged"))
|
||||||
|
pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
|
||||||
|
worktree_path = scratch["worktree_path"]
|
||||||
|
worktree_state = read_local_worktree_state(worktree_path)
|
||||||
|
worktree_state["worktree_path"] = worktree_path
|
||||||
|
# Prefer live branch from state (git) over porcelain branch field.
|
||||||
|
assessment = assess_reviewer_scratch_cleanup(
|
||||||
|
pr_number=pr_number,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
folder_name=scratch["folder_name"],
|
||||||
|
pr_merged=pr_merged,
|
||||||
|
pr_closed=pr_closed,
|
||||||
|
worktree_state=worktree_state,
|
||||||
|
active_reviewer_lease=bool(lease_map.get(pr_number)),
|
||||||
|
)
|
||||||
|
scratch_entries.append(assessment)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"project_root": os.path.realpath(project_root),
|
"project_root": os.path.realpath(project_root),
|
||||||
"merged_pr_count": len(entries),
|
"merged_pr_count": len(entries),
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
"reviewer_scratch_entries": scratch_entries,
|
||||||
|
"reviewer_scratch_count": len(scratch_entries),
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
"executed": False,
|
"executed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
def remove_local_worktree(
|
||||||
worktree_path = resolve_worktree_path(project_root, branch)
|
project_root: str,
|
||||||
|
branch: str,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||||
if not os.path.isdir(worktree_path):
|
if not os.path.isdir(worktree_path):
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -326,4 +694,65 @@ def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
|||||||
"performed": True,
|
"performed": True,
|
||||||
"message": f"removed worktree {worktree_path}",
|
"message": f"removed worktree {worktree_path}",
|
||||||
"worktree_path": worktree_path,
|
"worktree_path": worktree_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_reviewer_scratch_worktree(
|
||||||
|
project_root: str,
|
||||||
|
worktree_path: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Remove a reviewer scratch worktree by absolute path (#534).
|
||||||
|
|
||||||
|
Fail closed unless the path is a direct ``branches/review-pr*`` child of
|
||||||
|
*project_root*. Never routes through author branch-name derivation.
|
||||||
|
"""
|
||||||
|
root = os.path.realpath(project_root)
|
||||||
|
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||||
|
real = os.path.realpath((worktree_path or "").strip())
|
||||||
|
folder = os.path.basename(real)
|
||||||
|
if os.path.dirname(real) != branches_root:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"message": (
|
||||||
|
"reviewer scratch path must be a direct child of "
|
||||||
|
f"{branches_root}; got {real}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if parse_reviewer_scratch_folder(folder) is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"message": f"path is not a reviewer scratch folder: {folder}",
|
||||||
|
}
|
||||||
|
if not os.path.isdir(real):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"message": f"worktree not found: {real}",
|
||||||
|
}
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", project_root, "worktree", "remove", real],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
|
||||||
|
"worktree_path": real,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"performed": True,
|
||||||
|
"worktree_kind": "reviewer_scratch",
|
||||||
|
"message": f"removed reviewer scratch worktree {real}",
|
||||||
|
"worktree_path": real,
|
||||||
|
"author_worktree_cleanup": False,
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ after formal approval at the current PR head.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -133,6 +134,115 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def describe_session_lease_proof(
|
||||||
|
session: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Canonical merge-report lease proof fields (#535).
|
||||||
|
|
||||||
|
Surfaces how the in-session lease was established so merge handoffs can
|
||||||
|
distinguish sanctioned MCP acquire/adopt paths from bare manual seeding.
|
||||||
|
"""
|
||||||
|
if not session:
|
||||||
|
return {
|
||||||
|
"lease_proof_source": None,
|
||||||
|
"lease_recovery_reason": None,
|
||||||
|
"lease_proof_kind": "none",
|
||||||
|
"lease_proof_sanctioned": False,
|
||||||
|
"lease_proof_comment_id": None,
|
||||||
|
"lease_adopted_from_session_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
provenance = session.get("lease_provenance") or {}
|
||||||
|
if not isinstance(provenance, dict):
|
||||||
|
provenance = {}
|
||||||
|
source = (provenance.get("source") or "").strip() or None
|
||||||
|
sanctioned = is_sanctioned_session_lease(session)
|
||||||
|
reason = provenance.get("adoption_reason")
|
||||||
|
if isinstance(reason, str):
|
||||||
|
reason = reason.strip() or None
|
||||||
|
else:
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
if source == SOURCE_ADOPT and sanctioned:
|
||||||
|
kind = "sanctioned_adoption"
|
||||||
|
reason = reason or DEFAULT_ADOPTION_REASON
|
||||||
|
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||||
|
kind = "sanctioned_acquire"
|
||||||
|
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||||
|
kind = "sanctioned_heartbeat"
|
||||||
|
elif source:
|
||||||
|
kind = "unsanctioned"
|
||||||
|
else:
|
||||||
|
# Session present without provenance = classic manual _SESSION_LEASE seed.
|
||||||
|
kind = "unsanctioned_manual_seed"
|
||||||
|
|
||||||
|
comment_id = provenance.get("comment_id")
|
||||||
|
if comment_id is None:
|
||||||
|
comment_id = session.get("comment_id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lease_proof_source": source,
|
||||||
|
"lease_recovery_reason": reason,
|
||||||
|
"lease_proof_kind": kind,
|
||||||
|
"lease_proof_sanctioned": sanctioned,
|
||||||
|
"lease_proof_comment_id": comment_id,
|
||||||
|
"lease_adopted_from_session_id": provenance.get("adopted_from_session_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Phrases that claim non-MCP / fabricated lease proof in handoffs (#535).
|
||||||
|
_UNSAFE_LEASE_PROOF_CLAIM_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"manually\s+seeded\s+lease|"
|
||||||
|
r"manual(?:ly)?\s+seed(?:ed)?\s+(?:lease|proof)|"
|
||||||
|
r"seeded\s+lease\s+proof|"
|
||||||
|
r"injected\s+lease|"
|
||||||
|
r"lease\s+proof\s+injected|"
|
||||||
|
r"manual\s+_?SESSION_LEASE|"
|
||||||
|
r"_SESSION_LEASE\s+seed|"
|
||||||
|
r"unsanctioned\s+lease\s+proof"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evidence that the report used the sanctioned MCP adoption/acquire path.
|
||||||
|
_SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
r"sanctioned_adoption|"
|
||||||
|
r"mcp-review-lease-adoption"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Controller audit: block handoffs that claim unsafe lease seeding (#535).
|
||||||
|
|
||||||
|
Reports may discuss manual seeding as a blocked/historical anti-pattern only
|
||||||
|
when they also cite sanctioned MCP adoption/acquire evidence. Bare claims of
|
||||||
|
manual/seeded/injected lease proof without that evidence fail closed.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
if not _UNSAFE_LEASE_PROOF_CLAIM_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
if _SANCTIONED_LEASE_EVIDENCE_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": [
|
||||||
|
"report claims manual/seeded/injected/unsanctioned lease proof without "
|
||||||
|
"sanctioned MCP adoption evidence (use gitea_adopt_merger_pr_lease / "
|
||||||
|
"gitea_acquire_reviewer_pr_lease and cite lease_proof_source; #535)"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_adopt_merger_lease(
|
def assess_adopt_merger_lease(
|
||||||
comments: list[dict],
|
comments: list[dict],
|
||||||
*,
|
*,
|
||||||
|
|||||||
+161
-1
@@ -8,6 +8,9 @@ available unless explicit recovery-mode proof is supplied.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||||
@@ -366,4 +369,161 @@ def assess_gitea_operation_path(
|
|||||||
else "proceed with native MCP"
|
else "proceed with native MCP"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_terminal_probe_command() -> list[str]:
|
||||||
|
return ["git", "--version"] if shutil.which("git") else ["echo", "ok"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_executable(cwd: str | None, executable: str | None) -> str | None:
|
||||||
|
if not executable:
|
||||||
|
return None
|
||||||
|
if os.path.isabs(executable):
|
||||||
|
if os.path.exists(executable) and os.access(executable, os.X_OK):
|
||||||
|
return executable
|
||||||
|
return None
|
||||||
|
if "/" in executable or "\\" in executable:
|
||||||
|
target_cwd = cwd or os.getcwd()
|
||||||
|
full_path = os.path.abspath(os.path.join(target_cwd, executable))
|
||||||
|
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
||||||
|
return full_path
|
||||||
|
return None
|
||||||
|
return shutil.which(executable)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]:
|
||||||
|
"""Inspect and categorize command spawn failures (#556)."""
|
||||||
|
diagnostics = {
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": command,
|
||||||
|
"cwd_exists": True,
|
||||||
|
"cwd_is_dir": True,
|
||||||
|
"shell_exists": True,
|
||||||
|
"executable_exists": True,
|
||||||
|
"resolved_executable": None,
|
||||||
|
"error_type": "session launcher failure",
|
||||||
|
"error_msg": (
|
||||||
|
"Subprocess spawn failed despite valid CWD and executable. This may be due "
|
||||||
|
"to sandboxing, permission restrictions, or system resource limits."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cwd:
|
||||||
|
if not os.path.exists(cwd):
|
||||||
|
diagnostics["cwd_exists"] = False
|
||||||
|
diagnostics["error_type"] = "missing cwd"
|
||||||
|
diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}"
|
||||||
|
return diagnostics
|
||||||
|
if not os.path.isdir(cwd):
|
||||||
|
diagnostics["cwd_is_dir"] = False
|
||||||
|
diagnostics["error_type"] = "cwd is not a directory"
|
||||||
|
diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}"
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
exe = command[0] if command else None
|
||||||
|
if exe:
|
||||||
|
resolved_exe = _resolve_executable(cwd, exe)
|
||||||
|
diagnostics["resolved_executable"] = resolved_exe
|
||||||
|
if not resolved_exe:
|
||||||
|
diagnostics["executable_exists"] = False
|
||||||
|
if "venv" in exe or "pytest" in exe:
|
||||||
|
diagnostics["error_type"] = "missing runtime wrapper"
|
||||||
|
diagnostics["error_msg"] = (
|
||||||
|
"Virtual environment runtime wrapper/executable not found or not "
|
||||||
|
f"executable: {exe}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
diagnostics["error_type"] = "missing executable"
|
||||||
|
diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}"
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
# Check common shell availability
|
||||||
|
has_any_shell = False
|
||||||
|
for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"):
|
||||||
|
if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
|
||||||
|
has_any_shell = True
|
||||||
|
break
|
||||||
|
if not has_any_shell:
|
||||||
|
diagnostics["shell_exists"] = False
|
||||||
|
diagnostics["error_type"] = "missing shell"
|
||||||
|
diagnostics["error_msg"] = (
|
||||||
|
"No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is "
|
||||||
|
"available or executable."
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def probe_terminal_spawn(
|
||||||
|
cwd: str | None = None,
|
||||||
|
command: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Proactively probe command spawning to detect launcher health (#556)."""
|
||||||
|
probe_cmd = command or default_terminal_probe_command()
|
||||||
|
diag = diagnose_terminal_failure(cwd, probe_cmd)
|
||||||
|
if diag["error_type"] != "session launcher failure":
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": diag["error_msg"],
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
probe_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return {
|
||||||
|
"healthy": True,
|
||||||
|
"error_type": None,
|
||||||
|
"error_msg": "",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
# Non-zero status still proves the launcher spawned the process.
|
||||||
|
return {
|
||||||
|
"healthy": True,
|
||||||
|
"error_type": None,
|
||||||
|
"error_msg": "",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"exit_code": proc.returncode,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": diag["error_msg"],
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "probe timeout",
|
||||||
|
"error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": f"Subprocess spawn failed with exception: {exc}",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Canonical post-merge (moot) validation outcome and wording gate (#529).
|
||||||
|
|
||||||
|
When a PR is already merged or closed *before* a reviewer submits their
|
||||||
|
verdict, an ordinary "approve" is misleading: the review never gated the
|
||||||
|
merge, so a normal active approval overstates controller confidence. The
|
||||||
|
sanctioned outcome for that case is a **post-merge moot validation** — the
|
||||||
|
reviewer records what validation found, but classifies it as moot rather than
|
||||||
|
an active approval.
|
||||||
|
|
||||||
|
This module defines the four canonical validation distinctions #529 requires
|
||||||
|
and enforces the post-merge case:
|
||||||
|
|
||||||
|
- ``CLEAN_PASS_OUTCOME`` — clean validation pass on an open, reviewable PR.
|
||||||
|
- ``BASELINE_ACCEPTED_OUTCOME`` — validation pass with a baseline-proven
|
||||||
|
unrelated failure (proof handled by :mod:`premerge_baseline_proof`).
|
||||||
|
- ``BLOCKED_OUTCOME`` — validation blocked by an unresolved failure.
|
||||||
|
- ``POST_MERGE_MOOT_OUTCOME`` — the PR was already merged/closed before review
|
||||||
|
submission; validation is recorded as post-merge moot, not an active
|
||||||
|
approval.
|
||||||
|
|
||||||
|
The gate blocks two mistakes:
|
||||||
|
|
||||||
|
1. Claiming an *active approval* on a PR that was already merged/closed before
|
||||||
|
review submission (must be recorded as post-merge moot validation instead).
|
||||||
|
2. Claiming post-merge moot validation without proof the PR is actually
|
||||||
|
merged/closed (a merged-state field plus a merge commit SHA).
|
||||||
|
|
||||||
|
Each outcome maps to a durable ``validation:*`` process-state label so PRs and
|
||||||
|
issues carry the distinction (#529 acceptance criterion).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEAN_PASS_OUTCOME = "clean validation pass"
|
||||||
|
BASELINE_ACCEPTED_OUTCOME = "validation pass with baseline-proven unrelated failure"
|
||||||
|
BLOCKED_OUTCOME = "validation blocked by unresolved failure"
|
||||||
|
POST_MERGE_MOOT_OUTCOME = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Canonical validation status label a reviewer writes in the report body for the
|
||||||
|
# post-merge case; kept in sync with validation_status_vocabulary.
|
||||||
|
POST_MERGE_MOOT_STATUS = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Durable process-state labels (registered in issue_workflow_labels).
|
||||||
|
LABEL_CLEAN_PASS = "validation:clean-pass"
|
||||||
|
LABEL_BASELINE_ACCEPTED = "validation:baseline-accepted"
|
||||||
|
LABEL_BLOCKED = "validation:blocked"
|
||||||
|
LABEL_POST_MERGE_MOOT = "validation:post-merge-moot"
|
||||||
|
|
||||||
|
_OUTCOME_TO_LABEL: dict[str, str] = {
|
||||||
|
CLEAN_PASS_OUTCOME: LABEL_CLEAN_PASS,
|
||||||
|
BASELINE_ACCEPTED_OUTCOME: LABEL_BASELINE_ACCEPTED,
|
||||||
|
BLOCKED_OUTCOME: LABEL_BLOCKED,
|
||||||
|
POST_MERGE_MOOT_OUTCOME: LABEL_POST_MERGE_MOOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The PR was already merged/closed before the review was submitted.
|
||||||
|
_MERGED_BEFORE_REVIEW_RE = re.compile(
|
||||||
|
r"(?:already[- ]merged"
|
||||||
|
r"|merged\s+before\s+review"
|
||||||
|
r"|closed\s+before\s+review"
|
||||||
|
r"|pr\s+(?:is|was)\s+(?:already\s+)?(?:merged|closed)"
|
||||||
|
r"|pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged\s*/\s*closed)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# An active approval verdict is being claimed.
|
||||||
|
_ACTIVE_APPROVAL_RE = re.compile(
|
||||||
|
r"(?:review\s+decision\s*[:=]\s*approve"
|
||||||
|
r"|submitted\s+['\"]?approve['\"]?"
|
||||||
|
r"|active\s+approval"
|
||||||
|
r"|approving\s+the\s+pr"
|
||||||
|
r"|posting\s+an?\s+approval)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# The sanctioned post-merge moot validation wording.
|
||||||
|
_POST_MERGE_MOOT_RE = re.compile(
|
||||||
|
r"post[- ]merge\s+(?:moot\s+)?validation"
|
||||||
|
r"|post[- ]merge\s+moot"
|
||||||
|
r"|moot\s+validation",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Proof the PR is genuinely merged/closed.
|
||||||
|
_MERGED_STATE_PROOF_RE = re.compile(
|
||||||
|
r"(?:pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged_at\s*[:=]\s*\S"
|
||||||
|
r"|closed_at\s*[:=]\s*\S"
|
||||||
|
r"|state\s*[:=]\s*(?:merged|closed))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||||
|
r"merge\s+commit(?:\s+sha)?\s*[:=]\s*[0-9a-f]{7,40}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POST_MERGE_VALIDATION_TEMPLATE = (
|
||||||
|
"Validation status: post-merge moot validation\n"
|
||||||
|
"PR state: merged\n"
|
||||||
|
"Merge commit sha: <40-hex merge commit>\n"
|
||||||
|
"Validation finding: <what the validation run showed, for the record>\n"
|
||||||
|
"Note: PR merged/closed before review submission; recorded as post-merge "
|
||||||
|
"moot validation, not an active approval."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_state_label(outcome: str) -> str | None:
|
||||||
|
"""Return the durable ``validation:*`` label for a canonical outcome."""
|
||||||
|
return _OUTCOME_TO_LABEL.get(outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_merge_validation(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
pr_merged_or_closed: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess post-merge moot validation wording and proof (#529).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_text: The reviewer final report text.
|
||||||
|
pr_merged_or_closed: Optional structured signal that the PR is already
|
||||||
|
merged/closed. When ``True`` it forces the merged-before-review
|
||||||
|
path even if the report text omits the phrasing; proof fields are
|
||||||
|
still required in the report body for durability.
|
||||||
|
|
||||||
|
Returns a dict with ``proven``, ``block``, ``outcome``,
|
||||||
|
``process_state_label``, ``reasons``, ``skipped`` and ``safe_next_action``.
|
||||||
|
Only blocks when the report either claims an active approval on a
|
||||||
|
merged/closed PR, or claims post-merge moot validation without proof.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
|
||||||
|
merged_signal = bool(_MERGED_BEFORE_REVIEW_RE.search(text)) or bool(
|
||||||
|
pr_merged_or_closed
|
||||||
|
)
|
||||||
|
active_approval = bool(_ACTIVE_APPROVAL_RE.search(text))
|
||||||
|
moot_wording = bool(_POST_MERGE_MOOT_RE.search(text))
|
||||||
|
|
||||||
|
# Nothing about merged-state or moot validation — not applicable here.
|
||||||
|
if not merged_signal and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": None,
|
||||||
|
"process_state_label": None,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# An active approval on a PR already merged/closed before review, without
|
||||||
|
# the sanctioned moot wording, overstates confidence.
|
||||||
|
if merged_signal and active_approval and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [
|
||||||
|
"PR was already merged/closed before review submission; an "
|
||||||
|
"active approval overstates confidence — record 'post-merge "
|
||||||
|
"moot validation' instead of a normal approval"
|
||||||
|
],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"classify the outcome as post-merge moot validation (not an "
|
||||||
|
"active approval); cite PR state merged/closed and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-merge moot validation claimed — require merged-state + commit proof.
|
||||||
|
if moot_wording:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not _MERGED_STATE_PROOF_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without merged/closed state "
|
||||||
|
"proof (state: merged/closed, or merged_at/closed_at)"
|
||||||
|
)
|
||||||
|
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without a merge commit SHA"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": reasons,
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove the PR is merged/closed: cite PR state and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merged signal present but no approval claim and no moot wording yet —
|
||||||
|
# guide toward the moot outcome without blocking.
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"PR appears merged/closed; if submitting a verdict, record "
|
||||||
|
"post-merge moot validation rather than an active approval"
|
||||||
|
),
|
||||||
|
}
|
||||||
+15
-1
@@ -132,6 +132,13 @@ def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
|||||||
_SELECTED_PR_RE = re.compile(
|
_SELECTED_PR_RE = re.compile(
|
||||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||||
)
|
)
|
||||||
|
_SELECTED_PR_NONE_RE = re.compile(
|
||||||
|
r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE
|
||||||
|
)
|
||||||
|
_EMPTY_QUEUE_RE = re.compile(
|
||||||
|
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_NEXT_SUGGESTED_RE = re.compile(
|
_NEXT_SUGGESTED_RE = re.compile(
|
||||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||||
)
|
)
|
||||||
@@ -176,7 +183,11 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
|||||||
|
|
||||||
selected = _SELECTED_PR_RE.findall(text)
|
selected = _SELECTED_PR_RE.findall(text)
|
||||||
if len(selected) == 0:
|
if len(selected) == 0:
|
||||||
reasons.append("report must name exactly one Selected PR")
|
if _SELECTED_PR_NONE_RE.search(text):
|
||||||
|
if not _EMPTY_QUEUE_RE.search(text):
|
||||||
|
reasons.append("Selected PR is 'none' but no valid empty-queue claim found")
|
||||||
|
else:
|
||||||
|
reasons.append("report must name exactly one Selected PR")
|
||||||
elif len(set(selected)) > 1:
|
elif len(set(selected)) > 1:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"cleanup run selected multiple PRs "
|
"cleanup run selected multiple PRs "
|
||||||
@@ -184,6 +195,9 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
|||||||
"PR per run"
|
"PR per run"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text):
|
||||||
|
reasons.append("report contains both a selected PR and a claim of none selected")
|
||||||
|
|
||||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||||
if len(terminal) > 1:
|
if len(terminal) > 1:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Pre-merge baseline proof verifier for non-zero validation exits (#533).
|
||||||
|
|
||||||
|
A controller/reviewer may reproduce a failing test on *current* master and
|
||||||
|
describe it as proof of a "known baseline failure". Reproducing a failure on
|
||||||
|
post-merge master only proves the failure exists on current master — it does
|
||||||
|
NOT prove the failure pre-existed the PR under review. A PR can introduce or
|
||||||
|
preserve a regression, then once merged the failure appears on master and gets
|
||||||
|
mislabeled "baseline", weakening validation gates.
|
||||||
|
|
||||||
|
This module distinguishes four canonical validation outcomes:
|
||||||
|
|
||||||
|
- ``CLEAN_PASS`` — the validation command exited zero.
|
||||||
|
- ``CURRENT_MASTER_FAILURE_REPRODUCED`` — the same failure reproduces on
|
||||||
|
current (post-merge) master. Not sufficient as baseline proof.
|
||||||
|
- ``PREMERGE_BASELINE_PROVEN_FAILURE`` — the failure is proven on the PR's
|
||||||
|
pre-merge base commit, or by a documented known-failure record that predates
|
||||||
|
the PR.
|
||||||
|
- ``UNRESOLVED_REGRESSION_RISK`` — a non-zero exit that is neither a clean pass
|
||||||
|
nor proven pre-existing.
|
||||||
|
|
||||||
|
A report may only call a non-zero validation exit a "baseline"/"pre-existing"
|
||||||
|
failure when it carries pre-merge proof.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEAN_PASS = "clean pass"
|
||||||
|
CURRENT_MASTER_FAILURE_REPRODUCED = "current-master failure reproduced"
|
||||||
|
PREMERGE_BASELINE_PROVEN_FAILURE = "pre-merge baseline-proven failure"
|
||||||
|
UNRESOLVED_REGRESSION_RISK = "unresolved regression risk"
|
||||||
|
|
||||||
|
# A non-zero validation exit is claimed somewhere in the report.
|
||||||
|
_NONZERO_EXIT_RE = re.compile(
|
||||||
|
r"(?:exit(?:\s*(?:status|code))?\s*[:=]?\s*(?:[1-9]\d*)"
|
||||||
|
r"|\b\d+\s+failed\b"
|
||||||
|
r"|\bnon[- ]zero\s+(?:exit|validation))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# The report claims the failure is pre-existing / a baseline failure.
|
||||||
|
_BASELINE_CLAIM_RE = re.compile(
|
||||||
|
r"(?:pre[- ]?existing(?:\s+(?:baseline|failure))?"
|
||||||
|
r"|baseline(?:[- ]proven)?\s+failure"
|
||||||
|
r"|known[- ]baseline"
|
||||||
|
r"|failure\s+is\s+baseline"
|
||||||
|
r"|already\s+fail(?:s|ing)\s+on\s+master)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Only-current-master reproduction language (insufficient on its own).
|
||||||
|
_CURRENT_MASTER_RE = re.compile(
|
||||||
|
r"(?:current[- ]master\s+(?:reproduc|failure)"
|
||||||
|
r"|reproduc\w*\s+on\s+(?:current\s+)?master"
|
||||||
|
r"|post[- ]merge\s+master"
|
||||||
|
r"|fails\s+on\s+(?:current\s+)?master\s+(?:now|too|as\s+well))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Pre-merge base proof: a base commit tested before the PR landed.
|
||||||
|
_PREMERGE_BASE_RE = re.compile(
|
||||||
|
r"(?:pre[- ]merge\s+base(?:\s+commit)?"
|
||||||
|
r"|pr\s+base\s+commit"
|
||||||
|
r"|merge[- ]base(?:\s+commit)?"
|
||||||
|
r"|base\s+commit\s+before\s+(?:the\s+)?pr)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Documented known-failure record predating the PR.
|
||||||
|
_KNOWN_FAILURE_RECORD_RE = re.compile(
|
||||||
|
r"known[- ]failure\s+(?:record|reference|ledger)\b.{0,80}?"
|
||||||
|
r"(?:predat|before\s+(?:the\s+)?pr|pre[- ]existing|prior\s+to\s+(?:the\s+)?pr)",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Required proof fields for a pre-merge baseline claim.
|
||||||
|
_FIELD_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||||
|
"base commit": re.compile(
|
||||||
|
r"(?:pre[- ]merge\s+)?base\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||||
|
),
|
||||||
|
"tested commit": re.compile(
|
||||||
|
r"tested\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||||
|
),
|
||||||
|
"command": re.compile(r"command\s*[:=]\s*\S", re.IGNORECASE),
|
||||||
|
"exit status": re.compile(r"exit\s*(?:status|code)?\s*[:=]\s*\d+", re.IGNORECASE),
|
||||||
|
"failure signature": re.compile(
|
||||||
|
r"failure\s+signature\s*[:=]\s*\S", re.IGNORECASE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_premerge_baseline_proof(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Assess whether a claimed baseline failure carries pre-merge proof.
|
||||||
|
|
||||||
|
Returns a dict with ``proven``, ``block``, ``label``, ``reasons``,
|
||||||
|
``skipped`` and ``safe_next_action``. Only blocks when the report claims a
|
||||||
|
non-zero validation exit is baseline/pre-existing without valid pre-merge
|
||||||
|
proof.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
|
||||||
|
has_failure = bool(_NONZERO_EXIT_RE.search(text))
|
||||||
|
claims_baseline = bool(_BASELINE_CLAIM_RE.search(text))
|
||||||
|
|
||||||
|
# No failure claimed, or no baseline assertion — nothing to enforce here.
|
||||||
|
if not has_failure and not claims_baseline:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"label": CLEAN_PASS,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not claims_baseline:
|
||||||
|
# A non-zero exit not asserted as baseline — surface as regression risk,
|
||||||
|
# but do not block (the report never claimed a clean/baseline pass).
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"label": UNRESOLVED_REGRESSION_RISK,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
has_premerge_base = bool(_PREMERGE_BASE_RE.search(text))
|
||||||
|
has_known_record = bool(_KNOWN_FAILURE_RECORD_RE.search(text))
|
||||||
|
only_current_master = bool(_CURRENT_MASTER_RE.search(text))
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if not (has_premerge_base or has_known_record):
|
||||||
|
if only_current_master:
|
||||||
|
reasons.append(
|
||||||
|
"baseline failure claimed from current-master reproduction only; "
|
||||||
|
"that proves the failure on current master, not that it pre-existed "
|
||||||
|
"the PR — provide pre-merge base proof or a known-failure record"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"baseline/pre-existing failure claimed without pre-merge proof "
|
||||||
|
"(no pre-merge base commit and no documented known-failure record "
|
||||||
|
"predating the PR)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Require the concrete proof fields regardless of proof source.
|
||||||
|
missing = [name for name, pat in _FIELD_PATTERNS.items() if not pat.search(text)]
|
||||||
|
if missing:
|
||||||
|
reasons.append(
|
||||||
|
"pre-merge baseline claim missing required proof field(s): "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"label": UNRESOLVED_REGRESSION_RISK,
|
||||||
|
"reasons": reasons,
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove the failure on the PR pre-merge base commit (or cite a "
|
||||||
|
"known-failure record predating the PR) and state base commit, "
|
||||||
|
"tested commit, command, exit status, and failure signature; "
|
||||||
|
"current-master reproduction alone is not baseline proof"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"label": PREMERGE_BASELINE_PROVEN_FAILURE,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
+26
-2
@@ -16,11 +16,35 @@ or the local remote URL is unavailable (best-effort corroboration only).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
REMEDIATION = (
|
REMEDIATION = (
|
||||||
"Pass explicit org= and repo= matching the local git remote, "
|
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||||
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools."
|
"accept those parameters (including mcp_get_control_plane_guide), "
|
||||||
|
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools. "
|
||||||
|
"Do not pass org/repo to tools whose schema does not list them."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# https://host/org/repo.git or git@host:org/repo.git
|
||||||
|
_REMOTE_URL_SLUG_RE = re.compile(
|
||||||
|
r"(?:[:/])(?P<org>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_org_repo_from_remote_url(remote_url: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Best-effort parse of org/repo from a git remote URL."""
|
||||||
|
url = (remote_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _REMOTE_URL_SLUG_RE.search(url)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
org = (match.group("org") or "").strip()
|
||||||
|
repo = (match.group("repo") or "").strip()
|
||||||
|
if not org or not repo:
|
||||||
|
return None
|
||||||
|
return org, repo
|
||||||
|
|
||||||
|
|
||||||
def assess_remote_repo_match(
|
def assess_remote_repo_match(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
|
|||||||
capability_blocked: bool = False,
|
capability_blocked: bool = False,
|
||||||
mcp_reconnect_failed: bool = False,
|
mcp_reconnect_failed: bool = False,
|
||||||
stale_capability_state: bool = False,
|
stale_capability_state: bool = False,
|
||||||
|
live_namespace_broken: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
|
"""Return hard blockers that forbid all PR queue work (#290 AC3).
|
||||||
|
|
||||||
|
``live_namespace_broken`` fails the merge/review path closed when the live
|
||||||
|
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
|
||||||
|
though the tool is registered in FastMCP (#543 AC5). Feed it the
|
||||||
|
``blocks_merge_workflow`` verdict from
|
||||||
|
``mcp_namespace_health.classify_namespace_probe``.
|
||||||
|
"""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if infra_stop:
|
if infra_stop:
|
||||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||||
@@ -104,6 +112,12 @@ def assess_workflow_blockers(
|
|||||||
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
||||||
if stale_capability_state:
|
if stale_capability_state:
|
||||||
reasons.append("stale MCP capability state detected after reconnect failure")
|
reasons.append("stale MCP capability state detected after reconnect failure")
|
||||||
|
if live_namespace_broken:
|
||||||
|
reasons.append(
|
||||||
|
"live MCP namespace call path is broken (registered in FastMCP but "
|
||||||
|
"not callable through the namespace); repair the namespace before "
|
||||||
|
"review/merge"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"block": bool(reasons),
|
"block": bool(reasons),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
@@ -118,16 +132,19 @@ def assess_state_advancement(
|
|||||||
state_completion: dict[str, bool] | None,
|
state_completion: dict[str, bool] | None,
|
||||||
*,
|
*,
|
||||||
target_state: str,
|
target_state: str,
|
||||||
infra_stop: bool = False,
|
**blocker_kwargs,
|
||||||
capability_blocked: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed when *target_state* is requested before upstream gates pass."""
|
"""Fail closed when *target_state* is requested before upstream gates pass.
|
||||||
|
|
||||||
|
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
|
||||||
|
``mcp_reconnect_failed``, ``stale_capability_state``,
|
||||||
|
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
|
||||||
|
``can_approve``/``can_merge`` honor the full blocker set, not just
|
||||||
|
infra_stop/capability_blocked (#543 AC5).
|
||||||
|
"""
|
||||||
completion = dict(state_completion or {})
|
completion = dict(state_completion or {})
|
||||||
target = _clean(target_state).upper()
|
target = _clean(target_state).upper()
|
||||||
blockers = assess_workflow_blockers(
|
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||||
infra_stop=infra_stop,
|
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
)
|
|
||||||
reasons = list(blockers["reasons"])
|
reasons = list(blockers["reasons"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+191
-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)"
|
||||||
],
|
],
|
||||||
@@ -4830,6 +4842,22 @@ RECONCILER_CLOSE_REPORT_FIELDS = (
|
|||||||
"no review/merge confirmation",
|
"no review/merge confirmation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RECONCILER_SUPERSESSION_REPORT_FIELDS = (
|
||||||
|
"identity/profile",
|
||||||
|
"supersession close capability proof",
|
||||||
|
"target PR live state",
|
||||||
|
"target PR independent-work proof",
|
||||||
|
"superseding PR merged state",
|
||||||
|
"superseding merge commit SHA",
|
||||||
|
"target branch SHA",
|
||||||
|
"superseding ancestry proof",
|
||||||
|
"canonical close comment",
|
||||||
|
"linked issue status",
|
||||||
|
"PR close result",
|
||||||
|
"issue close result",
|
||||||
|
"no review/merge confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_reconciler_close_gate(
|
def assess_reconciler_close_gate(
|
||||||
*,
|
*,
|
||||||
@@ -4976,6 +5004,157 @@ def assess_reconciler_close_gate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_reconciler_supersession_close_gate(
|
||||||
|
*,
|
||||||
|
target_pr_number: int | None,
|
||||||
|
target_pr_state: str | None,
|
||||||
|
target_pr_mergeable: bool | None,
|
||||||
|
target_independently_required: bool | None,
|
||||||
|
superseding_pr_number: int | None,
|
||||||
|
superseding_pr_state: str | None,
|
||||||
|
superseding_pr_merged: bool,
|
||||||
|
superseding_merge_commit_sha: str | None,
|
||||||
|
superseding_head_sha: str | None,
|
||||||
|
target_branch: str | None,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
superseding_head_is_ancestor_of_target: bool | None,
|
||||||
|
canonical_comment_valid: bool,
|
||||||
|
canonical_comment_mentions_superseding_pr: bool,
|
||||||
|
canonical_comment_mentions_merge_commit: bool,
|
||||||
|
close_pr_capability: bool,
|
||||||
|
close_issue_capability: bool = False,
|
||||||
|
target_issue_state: str | None = None,
|
||||||
|
issue_satisfied_by_superseding: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Gate reconciler closure of PRs/issues superseded by a merged PR.
|
||||||
|
|
||||||
|
This is stricter than already-landed cleanup: the target PR may be closed
|
||||||
|
only after a named superseding PR is live-verified merged, its head is on a
|
||||||
|
freshly fetched target branch, the target PR is proven not independently
|
||||||
|
required, and a canonical close comment cites the superseding PR and merge
|
||||||
|
commit.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not isinstance(target_pr_number, int) or target_pr_number <= 0:
|
||||||
|
reasons.append("target PR number missing or invalid (#525)")
|
||||||
|
if not isinstance(superseding_pr_number, int) or superseding_pr_number <= 0:
|
||||||
|
reasons.append("superseding PR number missing or invalid (#525)")
|
||||||
|
if target_pr_number and superseding_pr_number and (
|
||||||
|
target_pr_number == superseding_pr_number
|
||||||
|
):
|
||||||
|
reasons.append("target PR and superseding PR must differ (#525)")
|
||||||
|
if (target_pr_state or "").strip().lower() != "open":
|
||||||
|
reasons.append("target PR must be live-verified open before close (#525)")
|
||||||
|
if target_pr_mergeable is True:
|
||||||
|
reasons.append(
|
||||||
|
"target PR is still mergeable; prove it is superseded before close (#525)"
|
||||||
|
)
|
||||||
|
if target_independently_required is not False:
|
||||||
|
reasons.append(
|
||||||
|
"target PR independent-work proof missing; close requires explicit "
|
||||||
|
"proof that no required work remains (#525)"
|
||||||
|
)
|
||||||
|
if (superseding_pr_state or "").strip().lower() != "closed":
|
||||||
|
reasons.append("superseding PR must be live-verified closed (#525)")
|
||||||
|
if superseding_pr_merged is not True:
|
||||||
|
reasons.append("superseding PR must be live-verified merged (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_merge_commit_sha or "").strip()):
|
||||||
|
reasons.append("superseding merge commit SHA missing or invalid (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_head_sha or "").strip()):
|
||||||
|
reasons.append("superseding head SHA missing or invalid (#525)")
|
||||||
|
if not (target_branch or "").strip():
|
||||||
|
reasons.append("target branch missing (#525)")
|
||||||
|
if not _FULL_SHA.match((target_branch_sha or "").strip()):
|
||||||
|
reasons.append("target branch SHA missing or invalid (#525)")
|
||||||
|
if superseding_head_is_ancestor_of_target is None:
|
||||||
|
reasons.append("superseding ancestry proof not checked (#525)")
|
||||||
|
if not canonical_comment_valid:
|
||||||
|
reasons.append("canonical close comment failed validation (#525)")
|
||||||
|
if not canonical_comment_mentions_superseding_pr:
|
||||||
|
reasons.append("canonical comment must cite superseding PR (#525)")
|
||||||
|
if not canonical_comment_mentions_merge_commit:
|
||||||
|
reasons.append("canonical comment must cite merge commit SHA (#525)")
|
||||||
|
|
||||||
|
never_allowed = {
|
||||||
|
"review_allowed": False,
|
||||||
|
"approve_allowed": False,
|
||||||
|
"request_changes_allowed": False,
|
||||||
|
"merge_allowed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"outcome": "GATE_NOT_PROVEN",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove superseding PR state, target branch ancestry, target "
|
||||||
|
"supersession, and canonical comment before closing"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if superseding_head_is_ancestor_of_target is False:
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSEDING_PR_NOT_ON_TARGET",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
f"superseding PR #{superseding_pr_number} head is not an "
|
||||||
|
f"ancestor of {target_branch}; supersession close denied (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": "re-fetch target branch and re-check ancestry",
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if close_pr_capability is not True:
|
||||||
|
return {
|
||||||
|
"outcome": "RECOVERY_HANDOFF_REQUIRED",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
"exact gitea.pr.close capability not proven; produce a "
|
||||||
|
"recovery handoff instead of closing (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"launch a reconciler profile with gitea.pr.close and replay "
|
||||||
|
"the live-state proof"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_close_allowed = (
|
||||||
|
(target_issue_state or "").strip().lower() == "open"
|
||||||
|
and issue_satisfied_by_superseding is True
|
||||||
|
and close_issue_capability is True
|
||||||
|
)
|
||||||
|
issue_reasons = []
|
||||||
|
if (target_issue_state or "").strip().lower() == "closed":
|
||||||
|
issue_reasons.append("linked issue already closed; no issue close attempted (#525)")
|
||||||
|
elif target_issue_state and not issue_close_allowed:
|
||||||
|
issue_reasons.append(
|
||||||
|
"issue close requires an open issue satisfied by the superseding "
|
||||||
|
"merged PR and exact gitea.issue.close capability (#525)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSESSION_CLOSE_ALLOWED",
|
||||||
|
"pr_close_allowed": True,
|
||||||
|
"issue_close_allowed": issue_close_allowed,
|
||||||
|
"reasons": issue_reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"post the canonical comment, close the superseded PR, and close "
|
||||||
|
"the linked issue only when issue_close_allowed is true"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Identity disclosure (#305)
|
# Identity disclosure (#305)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -5540,6 +5719,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,
|
||||||
|
}
|
||||||
+259
-12
@@ -190,18 +190,28 @@ def find_active_reviewer_lease(
|
|||||||
pr_number: int,
|
pr_number: int,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
"""Return the newest unexpired non-terminal lease for *pr_number*.
|
||||||
|
|
||||||
|
Append-only lease markers form a ledger: the **newest** marker is
|
||||||
|
authoritative. A later terminal phase (``released`` / ``done`` /
|
||||||
|
``blocked``) ends the lease even when older ``claimed`` markers remain
|
||||||
|
on the thread (#577). Skipping only terminal markers and walking older
|
||||||
|
claims incorrectly re-arms a lease after a successful release.
|
||||||
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
if not entries:
|
||||||
if phase in _TERMINAL_PHASES:
|
return None
|
||||||
continue
|
newest = entries[0]
|
||||||
if _lease_expired(lease, now=now):
|
phase = (newest.get("phase") or "").strip().lower()
|
||||||
continue
|
if phase in _TERMINAL_PHASES:
|
||||||
if phase in _ACTIVE_PHASES or phase:
|
return None
|
||||||
lease = dict(lease)
|
if _lease_expired(newest, now=now):
|
||||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
return None
|
||||||
return lease
|
if phase in _ACTIVE_PHASES or phase:
|
||||||
|
lease = dict(newest)
|
||||||
|
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||||
|
return lease
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -512,4 +522,241 @@ def assess_lease_inventory(
|
|||||||
"active_review_leases": active,
|
"active_review_leases": active,
|
||||||
"stale_review_leases": stale,
|
"stale_review_leases": stale,
|
||||||
"reclaimable_review_leases": reclaimable,
|
"reclaimable_review_leases": reclaimable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Canonical next-action vocabulary for reviewer lease handoff (#599).
|
||||||
|
NEXT_ACTION_ACQUIRE = "acquire"
|
||||||
|
NEXT_ACTION_WAIT = "wait"
|
||||||
|
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION = "resume_exact_owner_session"
|
||||||
|
NEXT_ACTION_RELEASE_EXPIRED_LEASE = "release_expired_lease"
|
||||||
|
NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP = "operator_authorized_cleanup"
|
||||||
|
NEXT_ACTION_REPAIR_WORKTREE_BINDING = "repair_worktree_binding"
|
||||||
|
|
||||||
|
_HANDOFF_CLASSIFICATIONS = frozenset({
|
||||||
|
"no_lease",
|
||||||
|
"own_active",
|
||||||
|
"own_expired",
|
||||||
|
"foreign_active",
|
||||||
|
"foreign_reclaimable",
|
||||||
|
"foreign_expired",
|
||||||
|
"instructed_lease_missing_with_replacement",
|
||||||
|
"worktree_binding_mismatch",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_path(value: str | None) -> str:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return os.path.normpath(text.rstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_reviewer_pr_lease_handoff(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
current_session_id: str | None,
|
||||||
|
current_reviewer_identity: str | None,
|
||||||
|
proposed_worktree: str | None = None,
|
||||||
|
env_bound_worktree: str | None = None,
|
||||||
|
instructed_session_id: str | None = None,
|
||||||
|
instructed_comment_id: int | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify open-PR reviewer lease handoff and emit a canonical next action (#599).
|
||||||
|
|
||||||
|
Read-only diagnosis. Never steals, releases, or adopts a foreign lease.
|
||||||
|
Fail-closed acquisition rules for active foreign leases remain intact.
|
||||||
|
|
||||||
|
Returns a structured diagnosis with:
|
||||||
|
- classification
|
||||||
|
- next_action (one of wait / resume_exact_owner_session /
|
||||||
|
release_expired_lease / operator_authorized_cleanup /
|
||||||
|
repair_worktree_binding / acquire)
|
||||||
|
- active_lease identity fields when present
|
||||||
|
- worktree_binding match result
|
||||||
|
- instructed-lease mismatch flags
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
session_id = (current_session_id or "").strip()
|
||||||
|
identity = (current_reviewer_identity or "").strip()
|
||||||
|
instructed_sid = (instructed_session_id or "").strip()
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
session_lease = get_session_lease()
|
||||||
|
|
||||||
|
# Worktree binding: env-bound vs proposed vs active lease worktree.
|
||||||
|
env_wt = _norm_path(env_bound_worktree)
|
||||||
|
prop_wt = _norm_path(proposed_worktree)
|
||||||
|
lease_wt = _norm_path((active or {}).get("worktree") if active else None)
|
||||||
|
binding_mismatch = False
|
||||||
|
binding_details: dict[str, Any] = {
|
||||||
|
"env_bound_worktree": env_bound_worktree or None,
|
||||||
|
"proposed_worktree": proposed_worktree or None,
|
||||||
|
"lease_worktree": (active or {}).get("worktree") if active else None,
|
||||||
|
"match": True,
|
||||||
|
}
|
||||||
|
paths = [p for p in (env_wt, prop_wt, lease_wt) if p]
|
||||||
|
if len(paths) >= 2 and len(set(paths)) > 1:
|
||||||
|
binding_mismatch = True
|
||||||
|
binding_details["match"] = False
|
||||||
|
reasons.append(
|
||||||
|
"worktree binding mismatch: env/proposed/lease worktree paths disagree"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Instructed lease gone while a different lease is active (PR #592-style).
|
||||||
|
instructed_missing_with_replacement = False
|
||||||
|
if instructed_sid or instructed_comment_id is not None:
|
||||||
|
if not active:
|
||||||
|
reasons.append(
|
||||||
|
"instructed lease is gone and no active replacement lease remains"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
owner = (active.get("session_id") or "").strip()
|
||||||
|
cid = active.get("comment_id")
|
||||||
|
sid_mismatch = bool(instructed_sid and owner and owner != instructed_sid)
|
||||||
|
cid_mismatch = (
|
||||||
|
instructed_comment_id is not None
|
||||||
|
and cid is not None
|
||||||
|
and int(cid) != int(instructed_comment_id)
|
||||||
|
)
|
||||||
|
if sid_mismatch or cid_mismatch:
|
||||||
|
instructed_missing_with_replacement = True
|
||||||
|
reasons.append(
|
||||||
|
"instructed lease is gone; a different active lease replaced it "
|
||||||
|
f"(active session_id={owner}, comment_id={cid})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classification + next_action.
|
||||||
|
classification = "no_lease"
|
||||||
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
|
||||||
|
if active:
|
||||||
|
owner = (active.get("session_id") or "").strip()
|
||||||
|
freshness = active.get("freshness") or classify_lease_freshness(
|
||||||
|
active, now=now
|
||||||
|
)
|
||||||
|
owner_identity = (active.get("reviewer_identity") or "").strip()
|
||||||
|
is_own = bool(session_id and owner and owner == session_id)
|
||||||
|
# Same identity alone is NOT ownership for resume; session_id must match.
|
||||||
|
same_identity = bool(
|
||||||
|
identity and owner_identity and identity == owner_identity
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_own and freshness in {"active", "stale_warning"}:
|
||||||
|
classification = "own_active"
|
||||||
|
next_action = NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
elif is_own and freshness in {"reclaimable", "expired"}:
|
||||||
|
classification = "own_expired"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"own lease freshness is '{freshness}'; release via "
|
||||||
|
"gitea_release_reviewer_pr_lease then re-acquire"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness in {"active", "stale_warning"}:
|
||||||
|
classification = "foreign_active"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
f"foreign active reviewer lease (session_id={owner}, "
|
||||||
|
f"phase={active.get('phase')}, freshness={freshness}); "
|
||||||
|
"do not submit; do not steal"
|
||||||
|
)
|
||||||
|
if same_identity:
|
||||||
|
reasons.append(
|
||||||
|
"lease identity matches current reviewer but session_id differs; "
|
||||||
|
"resume only from the exact owner session_id or wait"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness == "reclaimable":
|
||||||
|
classification = "foreign_reclaimable"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"foreign reclaimable lease (session_id={owner}); clear only via "
|
||||||
|
"sanctioned gitea_release_reviewer_pr_lease when reclaimable"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness == "expired":
|
||||||
|
classification = "foreign_expired"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"foreign expired lease (session_id={owner}); use sanctioned release"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
classification = "foreign_active"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
f"unclassified active lease state (session_id={owner}, "
|
||||||
|
f"freshness={freshness}); wait fail-closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instructed_missing_with_replacement and classification.startswith(
|
||||||
|
"foreign"
|
||||||
|
):
|
||||||
|
classification = "instructed_lease_missing_with_replacement"
|
||||||
|
# Foreign active still means wait; reclaimable still release.
|
||||||
|
if next_action == NEXT_ACTION_WAIT:
|
||||||
|
reasons.append(
|
||||||
|
"replacement foreign lease is active — wait; "
|
||||||
|
"operator_authorized_cleanup only with explicit operator authority"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
classification = "no_lease"
|
||||||
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
reasons.append("no active reviewer lease; acquire via gitea_acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
# Binding mismatch is a first-class blocker before submit, but does not
|
||||||
|
# erase foreign-lease wait/release guidance. Override next_action only when
|
||||||
|
# the session would otherwise be free to acquire or resume (submit path).
|
||||||
|
if binding_mismatch:
|
||||||
|
if next_action in {
|
||||||
|
NEXT_ACTION_ACQUIRE,
|
||||||
|
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION,
|
||||||
|
}:
|
||||||
|
classification = "worktree_binding_mismatch"
|
||||||
|
next_action = NEXT_ACTION_REPAIR_WORKTREE_BINDING
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"also repair worktree binding before submit "
|
||||||
|
f"(next_action remains {next_action})"
|
||||||
|
)
|
||||||
|
|
||||||
|
lease_summary = None
|
||||||
|
if active:
|
||||||
|
lease_summary = {
|
||||||
|
"comment_id": active.get("comment_id"),
|
||||||
|
"session_id": active.get("session_id"),
|
||||||
|
"phase": active.get("phase"),
|
||||||
|
"candidate_head": active.get("candidate_head"),
|
||||||
|
"expires_at": active.get("expires_at"),
|
||||||
|
"last_activity": active.get("last_activity"),
|
||||||
|
"freshness": active.get("freshness")
|
||||||
|
or classify_lease_freshness(active, now=now),
|
||||||
|
"reviewer_identity": active.get("reviewer_identity"),
|
||||||
|
"profile": active.get("profile"),
|
||||||
|
"worktree": active.get("worktree"),
|
||||||
|
"blocker": active.get("blocker"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"classification": classification,
|
||||||
|
"next_action": next_action,
|
||||||
|
"active_lease": lease_summary,
|
||||||
|
"session_lease": session_lease,
|
||||||
|
"worktree_binding": binding_details,
|
||||||
|
"instructed_session_id": instructed_session_id,
|
||||||
|
"instructed_comment_id": instructed_comment_id,
|
||||||
|
"instructed_lease_missing_with_replacement": instructed_missing_with_replacement,
|
||||||
|
"mutation_allowed": (
|
||||||
|
next_action == NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
and not binding_mismatch
|
||||||
|
and bool(session_lease)
|
||||||
|
),
|
||||||
|
"reasons": reasons,
|
||||||
|
"forbidden": [
|
||||||
|
"manual lock deletion",
|
||||||
|
"raw API bypass",
|
||||||
|
"mtime manipulation",
|
||||||
|
"direct _SESSION_LEASE seeding",
|
||||||
|
"silent foreign lease steal",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
+34
-3
@@ -55,7 +55,6 @@ def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
|
|||||||
|
|
||||||
REVIEWER_TASKS = frozenset({
|
REVIEWER_TASKS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
"merge_pr",
|
|
||||||
"blind_pr_queue_review",
|
"blind_pr_queue_review",
|
||||||
"pr_queue_cleanup",
|
"pr_queue_cleanup",
|
||||||
"pr-queue-cleanup",
|
"pr-queue-cleanup",
|
||||||
@@ -63,6 +62,10 @@ REVIEWER_TASKS = frozenset({
|
|||||||
"approve_pr",
|
"approve_pr",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
MERGER_TASKS = frozenset({
|
||||||
|
"merge_pr",
|
||||||
|
})
|
||||||
|
|
||||||
AUTHOR_TASKS = frozenset({
|
AUTHOR_TASKS = frozenset({
|
||||||
"create_issue",
|
"create_issue",
|
||||||
"comment_issue",
|
"comment_issue",
|
||||||
@@ -80,6 +83,7 @@ AUTHOR_TASKS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
"reconcile_already_landed_pr",
|
"reconcile_already_landed_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -97,7 +101,7 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
"delete_branch": "author",
|
"delete_branch": "author",
|
||||||
"review_pr": "reviewer",
|
"review_pr": "reviewer",
|
||||||
"merge_pr": "reviewer",
|
"merge_pr": "merger",
|
||||||
"blind_pr_queue_review": "reviewer",
|
"blind_pr_queue_review": "reviewer",
|
||||||
"pr_queue_cleanup": "reviewer",
|
"pr_queue_cleanup": "reviewer",
|
||||||
"pr-queue-cleanup": "reviewer",
|
"pr-queue-cleanup": "reviewer",
|
||||||
@@ -109,9 +113,13 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_already_landed_pr": "reconciler",
|
"reconcile_already_landed_pr": "reconciler",
|
||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
|
"cleanup_merged_pr_branch": "reconciler",
|
||||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||||
"reconcile_close_landed_pr": "reconciler",
|
"reconcile_close_landed_pr": "reconciler",
|
||||||
"reconcile_close_landed_issue": "reconciler",
|
"reconcile_close_landed_issue": "reconciler",
|
||||||
|
"reconcile_close_superseded_pr": "reconciler",
|
||||||
|
"reconcile_close_satisfied_issue": "reconciler",
|
||||||
|
"reconcile_create_followup_issue": "reconciler",
|
||||||
}
|
}
|
||||||
|
|
||||||
WRONG_ROLE_REVIEWER_MSG = (
|
WRONG_ROLE_REVIEWER_MSG = (
|
||||||
@@ -123,6 +131,10 @@ WRONG_ROLE_RECONCILER_MSG = (
|
|||||||
"MCP namespace/profile with exact close capability."
|
"MCP namespace/profile with exact close capability."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WRONG_ROLE_MERGER_MSG = (
|
||||||
|
"Wrong role/session for merger task. Launch merger MCP namespace."
|
||||||
|
)
|
||||||
|
|
||||||
_session_last_route: dict | None = None
|
_session_last_route: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +250,25 @@ def route_task_session(
|
|||||||
_record_route(result)
|
_record_route(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if required_role == "merger":
|
||||||
|
result = {
|
||||||
|
"task_type": task_type,
|
||||||
|
"required_role": required_role,
|
||||||
|
"active_role": active_role_kind,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"route_result": ROUTE_WRONG_ROLE,
|
||||||
|
"downstream_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
WRONG_ROLE_MERGER_MSG,
|
||||||
|
"Merger tasks cannot run in author or reviewer sessions.",
|
||||||
|
],
|
||||||
|
"message": WRONG_ROLE_MERGER_MSG,
|
||||||
|
"runtime_switching_supported": runtime_switching_supported,
|
||||||
|
"profile_switch_blocked": not runtime_switching_supported,
|
||||||
|
}
|
||||||
|
_record_route(result)
|
||||||
|
return result
|
||||||
|
|
||||||
if required_role == "author":
|
if required_role == "author":
|
||||||
route = ROUTE_TO_AUTHOR
|
route = ROUTE_TO_AUTHOR
|
||||||
message = (
|
message = (
|
||||||
@@ -406,4 +437,4 @@ def assess_infra_stop(project_root: str | None = None) -> dict:
|
|||||||
|
|
||||||
def check_mid_merge(project_root: str | None = None) -> bool:
|
def check_mid_merge(project_root: str | None = None) -> bool:
|
||||||
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
||||||
return assess_infra_stop(project_root)["infra_stop"]
|
return assess_infra_stop(project_root)["infra_stop"]
|
||||||
|
|||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Path-filtered CI gate for the internal web UI (#436).
|
||||||
|
# Runs the hermetic web UI unittest suite when a change touches UI surfaces.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
if [[ "${WEBUI_CI_FORCE:-}" == "1" ]]; then
|
||||||
|
exec "$script_dir/test-webui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
base_ref="${WEBUI_CI_BASE_REF:-}"
|
||||||
|
if [[ -z "$base_ref" ]]; then
|
||||||
|
if git rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD prgs/master 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$base_ref" ]]; then
|
||||||
|
base_ref="${GITHUB_BASE_REF:-${CHANGE_TARGET:-master}}"
|
||||||
|
if git rev-parse --verify "origin/$base_ref" >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD "origin/$base_ref")"
|
||||||
|
elif git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD "$base_ref")"
|
||||||
|
else
|
||||||
|
base_ref="HEAD~1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed="$(git diff --name-only "$base_ref" HEAD 2>/dev/null || true)"
|
||||||
|
if [[ -z "$changed" ]]; then
|
||||||
|
echo "ci-webui-check: no changed files vs $base_ref; skipping web UI suite"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||||
|
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||||
|
python_bin="$repo_root/venv/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s\n' "$changed" | "$python_bin" -c "
|
||||||
|
import sys
|
||||||
|
from webui.ci_paths import should_run_webui_ci
|
||||||
|
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||||
|
raise SystemExit(0 if should_run_webui_ci(paths) else 1)
|
||||||
|
"; then
|
||||||
|
echo "ci-webui-check: web UI surface changed; running suite"
|
||||||
|
exec "$script_dir/test-webui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-webui-check: no web UI paths in diff; skipping suite"
|
||||||
|
exit 0
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install canonical Gitea workflow skill into Codex skills dir (#551).
|
||||||
|
# Symlinks the in-repo skills/llm-project-workflow package under the names
|
||||||
|
# gitea-workflow, llm-project-workflow, and git-pr-workflows.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/install-codex-workflow-skill.sh [--dry-run] [--skills-dir DIR]
|
||||||
|
|
||||||
|
Symlink the portable skills/llm-project-workflow package into the Codex
|
||||||
|
skills directory under the canonical names:
|
||||||
|
gitea-workflow
|
||||||
|
llm-project-workflow
|
||||||
|
git-pr-workflows
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
skills dir: $CODEX_HOME/skills or ~/.codex/skills
|
||||||
|
source: <repo>/skills/llm-project-workflow
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
dry_run=0
|
||||||
|
skills_dir=""
|
||||||
|
while [[ "${1:-}" == --* ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--skills-dir)
|
||||||
|
shift
|
||||||
|
skills_dir="${1:-}"
|
||||||
|
;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
source_skill="$repo_root/skills/llm-project-workflow"
|
||||||
|
|
||||||
|
if [[ ! -f "$source_skill/SKILL.md" ]]; then
|
||||||
|
echo "Error: missing $source_skill/SKILL.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$skills_dir" ]]; then
|
||||||
|
if [[ -n "${CODEX_HOME:-}" ]]; then
|
||||||
|
skills_dir="$CODEX_HOME/skills"
|
||||||
|
else
|
||||||
|
skills_dir="${HOME}/.codex/skills"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
names=(gitea-workflow llm-project-workflow git-pr-workflows)
|
||||||
|
|
||||||
|
echo "source: $source_skill"
|
||||||
|
echo "codex skills dir: $skills_dir"
|
||||||
|
|
||||||
|
if [[ "$dry_run" -eq 0 ]]; then
|
||||||
|
mkdir -p "$skills_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for name in "${names[@]}"; do
|
||||||
|
target="$skills_dir/$name"
|
||||||
|
if [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
echo "dry-run: ln -sfn $source_skill $target"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -e "$target" || -L "$target" ]]; then
|
||||||
|
if [[ -L "$target" ]]; then
|
||||||
|
rm -f "$target"
|
||||||
|
else
|
||||||
|
echo "Error: $target exists and is not a symlink (refuse to overwrite)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ln -sfn "$source_skill" "$target"
|
||||||
|
echo "linked $target -> $source_skill"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done. Restart Codex so skills reload."
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||||
|
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||||
|
python_bin="$repo_root/venv/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pattern="${WEBUI_TEST_PATTERN:-test_webui_*.py}"
|
||||||
|
export WEBUI_TEST_OFFLINE="${WEBUI_TEST_OFFLINE:-1}"
|
||||||
|
exec "$python_bin" -m unittest discover -s tests -p "$pattern" "$@"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: gitea-workflow
|
||||||
|
description: >-
|
||||||
|
Canonical alias for the Gitea/LLM project workflow router. Same skill as
|
||||||
|
llm-project-workflow. Use at the start of any Gitea-Tools author, review,
|
||||||
|
merge, reconcile, or issue-filing task. Trigger terms: gitea-workflow,
|
||||||
|
llm-project-workflow, git-pr-workflows, gitea, PR review, work-issue.
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitea-workflow (canonical alias)
|
||||||
|
|
||||||
|
This directory is the **stable name** for controller prompts and Codex mounts
|
||||||
|
(`gitea-workflow`). The portable implementation lives at:
|
||||||
|
|
||||||
|
**[`../llm-project-workflow/SKILL.md`](../llm-project-workflow/SKILL.md)**
|
||||||
|
|
||||||
|
## Required action
|
||||||
|
|
||||||
|
1. Open and follow `skills/llm-project-workflow/SKILL.md` (router).
|
||||||
|
2. Load the matching workflow under `skills/llm-project-workflow/workflows/`.
|
||||||
|
3. Do not mutate git/Gitea until the workflow is loaded.
|
||||||
|
|
||||||
|
## Multi-runtime names (must resolve identically)
|
||||||
|
|
||||||
|
| Name | Role |
|
||||||
|
|------|------|
|
||||||
|
| `gitea-workflow` | Canonical controller / Codex skill name |
|
||||||
|
| `llm-project-workflow` | Portable in-repo skill package |
|
||||||
|
| `git-pr-workflows` | Legacy alias |
|
||||||
|
|
||||||
|
Install for Codex:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-codex-workflow-skill.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Preflight via MCP: `mcp_check_workflow_skill_preflight`.
|
||||||
@@ -6,6 +6,8 @@ description: >-
|
|||||||
report schema. Use at the start of any implementation, review, merge,
|
report schema. Use at the start of any implementation, review, merge,
|
||||||
reconciliation, or issue-filing task.
|
reconciliation, or issue-filing task.
|
||||||
---
|
---
|
||||||
|
> **Also known as:** `gitea-workflow`, `git-pr-workflows` (canonical multi-runtime names — see docs/workflow-skill-mount.md / #551).
|
||||||
|
|
||||||
|
|
||||||
# LLM Project Workflow Skill
|
# LLM Project Workflow Skill
|
||||||
|
|
||||||
@@ -31,12 +33,29 @@ workflow file.
|
|||||||
- A nearby capability does not count.
|
- A nearby capability does not count.
|
||||||
- Do not self-review or self-merge.
|
- Do not self-review or self-merge.
|
||||||
- Do not mix modes in one run.
|
- Do not mix modes in one run.
|
||||||
|
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
|
||||||
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
||||||
only.
|
only (see BLOCKED + DIAGNOSE rule above).
|
||||||
- Final report must use the schema for the loaded workflow.
|
- Final report must use the schema for the loaded workflow.
|
||||||
- If a task requires a different mode, stop and produce a handoff for the
|
- If a task requires a different mode, stop and produce a handoff for the
|
||||||
correct workflow.
|
correct workflow.
|
||||||
|
|
||||||
|
## Covered blocker classes (BLOCKED + DIAGNOSE must trigger for these)
|
||||||
|
|
||||||
|
- missing required skill or workflow guide (e.g. gitea-workflow, llm-project-workflow)
|
||||||
|
- broken terminal/tool runner or shell spawn failure
|
||||||
|
- MCP capability failure, reset, or deadlock (e.g. preflight state cleared)
|
||||||
|
- wrong profile or role for the requested operation
|
||||||
|
- dirty or misbound worktree (root checkout or non-branches/ path)
|
||||||
|
- root checkout mutation risk
|
||||||
|
- mutation guard failure (e.g. branches-only guard)
|
||||||
|
- missing required MCP tool/schema or operation
|
||||||
|
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
||||||
|
- unavailable project instructions or checked-in guides
|
||||||
|
- any other failure of a step the current workflow or controller prompt declares "required"
|
||||||
|
|
||||||
|
**Prohibited unless controller authorizes in writing for this instance:** temp scripts, direct API fallback, MCP internals, direct imports, in-memory state restoration, manual bypasses, or any continuation that hides the blocker. All such cases must be reported as process/tooling defects.
|
||||||
|
|
||||||
## Mode isolation
|
## Mode isolation
|
||||||
|
|
||||||
A run that starts in `review-merge-pr` mode may not create process issues,
|
A run that starts in `review-merge-pr` mode may not create process issues,
|
||||||
@@ -167,6 +186,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
|||||||
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
|
- [`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
|
||||||
|
|
||||||
@@ -182,4 +202,28 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
|||||||
|
|
||||||
Releases follow SemVer from remote `master` only, after full test suite passes.
|
Releases follow SemVer from remote `master` only, after full test suite passes.
|
||||||
See [`templates/release-tag.md`](templates/release-tag.md) and
|
See [`templates/release-tag.md`](templates/release-tag.md) and
|
||||||
`scripts/release-tag`.
|
`scripts/release-tag`.
|
||||||
|
|
||||||
|
## Proof: missing required workflow steps stop before mutation
|
||||||
|
|
||||||
|
- The llm-project-workflow router (this file) and every loaded workflow (work-issue.md, review-merge-pr.md, create-issue.md, etc.) now declare at the top: if required step/skill/tool/capability/instruction/profile/worktree binding/preflight fails, STOP, state BLOCKED, use blocked-diagnose-report.md template, only non-mutating recovery.
|
||||||
|
- Controller prompts (start-issue.md, review-pr.md, merge-pr.md, recover-bad-state.md, etc.) and the runbooks (docs/llm-workflow-runbooks.md) explicitly require the same and prohibit unsafe fallbacks.
|
||||||
|
- MCP guards (branches-only mutation guard #274, worktree binding #510, preflight purity, role checks, lease gates, gitea_lock_issue, etc.) plus the "prove before mutation" rules ensure that a BLOCKED state prevents git/Gitea mutations.
|
||||||
|
- When a skill/guide/tool is missing (e.g. gitea-workflow not mounted for a runtime), the load step in the router/prompt fails the "required" check → BLOCKED + report before any gitea_* call or git command that mutates.
|
||||||
|
- Terminal/shell failures, capability deadlocks, wrong profile, dirty/misbound worktree, root risk, guard failures, missing schema, stale state, unavailable instructions all map to the covered blocker classes and trigger the same stop + report.
|
||||||
|
- No code path in the canonical workflows allows continuation past a declared required step without the BLOCKED report.
|
||||||
|
- See also: Global LLM Worktree Rule, Shell Spawn Hard-Stop Rule, Identity and profile safety, Subagent Tool-Budget Guardrails, and the explicit prohibition list in Universal rules.
|
||||||
|
|
||||||
|
Tests / proof docs updated in this change + runbooks. Full relevant test runs (see PR handoff) pass; `git diff --check` clean. Missing-step cases are now documented to fail closed before mutation.
|
||||||
|
|
||||||
|
## Bootstrap Review Path (#557)
|
||||||
|
|
||||||
|
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
|
||||||
|
gates with raw API, direct imports, or root checkout edits.
|
||||||
|
|
||||||
|
If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
|
||||||
|
(#557)` record, follow:
|
||||||
|
|
||||||
|
`docs/bootstrap-review-path.md`
|
||||||
|
|
||||||
|
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Blocker Report Template (BLOCKED + DIAGNOSE)
|
||||||
|
|
||||||
|
Use this exact structure whenever a required workflow step cannot be performed. Emit this report and stop. Do not proceed to mutation or fallback unless a controller explicitly authorizes an exception in writing.
|
||||||
|
|
||||||
|
## Required step
|
||||||
|
<Describe the exact step, skill, tool, capability, instruction, profile, or preflight that was required. Include the canonical name and where it is defined (e.g. gitea-workflow skill, specific workflow file, gitea_xxx tool).>
|
||||||
|
|
||||||
|
## Observed failure
|
||||||
|
<Exact symptom, error message, missing output, guard error, 404, schema error, dirty state, wrong profile, etc. Quote relevant output or tool response.>
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
<What the workflow/docs/prompts say should happen. Reference the specific rule, template, or preflight that requires this step.>
|
||||||
|
|
||||||
|
## Checks performed
|
||||||
|
- List every verification attempted (e.g. gitea_whoami, resolve_task_capability, ls skills/, git status, mcp_list_*, worktree list, etc.)
|
||||||
|
- Note any discrepancies found (e.g. skill not mounted for this runtime, capability not in profile, cwd not under branches/, etc.)
|
||||||
|
|
||||||
|
## Safe recovery attempted
|
||||||
|
- Only non-mutating actions (reads, lists, views, status, whoami, resolve, fetch --dry, etc.)
|
||||||
|
- List what was tried and the result.
|
||||||
|
- If no safe recovery possible, state that explicitly.
|
||||||
|
|
||||||
|
## Likely classification
|
||||||
|
Choose one or more:
|
||||||
|
- missing required skill or workflow guide
|
||||||
|
- broken terminal/tool runner
|
||||||
|
- MCP capability failure or deadlock
|
||||||
|
- wrong profile or role
|
||||||
|
- dirty or misbound worktree
|
||||||
|
- root checkout mutation risk
|
||||||
|
- mutation guard failure
|
||||||
|
- missing required MCP tool/schema
|
||||||
|
- stale or inconsistent runtime state
|
||||||
|
- unavailable project instructions
|
||||||
|
- other: <describe>
|
||||||
|
|
||||||
|
## Durable fix recommendation
|
||||||
|
<Specific, actionable recommendation that fixes the root process/tooling issue (e.g. "Mount gitea-workflow skill for Codex under canonical name in ~/.codex/skills/", "Add preflight in llm-project-workflow/SKILL.md that hard-stops before any gitea_ call if X is unavailable", "Update controller prompt to require BLOCKED + this report before any fallback", "Grant capability in profile config", etc.). Do not suggest temp workarounds.>
|
||||||
|
|
||||||
|
## Mutation occurred?
|
||||||
|
- No (preferred and required unless explicitly authorized)
|
||||||
|
- Yes — describe exactly what was mutated and why it was unavoidable after diagnosis. (This should be rare and will trigger additional review.)
|
||||||
|
|
||||||
|
## Single next action
|
||||||
|
<One concrete next step for the current actor (e.g. "Controller to approve or reject recovery", "File follow-up issue #XXX for skill mounting", "Re-launch session from clean branches/ worktree after skill installed", "Stop and wait for profile update").>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Rule reminder (do not bypass):**
|
||||||
|
If the required step is unavailable, you are BLOCKED. Diagnose using this template. Report. Stop. Unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) are prohibited unless a controller has authorized them for this specific instance in a prior handoff.
|
||||||
|
|
||||||
|
This report must appear in the final output / handoff before any further action.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Blocked review submission handoff
|
||||||
|
|
||||||
|
Use when `gitea_submit_pr_review` or the terminal mutation gate blocks review
|
||||||
|
submission.
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Blocked Review Submission Handoff
|
||||||
|
|
||||||
|
- Tool called: gitea_submit_pr_review
|
||||||
|
- Mutation attempted: yes
|
||||||
|
- Mutation rejected: yes
|
||||||
|
- No server-side state changed: confirmed
|
||||||
|
- Proof/source: <paste exact tool error or gate reason>
|
||||||
|
- Prior terminal mutation that consumed budget: <exact prior mutation or none>
|
||||||
|
- Review decision (local intent): <approve | request_changes | comment only>
|
||||||
|
- Server-side final decision marked: <yes with tool proof | no>
|
||||||
|
- Gitea review submitted: no
|
||||||
|
- Gitea review blocked because: <exact gate/error>
|
||||||
|
- Review mutations: none (submission blocked)
|
||||||
|
- MCP/Gitea mutations: <only mutations that actually occurred>
|
||||||
|
- Safe next action: rewrite handoff with consistent ledger before posting
|
||||||
|
```
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Template: canonical state comments
|
||||||
|
|
||||||
|
Use these only for workflow-changing comments. Casual discussion does not need
|
||||||
|
the full template.
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical Issue State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | merger | reconciler | user>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
WHAT_HAPPENED:
|
||||||
|
<latest meaningful event>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<decision rationale>
|
||||||
|
|
||||||
|
RELATED_DISCUSSION:
|
||||||
|
<link/reference or none>
|
||||||
|
|
||||||
|
RELATED_PRS:
|
||||||
|
- #...
|
||||||
|
|
||||||
|
BRANCH:
|
||||||
|
<branch or none>
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
<40-character SHA or none>
|
||||||
|
|
||||||
|
VALIDATION:
|
||||||
|
<tests/proofs or none>
|
||||||
|
|
||||||
|
BLOCKERS:
|
||||||
|
<blocker and unblock condition, or none>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical PR State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | merger | reconciler | user>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
WHAT_HAPPENED:
|
||||||
|
<latest meaningful event>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<decision rationale>
|
||||||
|
|
||||||
|
ISSUE:
|
||||||
|
#...
|
||||||
|
|
||||||
|
BASE:
|
||||||
|
<branch>
|
||||||
|
|
||||||
|
HEAD:
|
||||||
|
<branch>
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
<40-character SHA>
|
||||||
|
|
||||||
|
REVIEW_STATUS:
|
||||||
|
<none | approved | changes-requested | stale | contaminated>
|
||||||
|
|
||||||
|
VALIDATION:
|
||||||
|
<tests/proofs>
|
||||||
|
|
||||||
|
BLOCKERS:
|
||||||
|
<blockers or none>
|
||||||
|
|
||||||
|
SUPERSEDES:
|
||||||
|
<PRs or none>
|
||||||
|
|
||||||
|
SUPERSEDED_BY:
|
||||||
|
<PR or none>
|
||||||
|
|
||||||
|
MERGE_READY:
|
||||||
|
<yes/no and why>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discussion
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Canonical Discussion Summary
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
<needs-more-discussion | ready-for-issues | issues-created | closed>
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
<controller | author | reviewer | user>
|
||||||
|
|
||||||
|
DECISION:
|
||||||
|
<what was decided>
|
||||||
|
|
||||||
|
WHY:
|
||||||
|
<reasoning and tradeoffs>
|
||||||
|
|
||||||
|
SUBSTANTIVE_COMMENTS:
|
||||||
|
<count and summary>
|
||||||
|
|
||||||
|
ISSUES_TO_CREATE_OR_CREATED:
|
||||||
|
- #...
|
||||||
|
|
||||||
|
DEPENDENCY_ORDER:
|
||||||
|
<order or none>
|
||||||
|
|
||||||
|
NON_GOALS:
|
||||||
|
<non-goals>
|
||||||
|
|
||||||
|
OPEN_QUESTIONS:
|
||||||
|
<questions or none>
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
<specific one-sentence action>
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
<paste-ready prompt for the next role>
|
||||||
|
|
||||||
|
LAST_UPDATED_BY:
|
||||||
|
<identity/profile/date>
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Template: Canonical Thread Handoff (CTH)
|
||||||
|
|
||||||
|
Copy, fill the fields, and post as an issue or PR comment.
|
||||||
|
|
||||||
|
```text
|
||||||
|
## CTH: <Type>
|
||||||
|
|
||||||
|
Status: <current workflow state>
|
||||||
|
Next owner: <author|reviewer|merger|controller|operator>
|
||||||
|
Current blocker: <none or exact blocker>
|
||||||
|
Decision: <what was decided this session>
|
||||||
|
Proof: <commands, SHAs, gate outputs, or explicit pending proof>
|
||||||
|
Next action: <one concrete step for the next actor>
|
||||||
|
Ready-to-paste prompt: <full prompt the next session should paste>
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `<Type>` values:
|
||||||
|
|
||||||
|
- State Handoff
|
||||||
|
- Controller Decision
|
||||||
|
- Author Handoff
|
||||||
|
- Reviewer Handoff
|
||||||
|
- Merger Handoff
|
||||||
|
- Supersession Notice
|
||||||
|
- Blocker
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Find the latest CTH comment in the thread before starting work.
|
||||||
|
- Treat the latest valid CTH as the current source of truth.
|
||||||
|
- Post a new CTH when you finish, block, skip, supersede, approve, request
|
||||||
|
changes, or hand off.
|
||||||
|
- A CTH summarizes workflow state; formal Gitea review verdicts remain
|
||||||
|
authoritative for merge gates.
|
||||||
|
|
||||||
|
Full protocol: `docs/canonical-thread-handoff.md`
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Template: merge a PR (eligible reviewer only)
|
# Template: merge a PR (eligible merger only)
|
||||||
|
|
||||||
Copy, fill the `<...>` fields, and paste as the task prompt.
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
@@ -10,12 +10,17 @@ Load the canonical workflow first:
|
|||||||
Final report schema: `schemas/review-merge-final-report.md`.
|
Final report schema: `schemas/review-merge-final-report.md`.
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
|
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||||
|
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||||
|
Post a new CTH: Merger Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
- 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 +68,12 @@ Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
|
|||||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
- 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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rules:
|
|||||||
reviewing a second PR after a terminal mutation in this run.
|
reviewing a second PR after a terminal mutation in this run.
|
||||||
- After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if
|
- After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if
|
||||||
operator explicitly authorized merge for this PR in this run.
|
operator explicitly authorized merge for this PR in this run.
|
||||||
- Report Next suggested PR without continuing to it.
|
- Report Next suggested PR without continuing to it. If the PR queue is empty, look at approvals, or issues next.
|
||||||
|
|
||||||
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
|
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
|
||||||
Merge authorized for selected PR in this run: <true|false>
|
Merge authorized for selected PR in this run: <true|false>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then-
|
Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then-
|
||||||
act: gather facts first, never discard unmerged work.
|
act: gather facts first, never discard unmerged work.
|
||||||
|
|
||||||
|
**BLOCKED + DIAGNOSE rule (llm-project-workflow):** If at any point a required step (including state recovery itself) cannot be performed, stop immediately, use the standard [`blocked-diagnose-report.md`](blocked-diagnose-report.md) template, attempt only safe non-mutating recovery, and report. Do not continue or fallback.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Task: recover repo state for <situation>. Do not lose unmerged work.
|
Task: recover repo state for <situation>. Do not lose unmerged work.
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
- Fail closed: if state is unclear or a step would delete unmerged work, STOP.
|
- BLOCKED + DIAGNOSE default: if state is unclear, a required check fails, or a step would delete unmerged work or bypass a guard, STOP and emit a full blocked-diagnose-report.md using the template. Clearly state BLOCKED. Diagnose. Only non-mutating recovery.
|
||||||
- Never push master. Never discard commits not safely pushed to <remote>.
|
- Never push master. Never discard commits not safely pushed to <remote>.
|
||||||
|
- Prove you are in a branches/ worktree before any recovery mutation.
|
||||||
|
|
||||||
Diagnose first:
|
Diagnose first:
|
||||||
1. git fetch <remote> --prune
|
1. git fetch <remote> --prune
|
||||||
@@ -16,8 +19,19 @@ Diagnose first:
|
|||||||
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
|
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
|
||||||
4. For any PR involved: confirm state (open/closed/merged) AND whether
|
4. For any PR involved: confirm state (open/closed/merged) AND whether
|
||||||
<remote>/master actually contains its commits ("closed" != "merged").
|
<remote>/master actually contains its commits ("closed" != "merged").
|
||||||
|
5. Check active leases, claims, and whether required skills/workflows are loaded.
|
||||||
|
|
||||||
Act per case:
|
If a required diagnostic or recovery step itself is unavailable (e.g. terminal broken, skill missing, guard blocks, wrong profile), emit:
|
||||||
|
|
||||||
|
## Required step
|
||||||
|
<the step>
|
||||||
|
|
||||||
|
## Observed failure
|
||||||
|
<...>
|
||||||
|
|
||||||
|
(complete the full blocked-diagnose-report.md template)
|
||||||
|
|
||||||
|
Act per case (only after clean diagnosis; if blocked, use the template and stop):
|
||||||
- Dirty worktree from another issue: leave it; start yours in a new worktree.
|
- Dirty worktree from another issue: leave it; start yours in a new worktree.
|
||||||
- Local master ahead of remote: confirm the extra commits live on a branch
|
- Local master ahead of remote: confirm the extra commits live on a branch
|
||||||
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
||||||
@@ -26,6 +40,7 @@ Act per case:
|
|||||||
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
||||||
git fsck --lost-found), re-push, reopen the PR.
|
git fsck --lost-found), re-push, reopen the PR.
|
||||||
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
||||||
|
- Any blocker: use blocked-diagnose-report.md template and stop.
|
||||||
|
|
||||||
Handoff: what was wrong, evidence, action taken, current state, what remains.
|
Handoff: what was wrong, evidence, action taken, current state, what remains. If BLOCKED, include the full blocker report.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -28,13 +28,18 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
|
|||||||
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
|
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
|
||||||
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
|
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
|
||||||
commit is not valid corroboration. Author-bound sessions must not present
|
commit is not valid corroboration. Author-bound sessions must not present
|
||||||
reviewer queue inventory as a reviewer decision.
|
reviewer queue inventory as a reviewer decision. If the PR queue is empty,
|
||||||
|
look at approvals, or issues next.
|
||||||
|
|
||||||
Load the canonical workflow first:
|
Load the canonical workflow first:
|
||||||
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
|
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
|
||||||
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):
|
||||||
|
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, lease, profile/role, capability, worktree under branches/, preflight, tool, instruction, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||||
|
- Find the latest CTH comment on the PR/issue thread before starting work.
|
||||||
|
Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
|
- 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
|
||||||
@@ -109,8 +114,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:
|
||||||
@@ -126,4 +132,16 @@ including the review/merge role fields: Selected PR, Reviewer eligibility,
|
|||||||
Pinned reviewed head, Review decision, Merge result, Linked issue status,
|
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
|
||||||
|
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
|
||||||
|
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
|
||||||
|
documented known-failure record predating the PR. Reproducing on current
|
||||||
|
(post-merge) master is "current-master failure reproduced", NOT baseline proof.
|
||||||
|
State: base commit, tested commit, command, exit status, failure signature.
|
||||||
|
Use one label: clean pass / current-master failure reproduced / pre-merge
|
||||||
|
baseline-proven failure / unresolved regression risk
|
||||||
|
(final_report_validator: reviewer.premerge_baseline_proof).
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report
|
|||||||
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
|
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
|
||||||
|
|
||||||
Rules (llm-project-workflow):
|
Rules (llm-project-workflow):
|
||||||
|
- **BLOCKED + DIAGNOSE default (required):** If any required step (load workflow, acquire lease, prove worktree under branches/, capability, profile, tool, instruction, preflight, etc.) cannot be performed, STOP. State BLOCKED. Use [`blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template exactly. Only safe non-mutating recovery. Report. Do not continue or fallback.
|
||||||
|
- Find the latest CTH comment on the issue/PR thread before starting work.
|
||||||
|
Post a new CTH: Author Handoff (or CTH: Blocker) at session end.
|
||||||
|
Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md
|
||||||
- No repo changes without a tracking issue. If none exists, create one first;
|
- 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
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ This file is the canonical issue-creation workflow for Gitea-Tools. Load it
|
|||||||
before any issue mutation. Final report schema:
|
before any issue mutation. Final report schema:
|
||||||
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
|
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
|
||||||
|
|
||||||
|
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
||||||
|
|
||||||
**Default task prompt:**
|
**Default task prompt:**
|
||||||
|
|
||||||
> Create or update Gitea issues in this project only if every identity,
|
> Create or update Gitea issues in this project only if every identity,
|
||||||
@@ -342,6 +344,41 @@ If edits are needed, make the smallest correction necessary and report the corre
|
|||||||
|
|
||||||
Do not silently change requested meaning.
|
Do not silently change requested meaning.
|
||||||
|
|
||||||
|
## 14a. Enforced content preflight (#582)
|
||||||
|
|
||||||
|
`gitea_create_issue` runs a **content preflight gate** before duplicate
|
||||||
|
search or creation. It fails closed (returns `BLOCKED + DIAGNOSE`, no issue
|
||||||
|
created) when the title/body is a *vague reference* to out-of-band draft
|
||||||
|
content the session cannot prove it holds, and no durable source pointer is
|
||||||
|
present.
|
||||||
|
|
||||||
|
An LLM must never fabricate issue content from stale chat memory. If asked to
|
||||||
|
create "the drafted issue" / "the prepared issue" / "the issue we discussed"
|
||||||
|
/ "the previous draft" without the full content in the current context or a
|
||||||
|
durable source pointer, stop and return `BLOCKED + DIAGNOSE` naming the exact
|
||||||
|
vague/missing fields.
|
||||||
|
|
||||||
|
A **durable source pointer** is one of: an existing issue/PR reference
|
||||||
|
(`#582`), a comment id (`comment 8155`), a checked-in file path
|
||||||
|
(`task_capability_map.py`), a URL, or a scratchpad path. When a pointer is
|
||||||
|
present, retrieve the real content from it before creating.
|
||||||
|
|
||||||
|
The gate does **not** require a non-empty body; explicit title-only creation
|
||||||
|
stays allowed. It only blocks placeholder references. An operator may bypass
|
||||||
|
it with `allow_incomplete_content=True` (report the override).
|
||||||
|
|
||||||
|
**Invalid prompts (must block):**
|
||||||
|
|
||||||
|
* "Create the drafted issue."
|
||||||
|
* "File the prepared issue we discussed."
|
||||||
|
* "Open the previous draft."
|
||||||
|
|
||||||
|
**Valid prompts (proceed):**
|
||||||
|
|
||||||
|
* Full title + body + acceptance criteria supplied inline.
|
||||||
|
* "Create the issue drafted in `#582`." (durable pointer → fetch and use it)
|
||||||
|
* "Create the issue from `scratchpad/issue-draft.md`." (durable file pointer)
|
||||||
|
|
||||||
## 15. Labels, assignees, and metadata
|
## 15. Labels, assignees, and metadata
|
||||||
|
|
||||||
Apply labels, assignees, milestones, or project fields only if:
|
Apply labels, assignees, milestones, or project fields only if:
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ claim. Select exactly one PR according to project queue ordering rules
|
|||||||
PRs only with live per-PR proof. No multi-PR validation and no batch report
|
PRs only with live per-PR proof. No multi-PR validation and no batch report
|
||||||
may substitute for per-PR proof.
|
may substitute for per-PR proof.
|
||||||
|
|
||||||
|
If the open PR queue is empty:
|
||||||
|
* First, look at **Approvals** next: check if there are open PRs with pending/completed approvals requiring attention or merge.
|
||||||
|
* Next, look at **Issues** next: check if there are unresolved open issues requiring action/fixes.
|
||||||
|
|
||||||
## 4. Terminal mutation chain
|
## 4. Terminal mutation chain
|
||||||
|
|
||||||
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
|
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ This file is the canonical PR review/merge workflow for Gitea-Tools. Load it
|
|||||||
before any PR mutation. Final report schema:
|
before any PR mutation. Final report schema:
|
||||||
[`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md).
|
[`schemas/review-merge-final-report.md`](../schemas/review-merge-final-report.md).
|
||||||
|
|
||||||
|
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
||||||
|
|
||||||
**Default task prompt:**
|
**Default task prompt:**
|
||||||
|
|
||||||
> Review the next eligible open PR in this project. Merge it only if every
|
> Review the next eligible open PR in this project. Merge it only if every
|
||||||
@@ -26,6 +28,12 @@ workflow rules exactly.
|
|||||||
|
|
||||||
## 0. Load the canonical workflow first
|
## 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:
|
||||||
@@ -272,6 +280,10 @@ If queue ordering cannot be proven, stop and produce a recovery handoff.
|
|||||||
|
|
||||||
## 10. Select the next actionable PR using project rules
|
## 10. Select the next actionable PR using project rules
|
||||||
|
|
||||||
|
If the open PR queue is empty:
|
||||||
|
* First, look at approvals next to see if there are pending approvals or approved PRs that need attention/merge.
|
||||||
|
* Next, look at issues next to see if there are open issues requiring action/fixes.
|
||||||
|
|
||||||
Do not review your own PR.
|
Do not review your own PR.
|
||||||
|
|
||||||
Do not review stale, draft, blocked, duplicate, already-owned, dependency-blocked, already-landed, already-requested-changes work unless the rules explicitly allow it.
|
Do not review stale, draft, blocked, duplicate, already-owned, dependency-blocked, already-landed, already-requested-changes work unless the rules explicitly allow it.
|
||||||
@@ -814,6 +826,75 @@ The final report must identify:
|
|||||||
* whether same-PR merge continuation was allowed
|
* whether same-PR merge continuation was allowed
|
||||||
* whether the run stopped as required
|
* whether the run stopped as required
|
||||||
|
|
||||||
|
## 26A-1. Stale durable review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
After #559, the review-decision lock is durable under
|
||||||
|
`~/.cache/gitea-tools/session-state/` (for example
|
||||||
|
`review_decision_lock-prgs-reviewer.json`). That durability can outlive the work
|
||||||
|
it protects: a terminal `approve` / `request_changes` on a PR that is already
|
||||||
|
**merged or closed** still hard-stops later unrelated reviews under #332.
|
||||||
|
|
||||||
|
**Do not** delete session-state files by hand. Use the canonical MCP path.
|
||||||
|
|
||||||
|
### When cleanup is allowed
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` when:
|
||||||
|
|
||||||
|
1. `gitea_mark_final_review_decision` / live review is blocked by
|
||||||
|
`terminal review mutation already consumed ... (#332)`.
|
||||||
|
2. Live Gitea state for the **last terminal mutation's PR** is **merged** or
|
||||||
|
**closed** (no same-PR merge sequence remains).
|
||||||
|
3. Active profile identity matches the durable lock (reviewer profile with
|
||||||
|
`gitea.pr.review` for `apply=true`).
|
||||||
|
4. Optional pin: pass `expected_terminal_pr` to the prior terminal PR number
|
||||||
|
(for example `586` when resuming after a merged #586 lock).
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_whoami
|
||||||
|
gitea_resolve_task_capability(task="cleanup_stale_review_decision_lock")
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=..., org=..., repo=...)
|
||||||
|
# inspect is_moot / cleanup_allowed / last_terminal_pr
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<last_terminal_pr>,
|
||||||
|
remote=..., org=..., repo=...,
|
||||||
|
)
|
||||||
|
gitea_resolve_task_capability(task="review_pr")
|
||||||
|
gitea_load_review_workflow()
|
||||||
|
# continue formal mark + submit for the *new* PR
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful `apply=true` clears memory + durable store and returns an audit
|
||||||
|
record (and posts a durable PR comment on the terminal PR when
|
||||||
|
`post_audit_comment` is true and comment permission allows).
|
||||||
|
|
||||||
|
Same-profile auto-expire: after `gitea_merge_pr` succeeds for the PR that this
|
||||||
|
profile just approved, the decision lock for that profile is cleared
|
||||||
|
automatically. Cross-profile locks (for example reviewer approve, merger merge)
|
||||||
|
still require `gitea_cleanup_stale_review_decision_lock`.
|
||||||
|
|
||||||
|
### When cleanup is forbidden
|
||||||
|
|
||||||
|
Refuse / fail-closed — keep #332 hard-stop — when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active review or merge sequence may
|
||||||
|
still apply)
|
||||||
|
* live PR state cannot be fetched (ambiguous)
|
||||||
|
* no lock or no terminal live mutation exists
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* apply requested without authenticated identity or `gitea.pr.review`
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
Do **not** use cleanup to:
|
||||||
|
|
||||||
|
* bypass #332 on an open PR
|
||||||
|
* replace `gitea_authorize_review_correction` for a mistaken review on a still
|
||||||
|
active PR (#211)
|
||||||
|
* auto-approve or auto-merge any PR
|
||||||
|
* justify `rm` of session-state files
|
||||||
|
|
||||||
## 26B. Per-PR reviewer lease (#407)
|
## 26B. Per-PR reviewer lease (#407)
|
||||||
|
|
||||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||||
@@ -924,6 +1005,14 @@ Confirm:
|
|||||||
|
|
||||||
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
||||||
|
|
||||||
|
Do not delete source branches from reviewer mode with raw git commands. Commands
|
||||||
|
such as `git branch -d <branch>` and `git push <remote> --delete <branch>` are
|
||||||
|
not proof of authorized cleanup and bypass MCP role/capability gates. Merged PR
|
||||||
|
source branch cleanup must use an explicit MCP cleanup tool such as
|
||||||
|
`gitea_cleanup_merged_pr_branch` or another approved cleanup helper with exact
|
||||||
|
`gitea.branch.delete` authority, merged-PR proof, no open PR using the branch,
|
||||||
|
target-branch ancestry proof, a `branches/` worktree path, and cleanup mutations
|
||||||
|
reported separately from review mutations.
|
||||||
Review, baseline, and merge-simulation worktrees created during this run are
|
Review, baseline, and merge-simulation worktrees created during this run are
|
||||||
transient and are removed automatically at successful completion once they are
|
transient and are removed automatically at successful completion once they are
|
||||||
clean, carry no open PR, and hold no active lease (#401). Use
|
clean, carry no open PR, and hold no active lease (#401). Use
|
||||||
@@ -1023,6 +1112,8 @@ Blocked handoffs must say to rerun the full workflow after the blocker clears.
|
|||||||
|
|
||||||
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
||||||
|
|
||||||
|
If the referenced terminal PR is already **merged or closed** on live Gitea, the durable #332 decision lock is **moot**. Do **not** delete session-state files by hand. Use `gitea_cleanup_stale_review_decision_lock` (assess with `apply=false`, then `apply=true` with `expected_terminal_pr` set) from the reviewer profile after identity/profile checks (#594). Cleanup is **forbidden** while that PR is still open.
|
||||||
|
|
||||||
## 30. Final report must be precise
|
## 30. Final report must be precise
|
||||||
|
|
||||||
Include:
|
Include:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ This file is the canonical author/coder workflow for Gitea-Tools. Load it
|
|||||||
before any issue implementation mutation. Final report schema:
|
before any issue implementation mutation. Final report schema:
|
||||||
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
|
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
|
||||||
|
|
||||||
|
**BLOCKED + DIAGNOSE (universal default):** If at any point you cannot perform a required step (skill not available, terminal broken, capability missing, wrong profile, guard blocks, worktree misbound, instructions unavailable, preflight fails, etc.), STOP. Clearly state BLOCKED. Use the standard [`../templates/blocked-diagnose-report.md`](../templates/blocked-diagnose-report.md) template. Only safe non-mutating recovery. Report using the template. Do not continue or use any fallback (temp scripts, direct API, etc.) unless controller authorizes. All blocker classes listed in llm-project-workflow/SKILL.md must trigger this.
|
||||||
|
|
||||||
**Default task prompt:**
|
**Default task prompt:**
|
||||||
|
|
||||||
> Find the next eligible issue in this project, work on it only if all gates
|
> Find the next eligible issue in this project, work on it only if all gates
|
||||||
@@ -26,6 +28,12 @@ This is an author/coder workflow. It is not a reviewer workflow.
|
|||||||
|
|
||||||
## 0. Load the canonical workflow first
|
## 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 +623,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
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
||||||
|
|
||||||
|
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||||
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||||
|
work they protect: when the referenced PR is already merged or closed, no
|
||||||
|
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
|
||||||
|
new terminal reviews.
|
||||||
|
|
||||||
|
#620 scopes the terminal boundary by **reviewed head SHA**: a prior
|
||||||
|
REQUEST_CHANGES (or APPROVE) on head A must not block a fresh formal decision
|
||||||
|
on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
||||||
|
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
||||||
|
(#594); new-head re-review is allowed without deleting the durable lock.
|
||||||
|
|
||||||
|
This module is pure policy (no Gitea I/O). The MCP tool
|
||||||
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||||
|
helpers, and only then clears durable state when cleanup is allowed.
|
||||||
|
|
||||||
|
Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the
|
||||||
|
canonical path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS.
|
||||||
|
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_head_sha(value: Any) -> str | None:
|
||||||
|
"""Normalize a git SHA for equality comparison; None if empty/invalid."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def heads_equal(a: Any, b: Any) -> bool:
|
||||||
|
na = normalize_head_sha(a)
|
||||||
|
nb = normalize_head_sha(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return False
|
||||||
|
return na == nb
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_head_sha(mutation: dict | None, lock: dict | None = None) -> str | None:
|
||||||
|
"""Head SHA that bounds a live mutation (#620).
|
||||||
|
|
||||||
|
Prefers fields recorded on the mutation itself. For *legacy* mutations
|
||||||
|
that predate head-scoping, falls back to the lock's
|
||||||
|
``ready_expected_head_sha`` only when that ready binding is for the same
|
||||||
|
PR number (the frozen durable PR #619-style ledger).
|
||||||
|
"""
|
||||||
|
if not isinstance(mutation, dict):
|
||||||
|
return None
|
||||||
|
for key in ("head_sha", "expected_head_sha", "reviewed_head_sha"):
|
||||||
|
head = normalize_head_sha(mutation.get(key))
|
||||||
|
if head:
|
||||||
|
return head
|
||||||
|
if not isinstance(lock, dict):
|
||||||
|
return None
|
||||||
|
if lock.get("ready_pr_number") != mutation.get("pr_number"):
|
||||||
|
return None
|
||||||
|
return normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||||
|
|
||||||
|
|
||||||
|
def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||||
|
"""Return the last terminal live mutation on *lock*, or None."""
|
||||||
|
if not lock:
|
||||||
|
return None
|
||||||
|
terminals = [
|
||||||
|
m
|
||||||
|
for m in (lock.get("live_mutations") or [])
|
||||||
|
if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS
|
||||||
|
]
|
||||||
|
return terminals[-1] if terminals else None
|
||||||
|
|
||||||
|
|
||||||
|
def prior_live_mutations_block_boundary(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when prior live mutations block a new decision for PR+head (#620).
|
||||||
|
|
||||||
|
Historical terminals on a **different head of the same PR** do not block.
|
||||||
|
Any prior mutation on another PR, the same head, or without a comparable
|
||||||
|
head SHA fails closed (blocks).
|
||||||
|
|
||||||
|
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||||
|
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||||
|
(PR #619-style).
|
||||||
|
"""
|
||||||
|
if not lock:
|
||||||
|
return False
|
||||||
|
if lock.get("correction_authorized"):
|
||||||
|
return False
|
||||||
|
prior = list(lock.get("live_mutations") or [])
|
||||||
|
if not prior:
|
||||||
|
return False
|
||||||
|
target = normalize_head_sha(expected_head_sha)
|
||||||
|
for m in prior:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
return True
|
||||||
|
m_pr = m.get("pr_number")
|
||||||
|
m_head = mutation_head_sha(m, lock)
|
||||||
|
if (
|
||||||
|
m_pr == pr_number
|
||||||
|
and target
|
||||||
|
and m_head
|
||||||
|
and not heads_equal(m_head, target)
|
||||||
|
):
|
||||||
|
# Same open PR, different reviewed head — historical boundary only.
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_boundary_allows_fresh_decision(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None,
|
||||||
|
operation: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
||||||
|
|
||||||
|
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
||||||
|
the requested head differs from the last terminal's head. Merge is never
|
||||||
|
reopened by head change (approved head merge path is separate).
|
||||||
|
"""
|
||||||
|
if operation not in ("mark_ready", "review", "resume"):
|
||||||
|
return False
|
||||||
|
if not lock:
|
||||||
|
return False
|
||||||
|
if lock.get("correction_authorized"):
|
||||||
|
return True
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
return True
|
||||||
|
if last.get("pr_number") != pr_number:
|
||||||
|
return False
|
||||||
|
locked_head = mutation_head_sha(last, lock)
|
||||||
|
target = normalize_head_sha(expected_head_sha)
|
||||||
|
if not locked_head or not target:
|
||||||
|
return False
|
||||||
|
return not heads_equal(locked_head, target)
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_terminal_heads_from_ready(lock: dict) -> dict:
|
||||||
|
"""Stamp head_sha onto legacy terminal mutations before overwriting ready_*.
|
||||||
|
|
||||||
|
Called when marking a fresh decision on a new head so historical rows keep
|
||||||
|
their original boundary after ``ready_expected_head_sha`` advances (#620).
|
||||||
|
"""
|
||||||
|
if not isinstance(lock, dict):
|
||||||
|
return lock
|
||||||
|
ready_pr = lock.get("ready_pr_number")
|
||||||
|
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||||
|
if ready_pr is None or not ready_head:
|
||||||
|
return lock
|
||||||
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
|
changed = False
|
||||||
|
for m in mutations:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
if m.get("action") not in TERMINAL_REVIEW_ACTIONS:
|
||||||
|
continue
|
||||||
|
if m.get("pr_number") != ready_pr:
|
||||||
|
continue
|
||||||
|
if mutation_head_sha(m):
|
||||||
|
continue
|
||||||
|
m["head_sha"] = ready_head
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
lock["live_mutations"] = mutations
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
|
||||||
|
"""Normalize a Gitea PR payload into merge/closed flags."""
|
||||||
|
pr = pr_live or {}
|
||||||
|
merged = bool(pr.get("merged") or pr.get("merged_at"))
|
||||||
|
state = (pr.get("state") or "").strip().lower() or None
|
||||||
|
closed = state == "closed"
|
||||||
|
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||||
|
head_sha = normalize_head_sha(
|
||||||
|
pr.get("head_sha") or pr.get("head_commit_sha") or head.get("sha")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"pr_state": state,
|
||||||
|
"pr_merged": merged,
|
||||||
|
"pr_merged_or_closed": merged or closed,
|
||||||
|
"merge_commit_sha": pr.get("merge_commit_sha")
|
||||||
|
or pr.get("merged_commit_sha"),
|
||||||
|
"current_pr_head_sha": head_sha,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_summary(lock: dict | None) -> dict[str, Any] | None:
|
||||||
|
"""LLM-safe summary of a decision lock (no secrets)."""
|
||||||
|
if lock is None:
|
||||||
|
return None
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
|
last_head = mutation_head_sha(last, lock) if last else None
|
||||||
|
return {
|
||||||
|
"task": lock.get("task"),
|
||||||
|
"remote": lock.get("remote"),
|
||||||
|
"org": lock.get("org") or lock.get("ready_org"),
|
||||||
|
"repo": lock.get("repo") or lock.get("ready_repo"),
|
||||||
|
"profile_identity": lock.get("profile_identity")
|
||||||
|
or lock.get("session_profile_lock")
|
||||||
|
or lock.get("session_profile"),
|
||||||
|
"session_profile": lock.get("session_profile"),
|
||||||
|
"ready_pr_number": lock.get("ready_pr_number"),
|
||||||
|
"ready_action": lock.get("ready_action"),
|
||||||
|
"ready_expected_head_sha": normalize_head_sha(
|
||||||
|
lock.get("ready_expected_head_sha")
|
||||||
|
),
|
||||||
|
"live_mutations_count": len(mutations),
|
||||||
|
"last_terminal": (
|
||||||
|
{
|
||||||
|
"pr_number": last.get("pr_number"),
|
||||||
|
"action": last.get("action"),
|
||||||
|
"review_id": last.get("review_id"),
|
||||||
|
"head_sha": last_head,
|
||||||
|
}
|
||||||
|
if last
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"locked_head_sha": last_head,
|
||||||
|
"correction_authorized": bool(lock.get("correction_authorized")),
|
||||||
|
"updated_at": lock.get("updated_at") or lock.get("recorded_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_stale_review_decision_lock(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_live: dict | None = None,
|
||||||
|
pr_lookup_error: str | None = None,
|
||||||
|
active_profile_identity: str | None = None,
|
||||||
|
current_pr_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a durable review-decision lock is stale/moot (#594/#620).
|
||||||
|
|
||||||
|
*pr_live* must be the live Gitea payload for the **last terminal
|
||||||
|
mutation's PR**. When no lock / no terminal mutation exists, cleanup is
|
||||||
|
not applicable. When the PR is still open (or lookup fails), cleanup is
|
||||||
|
forbidden (#594). Open PR + different live head reports
|
||||||
|
``stale_by_head`` / ``fresh_review_on_current_head_allowed`` (#620)
|
||||||
|
without enabling cleanup.
|
||||||
|
"""
|
||||||
|
summary = lock_summary(lock)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"has_lock": lock is not None,
|
||||||
|
"lock_summary": summary,
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"locked_head_sha": None,
|
||||||
|
"current_pr_head_sha": normalize_head_sha(current_pr_head_sha),
|
||||||
|
"stale_by_head": False,
|
||||||
|
"fresh_review_on_current_head_allowed": False,
|
||||||
|
"is_moot": False,
|
||||||
|
"cleanup_allowed": False,
|
||||||
|
"profile_match": True,
|
||||||
|
"pr_state": None,
|
||||||
|
"pr_merged": False,
|
||||||
|
"pr_merged_or_closed": False,
|
||||||
|
"merge_commit_sha": None,
|
||||||
|
"reasons": [],
|
||||||
|
"recovery_tool": "gitea_cleanup_stale_review_decision_lock",
|
||||||
|
}
|
||||||
|
|
||||||
|
if lock is None:
|
||||||
|
result["reasons"].append("no review decision lock present")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Profile identity gate (caller may re-check with env; pure assess still
|
||||||
|
# reports mismatch when active identity is supplied).
|
||||||
|
stored = (
|
||||||
|
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
active = (active_profile_identity or "").strip()
|
||||||
|
if active and stored and active != stored:
|
||||||
|
result["profile_match"] = False
|
||||||
|
result["reasons"].append(
|
||||||
|
"review decision lock profile identity does not match active "
|
||||||
|
f"profile '{active}' (fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"review decision lock has no terminal live mutations; nothing to clear"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
pr_number = last.get("pr_number")
|
||||||
|
action = last.get("action")
|
||||||
|
locked_head = mutation_head_sha(last, lock)
|
||||||
|
result["last_terminal_pr"] = pr_number
|
||||||
|
result["last_terminal_action"] = action
|
||||||
|
result["locked_head_sha"] = locked_head
|
||||||
|
|
||||||
|
if pr_number is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"last terminal mutation missing pr_number; refuse cleanup (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_lookup_error:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"could not load live state for PR #{pr_number}: {pr_lookup_error} "
|
||||||
|
"(fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_live is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"live PR state required for terminal PR #{pr_number} before cleanup "
|
||||||
|
"(fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
live = classify_pr_live_state(pr_live)
|
||||||
|
current_head = normalize_head_sha(
|
||||||
|
current_pr_head_sha or live.get("current_pr_head_sha")
|
||||||
|
)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"pr_state": live["pr_state"],
|
||||||
|
"pr_merged": live["pr_merged"],
|
||||||
|
"pr_merged_or_closed": live["pr_merged_or_closed"],
|
||||||
|
"merge_commit_sha": live["merge_commit_sha"],
|
||||||
|
"current_pr_head_sha": current_head,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not live["pr_merged_or_closed"]:
|
||||||
|
stale_by_head = bool(
|
||||||
|
locked_head and current_head and not heads_equal(locked_head, current_head)
|
||||||
|
)
|
||||||
|
result["stale_by_head"] = stale_by_head
|
||||||
|
# Fresh formal decision is allowed on the new head without cleanup (#620).
|
||||||
|
result["fresh_review_on_current_head_allowed"] = stale_by_head
|
||||||
|
if stale_by_head:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal {action} on PR #{pr_number} is bound to head "
|
||||||
|
f"{locked_head[:12]}…; live head is {current_head[:12]}… — "
|
||||||
|
"fresh formal review on current head is allowed without "
|
||||||
|
"cleanup (fail closed for cleanup, #620); #332 same-head "
|
||||||
|
"duplicate protection remains"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal review mutation on open PR #{pr_number} is still active "
|
||||||
|
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
|
||||||
|
result["is_moot"] = True
|
||||||
|
result["cleanup_allowed"] = True
|
||||||
|
result["stale_by_head"] = False
|
||||||
|
result["fresh_review_on_current_head_allowed"] = False
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
|
||||||
|
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_cleanup_audit_record(
|
||||||
|
*,
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
applied: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured durable audit payload for a cleanup attempt."""
|
||||||
|
last = (assessment.get("lock_summary") or {}).get("last_terminal") or {}
|
||||||
|
return {
|
||||||
|
"event": "stale_review_decision_lock_cleanup",
|
||||||
|
"issue_ref": "#594",
|
||||||
|
"applied": bool(applied),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"),
|
||||||
|
"last_terminal_action": assessment.get("last_terminal_action")
|
||||||
|
or last.get("action"),
|
||||||
|
"pr_state": assessment.get("pr_state"),
|
||||||
|
"pr_merged": assessment.get("pr_merged"),
|
||||||
|
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||||
|
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
||||||
|
"prior_live_mutations_count": (
|
||||||
|
(assessment.get("lock_summary") or {}).get("live_mutations_count")
|
||||||
|
),
|
||||||
|
"prior_profile_identity": (
|
||||||
|
(assessment.get("lock_summary") or {}).get("profile_identity")
|
||||||
|
),
|
||||||
|
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||||||
|
"is_moot": assessment.get("is_moot"),
|
||||||
|
"reasons": list(assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for a durable Gitea audit comment after cleanup."""
|
||||||
|
status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)"
|
||||||
|
lines = [
|
||||||
|
"## Stale #332 review-decision lock cleanup (#594)",
|
||||||
|
"",
|
||||||
|
f"Status: **{status}**",
|
||||||
|
"",
|
||||||
|
f"- actor: `{audit.get('actor_username')}`",
|
||||||
|
f"- profile: `{audit.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{audit.get('timestamp')}`",
|
||||||
|
f"- last terminal: `{audit.get('last_terminal_action')}` on "
|
||||||
|
f"PR #{audit.get('last_terminal_pr')}",
|
||||||
|
f"- PR state: `{audit.get('pr_state')}` "
|
||||||
|
f"(merged={audit.get('pr_merged')})",
|
||||||
|
f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`",
|
||||||
|
f"- prior live_mutations_count: "
|
||||||
|
f"`{audit.get('prior_live_mutations_count')}`",
|
||||||
|
f"- prior profile_identity: `{audit.get('prior_profile_identity')}`",
|
||||||
|
"",
|
||||||
|
"Manual deletion of session-state files is **not** the workflow.",
|
||||||
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"""Canonical state handoff ledger for discussions, issues, and PRs (#494)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
HANDOFF_HEADING = "Controller Handoff"
|
||||||
|
|
||||||
|
ISSUE_STATE_FIELDS = (
|
||||||
|
"STATE",
|
||||||
|
"NEXT_ACTOR",
|
||||||
|
"NEXT_ACTION",
|
||||||
|
"NEXT_PROMPT",
|
||||||
|
"STATUS",
|
||||||
|
"RELATED_PRS",
|
||||||
|
"BRANCH",
|
||||||
|
"HEAD_SHA",
|
||||||
|
"VALIDATION",
|
||||||
|
"BLOCKERS",
|
||||||
|
"DUPLICATES_OR_SUPERSEDES",
|
||||||
|
"LAST_UPDATED_BY",
|
||||||
|
)
|
||||||
|
|
||||||
|
PR_STATE_FIELDS = (
|
||||||
|
"STATE",
|
||||||
|
"NEXT_ACTOR",
|
||||||
|
"NEXT_ACTION",
|
||||||
|
"NEXT_PROMPT",
|
||||||
|
"ISSUE",
|
||||||
|
"BASE",
|
||||||
|
"HEAD",
|
||||||
|
"HEAD_SHA",
|
||||||
|
"REVIEW_STATUS",
|
||||||
|
"VALIDATION",
|
||||||
|
"BLOCKERS",
|
||||||
|
"SUPERSEDES",
|
||||||
|
"SUPERSEDED_BY",
|
||||||
|
"MERGE_READY",
|
||||||
|
"LAST_UPDATED_BY",
|
||||||
|
)
|
||||||
|
|
||||||
|
DISCUSSION_STATE_FIELDS = (
|
||||||
|
"STATE",
|
||||||
|
"NEXT_ACTOR",
|
||||||
|
"NEXT_ACTION",
|
||||||
|
"NEXT_PROMPT",
|
||||||
|
"COMMENT_COUNT",
|
||||||
|
"SUBSTANTIVE_COMMENTS",
|
||||||
|
"URGENCY",
|
||||||
|
"BLOCKERS",
|
||||||
|
"LAST_UPDATED_BY",
|
||||||
|
)
|
||||||
|
|
||||||
|
DISCUSSION_SUMMARY_FIELDS = (
|
||||||
|
"DECISION",
|
||||||
|
"ISSUES_TO_CREATE",
|
||||||
|
"NON_GOALS",
|
||||||
|
"UNRESOLVED_QUESTIONS",
|
||||||
|
"NEXT_PROMPT",
|
||||||
|
"LAST_UPDATED_BY",
|
||||||
|
)
|
||||||
|
|
||||||
|
QUEUE_CONTROLLER_FIELDS = (
|
||||||
|
"QUEUE_STATE",
|
||||||
|
"SELECTED_OBJECT",
|
||||||
|
"SELECTED_OBJECT_TYPE",
|
||||||
|
"NEXT_ACTOR",
|
||||||
|
"NEXT_ACTION",
|
||||||
|
"NEXT_PROMPT",
|
||||||
|
"EVIDENCE",
|
||||||
|
"LAST_UPDATED_BY",
|
||||||
|
)
|
||||||
|
|
||||||
|
FINAL_REPORT_NEXT_ACTION_FIELDS = (
|
||||||
|
("Current status", ("current status",)),
|
||||||
|
("Next actor", ("next actor", "next_actor")),
|
||||||
|
("Next action", ("next action", "next_action")),
|
||||||
|
("Next prompt", ("next prompt", "next_prompt")),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATE_COMMENT_KINDS = frozenset({
|
||||||
|
"issue_state",
|
||||||
|
"pr_state",
|
||||||
|
"discussion_state",
|
||||||
|
"discussion_summary",
|
||||||
|
"queue_controller",
|
||||||
|
})
|
||||||
|
|
||||||
|
_FIELDS_BY_KIND = {
|
||||||
|
"issue_state": ISSUE_STATE_FIELDS,
|
||||||
|
"pr_state": PR_STATE_FIELDS,
|
||||||
|
"discussion_state": DISCUSSION_STATE_FIELDS,
|
||||||
|
"discussion_summary": DISCUSSION_SUMMARY_FIELDS,
|
||||||
|
"queue_controller": QUEUE_CONTROLLER_FIELDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
_FIELD_LINE_RE = re.compile(
|
||||||
|
r"^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||||
|
_READY_TO_MERGE_RE = re.compile(
|
||||||
|
r"\b(?:ready[_ ]to[_ ]merge|merge[_ ]ready\s*:\s*(?:yes|true))\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_APPROVAL_PROOF_RE = re.compile(
|
||||||
|
r"\b(?:review decision\s*:\s*approve|approved at|approval at current head|"
|
||||||
|
r"formal approval|review_status\s*:\s*approved)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_EXTERNAL_NONE_RE = re.compile(
|
||||||
|
r"^\s*[-*]?\s*external[- ]state mutations\s*:\s*none\s*$",
|
||||||
|
re.IGNORECASE | re.MULTILINE,
|
||||||
|
)
|
||||||
|
_LOCK_MUTATION_RE = re.compile(
|
||||||
|
r"\b(?:gitea_lock_issue|issue-locks/|lock file edited)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ISSUE_DONE_RE = re.compile(
|
||||||
|
r"\b(?:issue (?:done|closed)\b|current status\s*:\s*(?:done|closed)\b|"
|
||||||
|
r"current status\s*:\s*issue complete\b)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PR_MERGE_PROOF_RE = re.compile(
|
||||||
|
r"\b(?:pr (?:merged|number)|merge result\s*:\s*merged|pr mutations\s*:\s*created)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DISCUSSION_COMPLETE_RE = re.compile(
|
||||||
|
r"\b(?:discussion complete|discussion ready for issue creation)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DISCUSSION_SUMMARY_PROOF_RE = re.compile(
|
||||||
|
r"\b(?:discussion summary|issues to create|issues created|linked issues)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
MIN_DISCUSSION_COMMENTS_DEFAULT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _render_fields(fields: tuple[str, ...], values: dict[str, Any]) -> str:
|
||||||
|
lines = ["```text"]
|
||||||
|
for name in fields:
|
||||||
|
raw = values.get(name, values.get(name.lower(), "none"))
|
||||||
|
text = str(raw).strip() if raw is not None else "none"
|
||||||
|
lines.append(f"{name}: {text or 'none'}")
|
||||||
|
lines.append("```")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_issue_state_comment(**values: Any) -> str:
|
||||||
|
"""Render canonical issue state comment body."""
|
||||||
|
return _render_fields(ISSUE_STATE_FIELDS, values)
|
||||||
|
|
||||||
|
|
||||||
|
def render_pr_state_comment(**values: Any) -> str:
|
||||||
|
"""Render canonical PR state comment body."""
|
||||||
|
return _render_fields(PR_STATE_FIELDS, values)
|
||||||
|
|
||||||
|
|
||||||
|
def render_discussion_state_comment(**values: Any) -> str:
|
||||||
|
"""Render canonical discussion state comment body."""
|
||||||
|
return _render_fields(DISCUSSION_STATE_FIELDS, values)
|
||||||
|
|
||||||
|
|
||||||
|
def render_discussion_summary_comment(**values: Any) -> str:
|
||||||
|
"""Render discussion-to-issue conversion summary comment."""
|
||||||
|
return _render_fields(DISCUSSION_SUMMARY_FIELDS, values)
|
||||||
|
|
||||||
|
|
||||||
|
def render_queue_controller_report(**values: Any) -> str:
|
||||||
|
"""Render queue-controller selection report."""
|
||||||
|
return _render_fields(QUEUE_CONTROLLER_FIELDS, values)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_state_comment(text: str) -> dict[str, str]:
|
||||||
|
"""Parse KEY: value lines from a canonical state comment."""
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
for match in _FIELD_LINE_RE.finditer(text or ""):
|
||||||
|
parsed[match.group(1)] = match.group(2).strip()
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||||
|
section_match = _HANDOFF_SECTION_RE.search(report_text or "")
|
||||||
|
if not section_match:
|
||||||
|
return {}
|
||||||
|
section = (report_text or "")[section_match.end():]
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for line in section.splitlines():
|
||||||
|
stripped = line.strip().lstrip("-*").strip()
|
||||||
|
if ":" not in stripped:
|
||||||
|
continue
|
||||||
|
key, value = stripped.split(":", 1)
|
||||||
|
fields[key.strip().lower()] = value.strip()
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def assess_state_comment(text: str, *, kind: str) -> dict[str, Any]:
|
||||||
|
"""Validate a canonical Gitea state comment for *kind*."""
|
||||||
|
normalized = (kind or "").strip().lower()
|
||||||
|
if normalized not in STATE_COMMENT_KINDS:
|
||||||
|
return {
|
||||||
|
"complete": False,
|
||||||
|
"block": True,
|
||||||
|
"missing_fields": [],
|
||||||
|
"reasons": [f"unknown state comment kind '{kind}'"],
|
||||||
|
"safe_next_action": "use a supported state comment kind",
|
||||||
|
}
|
||||||
|
|
||||||
|
required = _FIELDS_BY_KIND[normalized]
|
||||||
|
parsed = parse_state_comment(text)
|
||||||
|
missing = [name for name in required if name not in parsed or not parsed[name].strip()]
|
||||||
|
reasons = [f"state comment missing field: {name}" for name in missing]
|
||||||
|
|
||||||
|
if normalized == "discussion_state":
|
||||||
|
urgency = parsed.get("URGENCY", "").strip().lower()
|
||||||
|
count_raw = parsed.get("COMMENT_COUNT", "").strip()
|
||||||
|
try:
|
||||||
|
count = int(count_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = -1
|
||||||
|
if (
|
||||||
|
urgency not in {"urgent", "trivial"}
|
||||||
|
and count >= 0
|
||||||
|
and count < MIN_DISCUSSION_COMMENTS_DEFAULT
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"discussion has {count} comments; need at least "
|
||||||
|
f"{MIN_DISCUSSION_COMMENTS_DEFAULT} substantive comments "
|
||||||
|
"or URGENCY: urgent|trivial"
|
||||||
|
)
|
||||||
|
|
||||||
|
block = bool(missing or reasons)
|
||||||
|
return {
|
||||||
|
"complete": not block,
|
||||||
|
"block": block,
|
||||||
|
"missing_fields": missing,
|
||||||
|
"parsed_fields": parsed,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
"fill all required state comment fields before posting"
|
||||||
|
if block
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_final_report_next_action_handoff(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Require current status plus next actor/action/prompt in Controller Handoff."""
|
||||||
|
fields = _handoff_field_map(report_text)
|
||||||
|
if not fields:
|
||||||
|
return {
|
||||||
|
"complete": False,
|
||||||
|
"block": True,
|
||||||
|
"missing_fields": [name for name, _ in FINAL_REPORT_NEXT_ACTION_FIELDS],
|
||||||
|
"reasons": [f"final report has no section titled exactly '{HANDOFF_HEADING}'"],
|
||||||
|
"safe_next_action": f"add a complete {HANDOFF_HEADING} section",
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for name, aliases in FINAL_REPORT_NEXT_ACTION_FIELDS:
|
||||||
|
value = ""
|
||||||
|
for alias in aliases:
|
||||||
|
if alias in fields:
|
||||||
|
value = fields[alias]
|
||||||
|
break
|
||||||
|
if not value or value.lower() in {"none", "n/a", "not verified in this session", ""}:
|
||||||
|
missing.append(name)
|
||||||
|
|
||||||
|
reasons = [f"handoff missing next-action field: {name}" for name in missing]
|
||||||
|
block = bool(missing)
|
||||||
|
return {
|
||||||
|
"complete": not block,
|
||||||
|
"block": block,
|
||||||
|
"missing_fields": missing,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
"add Current status, Next actor, Next action, and Next prompt to Controller Handoff"
|
||||||
|
if block
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_contradictory_state_handoff(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Fail closed on contradictory queue/state claims in final reports."""
|
||||||
|
text = report_text or ""
|
||||||
|
fields = _handoff_field_map(text)
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
review_decision = fields.get("review decision", fields.get("review status", ""))
|
||||||
|
has_approval = (
|
||||||
|
review_decision.lower() in {"approve", "approved"}
|
||||||
|
or bool(_APPROVAL_PROOF_RE.search(text))
|
||||||
|
)
|
||||||
|
if _READY_TO_MERGE_RE.search(text) and not has_approval:
|
||||||
|
reasons.append(
|
||||||
|
"report claims ready to merge without approval-at-current-head proof"
|
||||||
|
)
|
||||||
|
|
||||||
|
lock_val = fields.get("external-state mutations", "")
|
||||||
|
mutation_lines = " ".join(
|
||||||
|
fields.get(key, "")
|
||||||
|
for key in (
|
||||||
|
"issue mutations",
|
||||||
|
"mcp/gitea mutations",
|
||||||
|
"external-state mutations",
|
||||||
|
"claim/lock state",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
(_EXTERNAL_NONE_RE.search(text) or lock_val.lower() == "none")
|
||||||
|
and _LOCK_MUTATION_RE.search(mutation_lines)
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
"report claims External-state mutations: none while lock "
|
||||||
|
"activity is documented in mutation fields"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _ISSUE_DONE_RE.search(text) and not _PR_MERGE_PROOF_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"report claims issue done/complete without PR or merge proof"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _DISCUSSION_COMPLETE_RE.search(text) and not _DISCUSSION_SUMMARY_PROOF_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"report claims discussion complete without summary or issue-creation proof"
|
||||||
|
)
|
||||||
|
|
||||||
|
review_status = fields.get("review status", fields.get("review decision", ""))
|
||||||
|
merge_ready = fields.get("merge ready", "")
|
||||||
|
if (
|
||||||
|
merge_ready.lower() in {"yes", "true", "ready"}
|
||||||
|
and review_status.lower() not in {"approve", "approved"}
|
||||||
|
and not _APPROVAL_PROOF_RE.search(text)
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
"MERGE_READY or merge-ready claim without approved review status"
|
||||||
|
)
|
||||||
|
|
||||||
|
block = bool(reasons)
|
||||||
|
return {
|
||||||
|
"complete": not block,
|
||||||
|
"block": block,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
"correct contradictory state claims or add missing proof"
|
||||||
|
if block
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_state_handoff_ledger_report(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Composite #494 assessment for final reports."""
|
||||||
|
next_action = assess_final_report_next_action_handoff(report_text)
|
||||||
|
contradictions = assess_contradictory_state_handoff(report_text)
|
||||||
|
reasons = list(next_action.get("reasons") or []) + list(contradictions.get("reasons") or [])
|
||||||
|
block = bool(next_action.get("block") or contradictions.get("block"))
|
||||||
|
return {
|
||||||
|
"complete": not block,
|
||||||
|
"block": block,
|
||||||
|
"next_action": next_action,
|
||||||
|
"contradictions": contradictions,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
next_action.get("safe_next_action")
|
||||||
|
if next_action.get("block")
|
||||||
|
else contradictions.get("safe_next_action")
|
||||||
|
if contradictions.get("block")
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
+35
-3
@@ -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",
|
||||||
@@ -92,10 +96,25 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.approve",
|
"permission": "gitea.pr.approve",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #594: clear durable #332 decision lock only when last terminal PR is
|
||||||
|
# already merged/closed (moot). Apply path requires reviewer review
|
||||||
|
# permission; assessment itself uses gitea.read inside the tool.
|
||||||
|
"cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
"cleanup_merged_pr_branch": {
|
||||||
|
"permission": "gitea.branch.delete",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"commit_files": {
|
"commit_files": {
|
||||||
"permission": "gitea.repo.commit",
|
"permission": "gitea.repo.commit",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -106,11 +125,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"reconcile_merged_cleanups": {
|
"reconcile_merged_cleanups": {
|
||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"reconciliation_cleanup": {
|
"reconciliation_cleanup": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"work_issue": {
|
"work_issue": {
|
||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
@@ -142,6 +161,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.close",
|
"permission": "gitea.issue.close",
|
||||||
"role": "reconciler",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
|
"reconcile_close_superseded_pr": {
|
||||||
|
"permission": "gitea.pr.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_close_satisfied_issue": {
|
||||||
|
"permission": "gitea.issue.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_create_followup_issue": {
|
||||||
|
"permission": "gitea.issue.create",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"post_heartbeat": {
|
"post_heartbeat": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -163,6 +194,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+185
-25
@@ -1,20 +1,45 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Live health-check script to verify Gitea MCP namespace connections.
|
"""Offline-only MCP namespace spawn probe (NOT IDE-namespace proof).
|
||||||
|
|
||||||
Spawns the MCP server processes as defined in the IDE's global config,
|
Spawns a *separate* MCP server process from config via subprocess.Popen,
|
||||||
performs the JSON-RPC handshake, and queries the tools list to verify
|
performs the JSON-RPC handshake, verifies the required tool is registered,
|
||||||
that the connection is fully operational and doesn't return EOF.
|
and invokes that tool. Results are classified with
|
||||||
|
``probe_source=offline_spawn``.
|
||||||
|
|
||||||
|
This path is useful for offline launch/registration debugging. It does
|
||||||
|
**not** prove the IDE-managed MCP client namespace is healthy (#543). For
|
||||||
|
workflow gates, pass live IDE call evidence with
|
||||||
|
``probe_source=client_namespace`` to ``gitea_assess_mcp_namespace_health``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def test_connection(name, config):
|
from mcp_namespace_health import REQUIRED_NAMESPACE_TOOLS, classify_namespace_probe
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_line(proc):
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
stderr_content = proc.stderr.read()
|
||||||
|
return None, stderr_content
|
||||||
|
return json.loads(line), None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_message(proc, payload):
|
||||||
|
proc.stdin.write(json.dumps(payload) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def run_connection_test(name, config, *, required_tool=None, config_path=None):
|
||||||
print(f"Testing MCP connection for '{name}'...")
|
print(f"Testing MCP connection for '{name}'...")
|
||||||
command = config.get("command")
|
command = config.get("command")
|
||||||
args = config.get("args", [])
|
args = config.get("args", [])
|
||||||
env = config.get("env", {})
|
env = config.get("env", {})
|
||||||
|
tool_name = required_tool or REQUIRED_NAMESPACE_TOOLS.get(name) or "gitea_whoami"
|
||||||
|
|
||||||
# Merge current environment
|
# Merge current environment
|
||||||
run_env = os.environ.copy()
|
run_env = os.environ.copy()
|
||||||
@@ -31,8 +56,17 @@ def test_connection(name, config):
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=run_env
|
env=run_env
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Failed to spawn process: {e}")
|
print(f" [FAIL] Failed to spawn process: {exc}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": str(exc)},
|
||||||
|
process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Send initialize request
|
# Send initialize request
|
||||||
@@ -48,26 +82,36 @@ def test_connection(name, config):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
_write_message(proc, init_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Read response
|
# Read response
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
stderr_content = proc.stderr.read()
|
|
||||||
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
|
print(f" [OK] Received initialize response: {str(res)[:150]}...")
|
||||||
|
|
||||||
# Send initialized notification
|
# Send initialized notification
|
||||||
init_notif = {
|
init_notif = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"method": "notifications/initialized"
|
"method": "notifications/initialized"
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
_write_message(proc, init_notif)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Send tools/list request
|
# Send tools/list request
|
||||||
list_req = {
|
list_req = {
|
||||||
@@ -76,16 +120,27 @@ def test_connection(name, config):
|
|||||||
"params": {},
|
"params": {},
|
||||||
"id": 2
|
"id": 2
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(list_req) + "\n")
|
_write_message(proc, list_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
print(" [FAIL] Received EOF on tools/list request.")
|
print(" [FAIL] Received EOF on tools/list request.")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
res = json.loads(line)
|
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
print(f" [FAIL] Server returned error: {res['error']}")
|
print(f" [FAIL] Server returned error: {res['error']}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -94,16 +149,115 @@ def test_connection(name, config):
|
|||||||
tools = res.get("result", {}).get("tools", [])
|
tools = res.get("result", {}).get("tools", [])
|
||||||
tool_names = [t.get("name") for t in tools]
|
tool_names = [t.get("name") for t in tools]
|
||||||
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
||||||
|
if tool_name not in tool_names:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": "required tool missing"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Required tool '{tool_name}' is not registered.")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
call_req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": tool_name, "arguments": {}},
|
||||||
|
"id": 3,
|
||||||
|
}
|
||||||
|
_write_message(proc, call_req)
|
||||||
|
|
||||||
|
call_res, stderr_content = _read_json_line(proc)
|
||||||
|
if call_res is None:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Received EOF on {tool_name} invocation.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
if "error" in call_res:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": call_res["error"]},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": True, "result": call_res.get("result")},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [OK] Successfully invoked required tool '{tool_name}'.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Error during handshake: {e}")
|
print(f" [FAIL] Error during handshake: {exc}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=os.environ.get(
|
||||||
|
"MCP_CONFIG_PATH",
|
||||||
|
os.path.expanduser("~/.gemini/config/mcp_config.json"),
|
||||||
|
),
|
||||||
|
help="Path to MCP config JSON.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
action="append",
|
||||||
|
dest="namespaces",
|
||||||
|
help="Namespace to test. May be repeated.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = args.config
|
||||||
try:
|
try:
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
mcp_config = json.load(f)
|
mcp_config = json.load(f)
|
||||||
@@ -112,10 +266,16 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
servers = mcp_config.get("mcpServers", {})
|
servers = mcp_config.get("mcpServers", {})
|
||||||
|
namespaces = args.namespaces or list(REQUIRED_NAMESPACE_TOOLS)
|
||||||
failed = False
|
failed = False
|
||||||
for name in ["gitea-author", "gitea-reviewer"]:
|
for name in namespaces:
|
||||||
if name in servers:
|
if name in servers:
|
||||||
if not test_connection(name, servers[name]):
|
if not run_connection_test(
|
||||||
|
name,
|
||||||
|
servers[name],
|
||||||
|
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
|
||||||
|
config_path=config_path,
|
||||||
|
):
|
||||||
failed = True
|
failed = True
|
||||||
else:
|
else:
|
||||||
print(f"Server '{name}' not found in mcp_config.json")
|
print(f"Server '{name}' not found in mcp_config.json")
|
||||||
|
|||||||
+52
-1
@@ -1,4 +1,5 @@
|
|||||||
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,13 +17,46 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
the next test. It must never replace verify_mutation_authority with a
|
the next test. It must never replace verify_mutation_authority with a
|
||||||
no-op: individual tests that need a specific authority state set it up
|
no-op: individual tests that need a specific authority state set it up
|
||||||
explicitly.
|
explicitly.
|
||||||
|
|
||||||
|
Session-state isolation (#559 / #590 / #594): many tests use
|
||||||
|
``patch.dict(os.environ, ..., clear=True)``, which drops
|
||||||
|
``GITEA_MCP_SESSION_STATE_DIR``. Relying only on the env var therefore
|
||||||
|
re-exposes the operator host cache
|
||||||
|
(``~/.cache/gitea-tools/session-state``) and its review-mutation ledger.
|
||||||
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
||||||
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", _state_tmp.name)
|
state_dir = _state_tmp.name
|
||||||
|
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", state_dir)
|
||||||
|
try:
|
||||||
|
import mcp_session_state
|
||||||
|
except Exception:
|
||||||
|
mcp_session_state = None
|
||||||
|
if mcp_session_state is not None:
|
||||||
|
# Survive patch.dict(..., clear=True) that drops the env var (#590).
|
||||||
|
# Still honor an explicit GITEA_MCP_SESSION_STATE_DIR when tests set
|
||||||
|
# their own temp dir (see tests/test_mcp_session_state.py).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state, "DEFAULT_STATE_DIR", state_dir, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _isolated_default_state_dir(
|
||||||
|
_fallback: str = state_dir,
|
||||||
|
_env_key: str = mcp_session_state.STATE_DIR_ENV,
|
||||||
|
) -> str:
|
||||||
|
raw = (os.environ.get(_env_key) or "").strip()
|
||||||
|
return raw or _fallback
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state,
|
||||||
|
"default_state_dir",
|
||||||
|
_isolated_default_state_dir,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -32,6 +66,23 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_capability_violation", False)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_capability_violation_files", [])
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
|
||||||
|
# Unit tests must not depend on live host MCP process health. Tests that
|
||||||
|
# use patch.dict(..., clear=True) drop PYTEST_CURRENT_TEST, which would
|
||||||
|
# otherwise re-enable the stale-runtime probe against operator daemons.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_server,
|
||||||
|
"_check_mcp_runtimes_diagnostics",
|
||||||
|
lambda *args, **kwargs: [],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import review_workflow_load
|
import review_workflow_load
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase):
|
|||||||
required_permission("reconciliation_cleanup"),
|
required_permission("reconciliation_cleanup"),
|
||||||
"gitea.branch.delete",
|
"gitea.branch.delete",
|
||||||
)
|
)
|
||||||
self.assertEqual(required_role("reconciliation_cleanup"), "author")
|
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
control_root = "/repo/Gitea-Tools"
|
control_root = "/repo/Gitea-Tools"
|
||||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||||
with mock.patch.dict(
|
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
"os.environ",
|
with mock.patch.dict(
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
"os.environ",
|
||||||
clear=False,
|
{"GITEA_TEST_PORCELAIN": ""},
|
||||||
):
|
clear=False,
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
):
|
||||||
mcp_server.verify_preflight_purity()
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
mcp_server.verify_preflight_purity()
|
||||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||||
|
|
||||||
def test_verify_preflight_allows_branches_worktree(self):
|
def test_verify_preflight_allows_branches_worktree(self):
|
||||||
@@ -97,12 +98,13 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
mcp_server._preflight_capability_called = True
|
mcp_server._preflight_capability_called = True
|
||||||
mcp_server._preflight_resolved_role = "author"
|
mcp_server._preflight_resolved_role = "author"
|
||||||
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
worktree = "/repo/Gitea-Tools/branches/issue-274"
|
||||||
with mock.patch.dict(
|
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
"os.environ",
|
with mock.patch.dict(
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
"os.environ",
|
||||||
clear=False,
|
{"GITEA_TEST_PORCELAIN": ""},
|
||||||
):
|
clear=False,
|
||||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
):
|
||||||
|
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Doc-contract checks for the bootstrap review path (#557).
|
||||||
|
|
||||||
|
Self-hosted MCP workflow fixes can deadlock live review daemons. These tests
|
||||||
|
pin the narrow controller-authorized bootstrap path so it cannot silently
|
||||||
|
regress into a general gate bypass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
DOC = REPO_ROOT / "docs" / "bootstrap-review-path.md"
|
||||||
|
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
|
||||||
|
SKILL = REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_doc_exists_and_names_authorization_marker():
|
||||||
|
text = DOC.read_text(encoding="utf-8")
|
||||||
|
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
|
||||||
|
assert "bootstrap deadlock" in text.lower() or "Bootstrap deadlock" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_doc_requires_validation_and_audits():
|
||||||
|
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||||
|
for phrase in (
|
||||||
|
"git diff --check",
|
||||||
|
"Duplicate-PR audit",
|
||||||
|
"Mutation ledger audit",
|
||||||
|
"Contamination audit",
|
||||||
|
"workflow-contaminated",
|
||||||
|
):
|
||||||
|
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_doc_lists_allowed_and_forbidden_actions():
|
||||||
|
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||||
|
lower = text.lower()
|
||||||
|
for phrase in (
|
||||||
|
"Allowed verification actions",
|
||||||
|
"Forbidden actions",
|
||||||
|
"project root",
|
||||||
|
"direct Python import",
|
||||||
|
"branches/",
|
||||||
|
):
|
||||||
|
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
|
||||||
|
assert "curl" in lower
|
||||||
|
assert "raw" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_doc_forbids_general_gate_weakening():
|
||||||
|
text = _normalize(DOC.read_text(encoding="utf-8")).lower()
|
||||||
|
assert "never weakens normal" in text or "does not weaken normal" in text
|
||||||
|
assert "general bypass" in text or "general gate" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_doc_post_land_restart_and_verify():
|
||||||
|
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||||
|
for phrase in (
|
||||||
|
"Restart MCP daemons",
|
||||||
|
"canonical tools",
|
||||||
|
"gitea_whoami",
|
||||||
|
):
|
||||||
|
assert phrase in text, f"post-bootstrap guidance missing: {phrase!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runbook_links_bootstrap_path():
|
||||||
|
text = _normalize(RUNBOOK.read_text(encoding="utf-8"))
|
||||||
|
assert "Bootstrap Review Path for self-hosted MCP fixes (#557)" in text
|
||||||
|
assert "bootstrap-review-path.md" in text
|
||||||
|
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_links_bootstrap_path():
|
||||||
|
text = _normalize(SKILL.read_text(encoding="utf-8"))
|
||||||
|
assert "Bootstrap Review Path (#557)" in text
|
||||||
|
assert "bootstrap-review-path.md" in text
|
||||||
|
assert "BLOCKED + DIAGNOSE" in text or "BLOCKED" in text
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import branch_cleanup_guard as guard # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import task_capability_map # noqa: E402
|
||||||
|
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||||
|
from mcp_server import gitea_cleanup_merged_pr_branch # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "token fake"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawBranchDeleteGuard(unittest.TestCase):
|
||||||
|
def test_detects_local_and_remote_raw_git_delete_commands(self):
|
||||||
|
text = """
|
||||||
|
Cleanup mutations:
|
||||||
|
- git branch -d feat/issue-485-lease-comments-non-list-guard
|
||||||
|
- git push prgs --delete feat/issue-485-lease-comments-non-list-guard
|
||||||
|
"""
|
||||||
|
commands = guard.raw_branch_delete_commands(text)
|
||||||
|
self.assertEqual(len(commands), 2)
|
||||||
|
self.assertIn("git branch -d", commands[0])
|
||||||
|
self.assertIn("git push prgs --delete", commands[1])
|
||||||
|
|
||||||
|
def test_final_report_blocks_raw_delete_as_cleanup_proof(self):
|
||||||
|
report = """
|
||||||
|
## Controller Handoff
|
||||||
|
- Task: review_pr
|
||||||
|
- Cleanup mutations: git push prgs --delete feat/branch
|
||||||
|
"""
|
||||||
|
result = assess_final_report_validator(report, "review_pr")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("shared.raw_branch_delete_bypass" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cleanup_task_requires_branch_delete_capability(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("cleanup_merged_pr_branch"),
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("cleanup_merged_pr_branch"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
self.mock_all = patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||||
|
return_value=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_reviewer_without_delete_authority_fails_closed(self):
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": ["gitea.branch.delete"],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
|
||||||
|
branch="feat/branch",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||||
|
self.mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_root_checkout_cleanup_fails_closed(self):
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
|
||||||
|
branch="feat/branch",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertIn("root checkout", " ".join(res["reasons"]))
|
||||||
|
self.mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_authorized_cleanup_deletes_through_api_path(self):
|
||||||
|
branch = "feat/issue-485-lease-comments-non-list-guard"
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = [
|
||||||
|
{
|
||||||
|
"number": 487,
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-08T01:00:00Z",
|
||||||
|
"head": {"ref": branch, "sha": "a" * 40},
|
||||||
|
"base": {"ref": "master"},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["performed"])
|
||||||
|
delete_calls = [
|
||||||
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(delete_calls), 1)
|
||||||
|
self.assertIn("branches/feat%2Fissue-485", delete_calls[0].args[1])
|
||||||
|
|
||||||
|
def test_confirmation_required_before_delete(self):
|
||||||
|
branch = "feat/issue-485-lease-comments-non-list-guard"
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = [
|
||||||
|
{
|
||||||
|
"number": 487,
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-08T01:00:00Z",
|
||||||
|
"head": {"ref": branch, "sha": "a" * 40},
|
||||||
|
"base": {"ref": "master"},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="wrong",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertIn("confirmation", " ".join(res["reasons"]))
|
||||||
|
delete_calls = [
|
||||||
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertFalse(delete_calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Controller-closure baseline-proof gate tests (#529, criterion 6)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller_closure_baseline_proof import (
|
||||||
|
assess_controller_closure_baseline_proof,
|
||||||
|
)
|
||||||
|
from premerge_baseline_proof import (
|
||||||
|
CLEAN_PASS,
|
||||||
|
PREMERGE_BASELINE_PROVEN_FAILURE,
|
||||||
|
UNRESOLVED_REGRESSION_RISK,
|
||||||
|
)
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
|
||||||
|
# Complete pre-merge proof fields for a baseline claim (see #533).
|
||||||
|
_PROOF_FIELDS = (
|
||||||
|
"Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
|
||||||
|
"Exit status: 1\n"
|
||||||
|
"Failure signature: AssertionError: None is not true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllerClosureBaselineProof(unittest.TestCase):
|
||||||
|
def test_empty_report_skipped_and_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof("")
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["skipped"])
|
||||||
|
self.assertEqual(result["label"], CLEAN_PASS)
|
||||||
|
|
||||||
|
def test_none_report_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof(None)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["skipped"])
|
||||||
|
|
||||||
|
def test_clean_pass_closure_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof(
|
||||||
|
"Closing #529. Validation: full suite passed, exit status 0. Clean pass."
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["label"], CLEAN_PASS)
|
||||||
|
|
||||||
|
def test_expected_preexisting_failure_without_proof_blocked(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed. This is an expected "
|
||||||
|
"pre-existing failure, safe to close."
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["proven"])
|
||||||
|
self.assertEqual(result["label"], UNRESOLVED_REGRESSION_RISK)
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_current_master_reproduction_only_blocked(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed. Pre-existing baseline failure — "
|
||||||
|
"reproduced on current master after merge.\n"
|
||||||
|
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
|
||||||
|
"Exit status: 1\n"
|
||||||
|
"Failure signature: AssertionError: None is not true\n"
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_proven_baseline_closure_allowed(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed, an expected pre-existing "
|
||||||
|
"baseline failure proven on the PR pre-merge base commit.\n"
|
||||||
|
+ _PROOF_FIELDS
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllerCloseTaskKind(unittest.TestCase):
|
||||||
|
"""The composable validator must cover the controller_close task kind."""
|
||||||
|
|
||||||
|
def test_controller_close_blocks_unproven_baseline(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed. Expected pre-existing failure, closing the "
|
||||||
|
"tracking issue."
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "controller_close")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"premerge_baseline_proof" in f["rule_id"]
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_controller_close_alias_close_issue(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed. Expected pre-existing failure, closing the "
|
||||||
|
"tracking issue."
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "close_issue")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
|
||||||
|
def test_controller_close_allows_proven_baseline(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed, expected pre-existing baseline failure proven "
|
||||||
|
"on the PR pre-merge base commit.\n" + _PROOF_FIELDS
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "controller_close")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ def _review_handoff(**overrides):
|
|||||||
"- External-state mutations: none",
|
"- External-state mutations: none",
|
||||||
"- Read-only diagnostics: issue and PR metadata inspected",
|
"- Read-only diagnostics: issue and PR metadata inspected",
|
||||||
"- Current status: PR open",
|
"- Current status: PR open",
|
||||||
|
"- Next actor: author",
|
||||||
|
"- Next action: address review feedback",
|
||||||
|
"- Next prompt: Fix PR #203 per reviewer request_changes and push updated head",
|
||||||
"- Blockers: none",
|
"- Blockers: none",
|
||||||
"- Next: await author",
|
"- Next: await author",
|
||||||
"- Safety: no self-review; no self-merge",
|
"- Safety: no self-review; no self-merge",
|
||||||
@@ -92,6 +95,9 @@ def _reconcile_handoff(drop=(), **overrides):
|
|||||||
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
|
||||||
"- Reconciliation mutations: PR comment posted",
|
"- Reconciliation mutations: PR comment posted",
|
||||||
"- Current status: reconciliation complete",
|
"- Current status: reconciliation complete",
|
||||||
|
"- Next actor: reconciler",
|
||||||
|
"- Next action: close PR #99 after capability proof",
|
||||||
|
"- Next prompt: Reconcile already-landed PR #99 with prgs-reconciler profile",
|
||||||
"- Blocker: missing gitea.pr.close capability",
|
"- Blocker: missing gitea.pr.close capability",
|
||||||
"- Safe next action: hand off to a profile with gitea.pr.close",
|
"- Safe next action: hand off to a profile with gitea.pr.close",
|
||||||
"- No review/merge confirmation: confirmed",
|
"- No review/merge confirmation: confirmed",
|
||||||
@@ -108,6 +114,28 @@ def _reconcile_handoff(drop=(), **overrides):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwoCommentWorkflowIntegration(unittest.TestCase):
|
||||||
|
def test_tagged_pair_produces_no_two_comment_findings(self):
|
||||||
|
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
|
||||||
|
from tests.test_thread_state_ledger_validator import ( # noqa: E402
|
||||||
|
_minimal_handoff,
|
||||||
|
_minimal_ledger,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _minimal_handoff() + "\n\n" + _minimal_ledger()
|
||||||
|
self.assertEqual(findings_for_final_report(report), [])
|
||||||
|
|
||||||
|
def test_tagged_handoff_without_ledger_blocks_via_validator(self):
|
||||||
|
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
|
||||||
|
from tests.test_thread_state_ledger_validator import _minimal_handoff # noqa: E402
|
||||||
|
|
||||||
|
findings = findings_for_final_report(_minimal_handoff())
|
||||||
|
self.assertTrue(findings)
|
||||||
|
self.assertTrue(
|
||||||
|
any(f["severity"] == "block" for f in findings)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestValidatorFinding(unittest.TestCase):
|
class TestValidatorFinding(unittest.TestCase):
|
||||||
def test_finding_shape(self):
|
def test_finding_shape(self):
|
||||||
finding = validator_finding(
|
finding = validator_finding(
|
||||||
@@ -345,6 +373,92 @@ class TestReconciliationRules(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCanonicalStateUpdateRules(unittest.TestCase):
|
||||||
|
def test_claimed_state_comment_without_block_blocks(self):
|
||||||
|
report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99."
|
||||||
|
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_state_comment_missing_next_prompt_blocks(self):
|
||||||
|
report = (
|
||||||
|
_reconcile_handoff()
|
||||||
|
+ """
|
||||||
|
|
||||||
|
## Canonical PR State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
needs-review
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
reviewer
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
Review PR #99.
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
|
||||||
|
ISSUE:
|
||||||
|
#98
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
|
||||||
|
|
||||||
|
REVIEW_STATUS:
|
||||||
|
none
|
||||||
|
|
||||||
|
MERGE_READY:
|
||||||
|
no
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
reasons = " ".join(f["reason"] for f in result["findings"])
|
||||||
|
self.assertIn("NEXT_PROMPT", reasons)
|
||||||
|
|
||||||
|
def test_valid_state_comment_claim_passes_shared_rule(self):
|
||||||
|
report = (
|
||||||
|
_reconcile_handoff()
|
||||||
|
+ """
|
||||||
|
|
||||||
|
## Canonical PR State
|
||||||
|
|
||||||
|
STATE:
|
||||||
|
needs-review
|
||||||
|
|
||||||
|
WHO_IS_NEXT:
|
||||||
|
reviewer
|
||||||
|
|
||||||
|
NEXT_ACTION:
|
||||||
|
Review PR #99 against the pinned head.
|
||||||
|
|
||||||
|
NEXT_PROMPT:
|
||||||
|
Load the review workflow and review PR #99.
|
||||||
|
|
||||||
|
ISSUE:
|
||||||
|
#98
|
||||||
|
|
||||||
|
HEAD_SHA:
|
||||||
|
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
|
||||||
|
|
||||||
|
REVIEW_STATUS:
|
||||||
|
none
|
||||||
|
|
||||||
|
MERGE_READY:
|
||||||
|
no
|
||||||
|
|
||||||
|
BLOCKERS:
|
||||||
|
none
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||||
|
self.assertFalse(
|
||||||
|
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCanonicalReconcileSchema(unittest.TestCase):
|
class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||||
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
"""Issue #307: reconciliation workflows emit only the canonical schema."""
|
||||||
|
|
||||||
@@ -573,6 +687,31 @@ class TestReconcilerCloseProof(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCanonicalCommentPostClaim(unittest.TestCase):
|
||||||
|
def test_blocks_post_claim_when_validator_rejected_in_report(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"## Controller Handoff",
|
||||||
|
"- MCP/Gitea mutations: issue comment posted",
|
||||||
|
"- Issue comments posted: 1 canonical state comment",
|
||||||
|
"canonical comment validation failed (fail closed before posting).",
|
||||||
|
'- canonical_comment_validation: {"allowed": false}',
|
||||||
|
])
|
||||||
|
result = assess_final_report_validator(report, "work_issue")
|
||||||
|
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||||
|
self.assertIn("shared.canonical_comment_post_claim", rule_ids)
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
|
||||||
|
def test_allows_none_when_validator_rejected_without_post_claim(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"## Controller Handoff",
|
||||||
|
"- Issue comments posted: none",
|
||||||
|
"canonical comment validation failed (fail closed before posting).",
|
||||||
|
])
|
||||||
|
result = assess_final_report_validator(report, "work_issue")
|
||||||
|
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||||
|
self.assertNotIn("shared.canonical_comment_post_claim", rule_ids)
|
||||||
|
|
||||||
|
|
||||||
class TestEntryPoint(unittest.TestCase):
|
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")
|
||||||
@@ -588,8 +727,197 @@ 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_manual_seeded_lease_claim_blocks_without_mcp_evidence(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 using manually seeded lease proof",
|
||||||
|
"- 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.assertTrue(result["blocked"])
|
||||||
|
codes = {f.get("rule_id") for f in result.get("findings") or []}
|
||||||
|
self.assertIn("shared.manual_lease_proof_handoff", codes)
|
||||||
|
|
||||||
|
def test_manual_seeded_claim_allowed_when_adoption_cited(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: gitea_adopt_merger_pr_lease then merge",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: historical manual seed replaced by sanctioned adoption",
|
||||||
|
"- 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",
|
||||||
|
"- lease_proof_source: gitea_adopt_merger_pr_lease",
|
||||||
|
"- lease_proof_kind: sanctioned_adoption",
|
||||||
|
"- adoption_comment_id: 8288",
|
||||||
|
"- Merge result: PR merged successfully",
|
||||||
|
"- Cleanup status: complete",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(report, "merge_pr")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
codes = {f.get("rule_id") for f in result.get("findings") or []}
|
||||||
|
self.assertNotIn("shared.manual_lease_proof_handoff", codes)
|
||||||
|
|
||||||
|
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,397 @@
|
|||||||
|
"""Head-scoped #332 review-decision locks (#620).
|
||||||
|
|
||||||
|
Proves:
|
||||||
|
* REQUEST_CHANGES on head A does not block mark_ready/submit on head B
|
||||||
|
* APPROVE on head B is allowed after RC on head A (same open PR)
|
||||||
|
* same-head duplicate terminal remains blocked
|
||||||
|
* open-PR cleanup remains forbidden; assessment reports stale_by_head
|
||||||
|
* historical mutations keep their head_sha (preserved ledger)
|
||||||
|
* PR #619-style: old RC at 036b78e…, current head 2429d9d…, approve allowed
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
|
||||||
|
HEAD_A = "036b78e31ea5d036452b3a52e0e086b01e0c763f"
|
||||||
|
HEAD_B = "2429d9d2e826e519eb8eb4ade45987c00838aefb"
|
||||||
|
HEAD_C = "c" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||||
|
return {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": ready_pr,
|
||||||
|
"ready_action": ready_action,
|
||||||
|
"ready_expected_head_sha": ready_head,
|
||||||
|
"ready_remote": "prgs" if ready_pr else None,
|
||||||
|
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||||
|
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": correction,
|
||||||
|
"correction_reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RC_619_LEGACY = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 410,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
# no head_sha — pre-#620 durable ledger shape
|
||||||
|
}
|
||||||
|
RC_619_HEAD_A = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 410,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
"head_sha": HEAD_A,
|
||||||
|
}
|
||||||
|
APPROVE_619_HEAD_B = {
|
||||||
|
"pr_number": 619,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": 411,
|
||||||
|
"review_state": "approve",
|
||||||
|
"head_sha": HEAD_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(mutations=None, **kwargs):
|
||||||
|
import review_workflow_load
|
||||||
|
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(pr_number=619, head=HEAD_B):
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"merged_at": None,
|
||||||
|
"head": {"sha": head},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_lease():
|
||||||
|
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback(blocking=False, stale=False, success=True):
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"has_blocking_change_requests": blocking,
|
||||||
|
"review_feedback_stale": stale,
|
||||||
|
"current_head_sha": HEAD_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPureHeadScopeHelpers(unittest.TestCase):
|
||||||
|
def test_mutation_head_prefers_recorded_sha(self):
|
||||||
|
m = {"pr_number": 1, "head_sha": HEAD_A}
|
||||||
|
self.assertEqual(srdl.mutation_head_sha(m), HEAD_A)
|
||||||
|
|
||||||
|
def test_legacy_mutation_uses_ready_expected_for_same_pr(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(srdl.mutation_head_sha(RC_619_LEGACY, lock), HEAD_A)
|
||||||
|
|
||||||
|
def test_legacy_mutation_ignores_ready_for_other_pr(self):
|
||||||
|
lock = _lock([RC_619_LEGACY], ready_pr=1, ready_head=HEAD_A)
|
||||||
|
self.assertIsNone(srdl.mutation_head_sha(RC_619_LEGACY, lock))
|
||||||
|
|
||||||
|
def test_prior_blocks_same_head_not_other_head(self):
|
||||||
|
lock = _lock([RC_619_HEAD_A])
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=619, expected_head_sha=HEAD_A
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=619, expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backfill_stamps_legacy_terminals(self):
|
||||||
|
lock = _lock(
|
||||||
|
[dict(RC_619_LEGACY)],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
srdl.backfill_terminal_heads_from_ready(lock)
|
||||||
|
self.assertEqual(lock["live_mutations"][0]["head_sha"], HEAD_A)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardStopHeadScope(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_rc_head_a_allows_mark_ready_head_b(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rc_head_a_blocks_mark_ready_same_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_A
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertIn("#332", reasons[0])
|
||||||
|
|
||||||
|
def test_rc_head_a_blocks_other_pr(self):
|
||||||
|
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
700, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
|
||||||
|
def test_legacy_619_ledger_allows_new_head(self):
|
||||||
|
"""PR #619-style durable lock without mutation head_sha."""
|
||||||
|
_seed(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
619, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkFinalAndRecord(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def _mark(self, pr, action, head, feedback=None):
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_get_pr_review_feedback",
|
||||||
|
return_value=feedback or _feedback(blocking=False, stale=True),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"eligible": True, "head_sha": head},
|
||||||
|
):
|
||||||
|
return mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=pr,
|
||||||
|
action=action,
|
||||||
|
expected_head_sha=head,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rc_then_rc_on_new_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
# Prior RC at head A is unresolved but head moved → feedback stale.
|
||||||
|
res = self._mark(
|
||||||
|
619,
|
||||||
|
"request_changes",
|
||||||
|
HEAD_B,
|
||||||
|
feedback=_feedback(blocking=True, stale=True),
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
# Historical head A preserved on mutation after backfill.
|
||||||
|
heads = {
|
||||||
|
m.get("head_sha")
|
||||||
|
for m in lock["live_mutations"]
|
||||||
|
if m.get("action") == "request_changes"
|
||||||
|
}
|
||||||
|
self.assertIn(HEAD_A, heads)
|
||||||
|
self.assertEqual(lock["ready_expected_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_rc_then_approve_on_new_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
self.assertTrue(res.get("head_scoped"))
|
||||||
|
|
||||||
|
def test_same_head_rc_still_blocked_by_hard_stop(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_A)
|
||||||
|
self.assertFalse(res.get("marked_ready"))
|
||||||
|
self.assertTrue(any("#332" in r for r in res.get("reasons") or []))
|
||||||
|
|
||||||
|
def test_pr619_style_legacy_lock_approve_on_current_head(self):
|
||||||
|
_seed(
|
||||||
|
[RC_619_LEGACY],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(619, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
# Backfill should have stamped HEAD_A onto the legacy mutation.
|
||||||
|
self.assertEqual(lock["live_mutations"][0].get("head_sha"), HEAD_A)
|
||||||
|
self.assertEqual(lock["ready_expected_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_record_live_mutation_stores_head(self):
|
||||||
|
_seed(ready_pr=619, ready_head=HEAD_B, ready_action="approve")
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_B
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
mcp_server.record_live_review_mutation(619, "approve", review_id=99)
|
||||||
|
stored = mcp_server._load_review_decision_lock()["live_mutations"][-1]
|
||||||
|
self.assertEqual(stored["head_sha"], HEAD_B)
|
||||||
|
self.assertEqual(stored["pr_number"], 619)
|
||||||
|
|
||||||
|
def test_submit_gate_allows_new_head_after_prior_terminal(self):
|
||||||
|
"""check_review_decision_gate must not block different-head submit."""
|
||||||
|
mutations = [RC_619_HEAD_A]
|
||||||
|
_seed(
|
||||||
|
mutations,
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_B,
|
||||||
|
ready_action="approve",
|
||||||
|
)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_action"] = "approve"
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_B
|
||||||
|
lock["ready_remote"] = "prgs"
|
||||||
|
lock["ready_org"] = "Scaled-Tech-Consulting"
|
||||||
|
lock["ready_repo"] = "Gitea-Tools"
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
reasons = mcp_server.check_review_decision_gate(
|
||||||
|
619,
|
||||||
|
"approve",
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertEqual(reasons, [], reasons)
|
||||||
|
|
||||||
|
def test_submit_gate_blocks_same_head_duplicate(self):
|
||||||
|
mutations = [RC_619_HEAD_A]
|
||||||
|
_seed(
|
||||||
|
mutations,
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 619
|
||||||
|
lock["ready_action"] = "request_changes"
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_A
|
||||||
|
lock["ready_remote"] = "prgs"
|
||||||
|
lock["ready_org"] = "Scaled-Tech-Consulting"
|
||||||
|
lock["ready_repo"] = "Gitea-Tools"
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
reasons = mcp_server.check_review_decision_gate(
|
||||||
|
619,
|
||||||
|
"request_changes",
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessmentOpenPrHead(unittest.TestCase):
|
||||||
|
def test_open_pr_stale_by_head_no_cleanup(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_HEAD_A],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(619, HEAD_B)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["has_lock"])
|
||||||
|
self.assertFalse(a["is_moot"])
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertTrue(a["stale_by_head"])
|
||||||
|
self.assertTrue(a["fresh_review_on_current_head_allowed"])
|
||||||
|
self.assertEqual(a["locked_head_sha"], HEAD_A)
|
||||||
|
self.assertEqual(a["current_pr_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
def test_open_pr_same_head_no_fresh(self):
|
||||||
|
lock = _lock([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(619, HEAD_A)
|
||||||
|
)
|
||||||
|
self.assertFalse(a["stale_by_head"])
|
||||||
|
self.assertFalse(a["fresh_review_on_current_head_allowed"])
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_historical_mutation_preserved_in_summary(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_619_HEAD_A, APPROVE_619_HEAD_B],
|
||||||
|
ready_pr=619,
|
||||||
|
ready_head=HEAD_B,
|
||||||
|
ready_action="approve",
|
||||||
|
)
|
||||||
|
summary = srdl.lock_summary(lock)
|
||||||
|
self.assertEqual(summary["live_mutations_count"], 2)
|
||||||
|
self.assertEqual(summary["last_terminal"]["head_sha"], HEAD_B)
|
||||||
|
self.assertEqual(summary["locked_head_sha"], HEAD_B)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -198,10 +198,13 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
|
|||||||
srv._preflight_capability_baseline_porcelain = ""
|
srv._preflight_capability_baseline_porcelain = ""
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
self._orig_in_test = srv._preflight_in_test_mode
|
||||||
srv._preflight_in_test_mode = lambda: False
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
self._env_patch = patch.dict(os.environ, {"GITEA_MCP_DISABLE_PARITY_GATE": "1"}, clear=False)
|
||||||
|
self._env_patch.start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
srv._preflight_resolved_role = None
|
srv._preflight_resolved_role = None
|
||||||
|
self._env_patch.stop()
|
||||||
|
|
||||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@@ -230,7 +233,7 @@ class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
|
|||||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||||
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
|
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
|
||||||
result = srv.gitea_create_issue_comment(
|
result = srv.gitea_create_issue_comment(
|
||||||
515, "canonical reconciler audit", remote="prgs"
|
515, "reconciler audit comment", remote="prgs"
|
||||||
)
|
)
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertEqual(result["comment_id"], 9001)
|
self.assertEqual(result["comment_id"], 9001)
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
srv.gitea_create_issue_comment(
|
srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="canonical evidence",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
)
|
)
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
self.assertIn("stable control checkout", str(ctx.exception))
|
||||||
@@ -140,7 +140,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||||
result = srv.gitea_create_issue_comment(
|
result = srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="canonical evidence",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo="Gitea-Tools",
|
repo="Gitea-Tools",
|
||||||
@@ -153,7 +153,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
method, url, _auth_arg, payload = mock_api.call_args[0]
|
method, url, _auth_arg, payload = mock_api.call_args[0]
|
||||||
self.assertEqual(method, "POST")
|
self.assertEqual(method, "POST")
|
||||||
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
|
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
|
||||||
self.assertEqual(payload, {"body": "canonical evidence"})
|
self.assertEqual(payload, {"body": "evidence comment"})
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
@patch("gitea_mcp_server.api_request")
|
@patch("gitea_mcp_server.api_request")
|
||||||
@@ -183,7 +183,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
srv.gitea_create_issue_comment(
|
srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="canonical evidence",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=outside_worktree,
|
worktree_path=outside_worktree,
|
||||||
)
|
)
|
||||||
@@ -198,7 +198,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
srv.gitea_create_issue_comment(
|
srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="canonical evidence",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=os.path.join(
|
worktree_path=os.path.join(
|
||||||
CONTROL_CHECKOUT_ROOT,
|
CONTROL_CHECKOUT_ROOT,
|
||||||
@@ -239,7 +239,7 @@ class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, denied_env, clear=True):
|
with patch.dict(os.environ, denied_env, clear=True):
|
||||||
result = srv.gitea_create_issue_comment(
|
result = srv.gitea_create_issue_comment(
|
||||||
issue_number=557,
|
issue_number=557,
|
||||||
body="canonical evidence",
|
body="evidence comment",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=valid_worktree,
|
worktree_path=valid_worktree,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from issue_content_gate import ( # noqa: E402
|
||||||
|
assess_issue_content,
|
||||||
|
find_vague_reference,
|
||||||
|
has_durable_source_pointer,
|
||||||
|
normalize_text,
|
||||||
|
pre_create_issue_content_gate,
|
||||||
|
)
|
||||||
|
from mcp_server import gitea_create_issue # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
ISSUE_WRITE_ENV = {
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeAndPointers(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_normalize_strips_punctuation_and_case(self):
|
||||||
|
self.assertEqual(normalize_text(" The Drafted-Issue! "), "the drafted issue")
|
||||||
|
|
||||||
|
def test_issue_ref_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("See #402 for the spec"))
|
||||||
|
|
||||||
|
def test_file_path_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("Draft in task_capability_map.py"))
|
||||||
|
|
||||||
|
def test_scratchpad_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("Full text in scratchpad/draft.md"))
|
||||||
|
|
||||||
|
def test_url_is_durable_pointer(self):
|
||||||
|
self.assertTrue(
|
||||||
|
has_durable_source_pointer("https://gitea.prgs.cc/issues/1 has it")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plain_text_has_no_durable_pointer(self):
|
||||||
|
self.assertFalse(has_durable_source_pointer("just some words here"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindVagueReference(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_bare_vague_phrases_are_flagged(self):
|
||||||
|
for text in (
|
||||||
|
"the drafted issue",
|
||||||
|
"The prepared issue",
|
||||||
|
"the issue we discussed",
|
||||||
|
"the previous draft",
|
||||||
|
"Create the drafted issue",
|
||||||
|
"please file the prepared issue",
|
||||||
|
):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertIsNotNone(find_vague_reference(text))
|
||||||
|
|
||||||
|
def test_durable_pointer_defeats_vague_reference(self):
|
||||||
|
# Same vague phrase but with a durable pointer -> not vague.
|
||||||
|
self.assertIsNone(find_vague_reference("the drafted issue in #402"))
|
||||||
|
self.assertIsNone(
|
||||||
|
find_vague_reference("the prepared issue: see scratchpad/draft.md")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_long_specific_body_with_incidental_phrase_is_not_vague(self):
|
||||||
|
body = (
|
||||||
|
"As discussed, the create_issue tool must reject vague references. "
|
||||||
|
"Add a preflight check that lists the exact missing fields and "
|
||||||
|
"returns BLOCKED + DIAGNOSE with concrete acceptance criteria."
|
||||||
|
)
|
||||||
|
self.assertIsNone(find_vague_reference(body))
|
||||||
|
|
||||||
|
def test_concrete_short_body_is_not_vague(self):
|
||||||
|
self.assertIsNone(find_vague_reference("Fix null deref in auth handler"))
|
||||||
|
|
||||||
|
def test_empty_is_not_vague(self):
|
||||||
|
self.assertIsNone(find_vague_reference(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessIssueContent(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_missing_title_is_incomplete(self):
|
||||||
|
result = assess_issue_content("", "some body")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("title", result["missing_fields"])
|
||||||
|
|
||||||
|
def test_vague_body_is_incomplete_and_diagnosed(self):
|
||||||
|
result = assess_issue_content("Add the thing", "the drafted issue")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("body", result["vague_fields"])
|
||||||
|
self.assertIn("vague reference", result["diagnose"])
|
||||||
|
|
||||||
|
def test_vague_title_is_incomplete(self):
|
||||||
|
result = assess_issue_content("the prepared issue", "")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("title", result["vague_fields"])
|
||||||
|
|
||||||
|
def test_complete_content_passes(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"Block vague issue creation",
|
||||||
|
"Add a preflight gate that rejects placeholder references.",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
self.assertEqual(result["missing_fields"], [])
|
||||||
|
self.assertEqual(result["vague_fields"], [])
|
||||||
|
|
||||||
|
def test_title_only_is_allowed(self):
|
||||||
|
# Empty body must NOT block: title-only creation stays allowed.
|
||||||
|
result = assess_issue_content("A concrete specific title", "")
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
|
||||||
|
def test_durable_pointer_body_passes(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"Implement the drafted change",
|
||||||
|
"the drafted issue is fully specified in #582",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
|
||||||
|
def test_allow_incomplete_override(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"the drafted issue", "the drafted issue", allow_incomplete=True
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
self.assertTrue(result["override_applied"])
|
||||||
|
|
||||||
|
def test_gate_wrapper_sets_performed(self):
|
||||||
|
blocked = pre_create_issue_content_gate("Add x", "the drafted issue")
|
||||||
|
self.assertFalse(blocked["performed"])
|
||||||
|
ok = pre_create_issue_content_gate("Add x", "concrete actionable body")
|
||||||
|
self.assertTrue(ok["performed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueContentMCPGate(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_blocks_vague_reference_without_creating(
|
||||||
|
self, _auth, mock_get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="Create the drafted issue",
|
||||||
|
body="the drafted issue",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("content_gate", result)
|
||||||
|
self.assertIn("body", result["content_gate"]["vague_fields"])
|
||||||
|
self.assertTrue(any("BLOCKED + DIAGNOSE" in r for r in result["reasons"]))
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
mock_get_all.assert_not_called()
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@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_creates_when_content_is_concrete(
|
||||||
|
self, _auth, _get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
mock_api.return_value = {"number": 7, "html_url": "https://x/issues/7"}
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="Block vague issue creation",
|
||||||
|
body="Add a preflight gate rejecting placeholder references.",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["number"], 7)
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@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_override_creates_despite_vague_content(
|
||||||
|
self, _auth, _get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
mock_api.return_value = {"number": 8, "html_url": "https://x/issues/8"}
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="the drafted issue",
|
||||||
|
body="the drafted issue",
|
||||||
|
remote="prgs",
|
||||||
|
allow_incomplete_content=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["number"], 8)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__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()
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase):
|
|||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
)
|
)
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[{"id": 4, "name": "status:pr-open"}])
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@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)
|
||||||
@@ -105,7 +105,11 @@ class TestCreatePrLockRegistrationFlow(unittest.TestCase):
|
|||||||
self, _auth, _role, _api, _git_state, mock_api_request
|
self, _auth, _role, _api, _git_state, mock_api_request
|
||||||
):
|
):
|
||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"}
|
def mock_api(method, url, auth, data=None):
|
||||||
|
if "/labels" in url:
|
||||||
|
return [{"name": "status:pr-open"}]
|
||||||
|
return {"number": 521, "html_url": "https://example/pr/521"}
|
||||||
|
mock_api_request.side_effect = mock_api
|
||||||
lock_res = gitea_lock_issue(
|
lock_res = gitea_lock_issue(
|
||||||
issue_number=521,
|
issue_number=521,
|
||||||
branch_name="feat/issue-521-lock-issue-registration",
|
branch_name="feat/issue-521-lock-issue-registration",
|
||||||
|
|||||||
@@ -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,152 @@
|
|||||||
|
"""Tests for the master-parity staleness gate (#420).
|
||||||
|
|
||||||
|
Covers the pure assessment logic, the mutation-block helper, the env escape
|
||||||
|
hatches, and the server-side wiring: reads are never blocked by staleness, a
|
||||||
|
mutation is refused when the on-disk master has advanced, and the read-only
|
||||||
|
gitea_assess_master_parity tool reports the stale state.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import master_parity_gate as mp # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
SHA_A = "a" * 40
|
||||||
|
SHA_B = "b" * 40
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessMasterParity(unittest.TestCase):
|
||||||
|
def test_in_parity_when_heads_match(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||||
|
self.assertTrue(res["in_parity"])
|
||||||
|
self.assertFalse(res["stale"])
|
||||||
|
self.assertFalse(res["restart_required"])
|
||||||
|
self.assertTrue(res["determinable"])
|
||||||
|
|
||||||
|
def test_stale_when_master_advanced(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||||
|
self.assertFalse(res["in_parity"])
|
||||||
|
self.assertTrue(res["stale"])
|
||||||
|
self.assertTrue(res["restart_required"])
|
||||||
|
self.assertTrue(res["determinable"])
|
||||||
|
self.assertTrue(any("restart" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_startup_head_not_determinable(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
|
||||||
|
self.assertFalse(res["determinable"])
|
||||||
|
self.assertFalse(res["stale"])
|
||||||
|
self.assertTrue(res["in_parity"])
|
||||||
|
|
||||||
|
def test_missing_current_head_not_determinable(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
|
||||||
|
self.assertFalse(res["determinable"])
|
||||||
|
self.assertFalse(res["stale"])
|
||||||
|
self.assertTrue(res["in_parity"])
|
||||||
|
|
||||||
|
def test_none_startup_baseline(self):
|
||||||
|
res = mp.assess_master_parity(None, SHA_A)
|
||||||
|
self.assertFalse(res["determinable"])
|
||||||
|
self.assertFalse(res["stale"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlockReasonsAndReport(unittest.TestCase):
|
||||||
|
def test_stale_produces_block_reasons(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||||
|
self.assertTrue(mp.parity_block_reasons(res))
|
||||||
|
|
||||||
|
def test_in_parity_no_block_reasons(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||||
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||||
|
|
||||||
|
def test_disable_env_suppresses_block(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||||
|
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
||||||
|
self.assertTrue(mp.gate_disabled())
|
||||||
|
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||||
|
|
||||||
|
def test_report_shape_when_stale(self):
|
||||||
|
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||||
|
report = mp.parity_report(res)
|
||||||
|
self.assertEqual(report["kind"], "server_stale")
|
||||||
|
self.assertTrue(report["restart_required"])
|
||||||
|
self.assertEqual(report["startup_head"], SHA_A)
|
||||||
|
self.assertEqual(report["current_head"], SHA_B)
|
||||||
|
self.assertTrue(report["recovery"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadGitHead(unittest.TestCase):
|
||||||
|
def test_test_override_takes_precedence(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||||
|
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
|
||||||
|
|
||||||
|
def test_blank_override_is_none(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
|
||||||
|
self.assertIsNone(mp.read_git_head("/nonexistent"))
|
||||||
|
|
||||||
|
def test_empty_root_is_none(self):
|
||||||
|
# No override set; empty root cannot resolve a HEAD.
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k != mp.ENV_TEST_CURRENT_HEAD}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
self.assertIsNone(mp.read_git_head(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerWiring(unittest.TestCase):
|
||||||
|
"""Integration with the gate choke point in the server namespace."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import mcp_server
|
||||||
|
self.srv = mcp_server
|
||||||
|
# Pin a known startup baseline so the current-HEAD override can diverge.
|
||||||
|
self._saved = self.srv._STARTUP_PARITY
|
||||||
|
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
||||||
|
"startup_head": SHA_A}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.srv._STARTUP_PARITY = self._saved
|
||||||
|
|
||||||
|
def test_reads_not_blocked_when_stale(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||||
|
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
||||||
|
|
||||||
|
def test_mutation_blocked_when_stale(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||||
|
reasons = self.srv._master_parity_block("gitea.pr.create")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
# The profile-operation choke point surfaces the same block.
|
||||||
|
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
|
||||||
|
|
||||||
|
def test_mutation_allowed_when_in_parity(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
||||||
|
self.assertEqual(
|
||||||
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_disable_env_lets_mutation_through(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
|
||||||
|
mp.ENV_DISABLE: "1"}):
|
||||||
|
self.assertEqual(
|
||||||
|
self.srv._master_parity_block("gitea.pr.create"), [])
|
||||||
|
|
||||||
|
def test_assess_tool_reports_stale(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||||
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||||
|
self.assertTrue(out["stale"])
|
||||||
|
self.assertTrue(out["restart_required"])
|
||||||
|
self.assertEqual(out["startup_head"], SHA_A)
|
||||||
|
self.assertEqual(out["current_head"], SHA_B)
|
||||||
|
self.assertIn("report", out)
|
||||||
|
|
||||||
|
def test_assess_tool_reports_in_parity(self):
|
||||||
|
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
||||||
|
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||||
|
self.assertFalse(out["stale"])
|
||||||
|
self.assertTrue(out["in_parity"])
|
||||||
|
self.assertNotIn("report", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user