Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb97060867 |
@@ -46,12 +46,3 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
# profile's values. Leave unset for pure env-based configuration.
|
||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||
GITEA_MCP_PROFILE=prgs
|
||||
|
||||
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
|
||||
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
|
||||
# merger process) are ignored.
|
||||
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
|
||||
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||
|
||||
@@ -10,7 +10,3 @@ gitea-mcp*.json
|
||||
.vscode/
|
||||
graphify-out/
|
||||
branches/
|
||||
# Throwaway agent commit-encoding helpers (#261) — never commit.
|
||||
/_encode_*.py
|
||||
/_emit_*.py
|
||||
/_inline_*.py
|
||||
|
||||
@@ -53,7 +53,6 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n
|
||||
| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) |
|
||||
| `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) |
|
||||
| `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR |
|
||||
| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree |
|
||||
| `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) |
|
||||
| `gitea_mark_issue` | Claim/release an issue (start/done) |
|
||||
| `gitea_list_labels` | List all available labels in a repository |
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Detect throwaway agent helper scripts left in the repo root (#261)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
|
||||
AGENT_TEMP_BASENAME_PATTERNS = (
|
||||
"_encode_*.py",
|
||||
"_emit_*.py",
|
||||
"_inline_*.py",
|
||||
)
|
||||
|
||||
|
||||
def find_agent_temp_artifacts_from_porcelain(porcelain: str) -> list[str]:
|
||||
"""Return untracked repo-root helper paths matching agent temp patterns."""
|
||||
found: list[str] = []
|
||||
for line in (porcelain or "").splitlines():
|
||||
if not line.startswith("??"):
|
||||
continue
|
||||
path = line[3:].strip()
|
||||
if not path or "/" in path or "\\" in path:
|
||||
continue
|
||||
basename = path.split("/")[-1]
|
||||
if any(fnmatch.fnmatch(basename, pat) for pat in AGENT_TEMP_BASENAME_PATTERNS):
|
||||
found.append(path)
|
||||
return sorted(found)
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Already-landed open PR reconciliation gates (#310).
|
||||
|
||||
Reconciler workflows may close an open PR only when the PR head SHA is proven
|
||||
an ancestor of a freshly fetched target branch. Arbitrary PR closure is denied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def assess_already_landed_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return eligibility and proof for reconciling an open PR."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None
|
||||
if not head_sha and isinstance(pr.get("head"), str):
|
||||
head_sha = None
|
||||
head_ref = (pr.get("head") or {}).get("ref") if isinstance(pr.get("head"), dict) else pr.get("head")
|
||||
base_ref = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base")
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(project_root, remote, target_branch)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["close_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
@@ -1,344 +0,0 @@
|
||||
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
|
||||
|
||||
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
|
||||
explicitly authorized with exact capability proof, safety proof, and
|
||||
before/after snapshots. Cleanup mutations must be classified in final reports;
|
||||
audit reports must not claim ``no mutations`` when cleanup occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
|
||||
|
||||
PHASE_AUDIT = "audit"
|
||||
PHASE_CLEANUP = "cleanup"
|
||||
|
||||
# Tasks that enter audit phase on capability resolution (read-only default).
|
||||
AUDIT_PHASE_TASKS = frozenset({
|
||||
"reconcile-landed-pr",
|
||||
"reconcile_landed_pr",
|
||||
"reconcile_issue_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
})
|
||||
|
||||
# Mutation tasks forbidden during audit phase (fail closed).
|
||||
AUDIT_FORBIDDEN_TASKS = frozenset({
|
||||
"delete_branch",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"claim_issue",
|
||||
"close_pr",
|
||||
"close_issue",
|
||||
"create_issue",
|
||||
"merge_pr",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"comment_pr",
|
||||
"comment_issue",
|
||||
"set_issue_labels",
|
||||
})
|
||||
|
||||
# Shell/git commands audit phase must not run.
|
||||
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
|
||||
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
|
||||
r"gitea_delete_branch|delete_remote_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NO_MUTATIONS_RE = re.compile(
|
||||
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_OCCURRED_RE = re.compile(
|
||||
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
|
||||
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
|
||||
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_STATE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_PHASE_AUTH_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BEFORE_AFTER_RE = re.compile(
|
||||
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_SAFETY_PROOF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_session: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _blank_session() -> dict[str, Any]:
|
||||
return {
|
||||
"phase": PHASE_AUDIT,
|
||||
"entered_from_task": None,
|
||||
"cleanup_authorized": False,
|
||||
"cleanup_authorization": {},
|
||||
}
|
||||
|
||||
|
||||
def current_phase() -> str | None:
|
||||
"""Return active reconciliation phase or None when unset."""
|
||||
if not _session:
|
||||
return None
|
||||
return _session.get("phase")
|
||||
|
||||
|
||||
def active_record() -> dict[str, Any] | None:
|
||||
"""Return a copy of the session record, if any."""
|
||||
return dict(_session) if _session else None
|
||||
|
||||
|
||||
def clear_phase() -> None:
|
||||
"""Clear reconciliation phase state."""
|
||||
global _session
|
||||
_session = None
|
||||
|
||||
|
||||
def enter_audit_phase(task: str) -> dict[str, Any]:
|
||||
"""Enter read-only audit phase for a reconciliation task."""
|
||||
global _session
|
||||
normalized = (task or "").strip().lower()
|
||||
_session = _blank_session()
|
||||
_session["entered_from_task"] = normalized
|
||||
return dict(_session)
|
||||
|
||||
|
||||
def authorize_cleanup_phase(
|
||||
*,
|
||||
operator_approved: bool = False,
|
||||
workflow_authorized: bool = False,
|
||||
delete_capability_proven: bool = False,
|
||||
safety_proof: dict[str, Any] | None = None,
|
||||
before_after_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize cleanup phase after explicit approval and safety proofs."""
|
||||
reasons: list[str] = []
|
||||
if not (operator_approved or workflow_authorized):
|
||||
reasons.append(
|
||||
"cleanup phase requires operator approval or explicit workflow "
|
||||
"authorization"
|
||||
)
|
||||
if not delete_capability_proven:
|
||||
reasons.append(
|
||||
"cleanup phase requires exact delete_branch capability proof "
|
||||
"(gitea.branch.delete)"
|
||||
)
|
||||
safety = dict(safety_proof or {})
|
||||
if not safety.get("safe_to_delete_remote") and not safety.get(
|
||||
"safe_to_remove_worktree"
|
||||
):
|
||||
reasons.append(
|
||||
"cleanup phase requires proof that branch/worktree is safe to remove"
|
||||
)
|
||||
snapshot = dict(before_after_snapshot or {})
|
||||
if not snapshot.get("before") or not snapshot.get("after"):
|
||||
reasons.append(
|
||||
"cleanup phase requires before/after state snapshot"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"authorized": False,
|
||||
"phase": current_phase() or PHASE_AUDIT,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remain in audit-only mode or supply operator approval, "
|
||||
"delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot before cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = _blank_session()
|
||||
_session["phase"] = PHASE_CLEANUP
|
||||
_session["cleanup_authorized"] = True
|
||||
_session["cleanup_authorization"] = {
|
||||
"operator_approved": operator_approved,
|
||||
"workflow_authorized": workflow_authorized,
|
||||
"delete_capability_proven": delete_capability_proven,
|
||||
"safety_proof": safety,
|
||||
"before_after_snapshot": snapshot,
|
||||
}
|
||||
return {
|
||||
"authorized": True,
|
||||
"phase": PHASE_CLEANUP,
|
||||
"reasons": [],
|
||||
"cleanup_authorization": dict(_session["cleanup_authorization"]),
|
||||
"safe_next_action": "proceed with authorized cleanup mutations only",
|
||||
}
|
||||
|
||||
|
||||
def check_audit_task_enters_phase(task: str) -> bool:
|
||||
"""Return whether resolving *task* should enter audit phase."""
|
||||
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
|
||||
|
||||
|
||||
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed when a mutation task runs during audit phase."""
|
||||
normalized = (task or "").strip().lower()
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
if normalized in AUDIT_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in audit-only reconciliation "
|
||||
"mode: switch to an explicit cleanup phase with operator approval "
|
||||
"and exact delete_branch capability proof before cleanup mutations"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
|
||||
"""Fail closed when cleanup execution is attempted without authorization."""
|
||||
phase = current_phase()
|
||||
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
|
||||
return True, []
|
||||
if phase is None:
|
||||
return False, [
|
||||
"cleanup execution requires an active reconciliation session; "
|
||||
"resolve a reconciliation audit task first"
|
||||
]
|
||||
return False, [
|
||||
"cleanup execution forbidden in audit-only reconciliation mode; "
|
||||
"call gitea_authorize_reconciliation_cleanup_phase with operator "
|
||||
"approval, delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot"
|
||||
]
|
||||
|
||||
|
||||
def classify_cleanup_mutation(action: str) -> str:
|
||||
"""Map a cleanup action to the required mutation ledger category (#419)."""
|
||||
normalized = (action or "").strip().lower()
|
||||
if "delete_remote" in normalized or normalized in {
|
||||
"delete_branch",
|
||||
"gitea_delete_branch",
|
||||
}:
|
||||
return "external-state"
|
||||
if "branch" in normalized and "delete" in normalized:
|
||||
return "git-ref"
|
||||
if "worktree" in normalized or "remove_local" in normalized:
|
||||
return "cleanup"
|
||||
return "cleanup"
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
|
||||
"""Validate audit/cleanup reconciliation reports (fail closed)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
|
||||
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
|
||||
|
||||
if cleanup_occurred and claims_no_mutations:
|
||||
reasons.append(
|
||||
"report claims no mutations but documents cleanup mutations; "
|
||||
"audit-only reports must not perform cleanup and cleanup reports "
|
||||
"must not claim no mutations"
|
||||
)
|
||||
|
||||
if cleanup_occurred:
|
||||
if not _CLEANUP_PHASE_AUTH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without "
|
||||
"'Cleanup phase authorized: true'"
|
||||
)
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without delete_branch capability "
|
||||
"proof"
|
||||
)
|
||||
if not _BEFORE_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without before/after state snapshot"
|
||||
)
|
||||
if not _SAFETY_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without branch/worktree safety proof"
|
||||
)
|
||||
|
||||
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
|
||||
if not _EXTERNAL_STATE_RE.search(text):
|
||||
reasons.append(
|
||||
"remote branch deletion must be classified under "
|
||||
"External-state mutations"
|
||||
)
|
||||
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
|
||||
if not _GIT_REF_RE.search(text):
|
||||
reasons.append(
|
||||
"local branch deletion must be classified under "
|
||||
"Git ref mutations"
|
||||
)
|
||||
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
|
||||
if not _CLEANUP_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"worktree removal must be classified under Cleanup mutations"
|
||||
)
|
||||
|
||||
if (
|
||||
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
|
||||
or "reconcile-landed-pr" in text.lower()
|
||||
):
|
||||
if cleanup_occurred and "audit phase" in text.lower():
|
||||
if "cleanup phase" not in text.lower():
|
||||
reasons.append(
|
||||
"report mixes audit phase with cleanup mutations without "
|
||||
"documenting cleanup phase transition"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"cleanup_occurred": cleanup_occurred,
|
||||
"claims_no_mutations": claims_no_mutations,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "fix audit/cleanup report: separate audit from cleanup phase, "
|
||||
"classify mutations, and do not claim no mutations after cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
|
||||
"""Block shell commands that perform cleanup during audit phase."""
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
cmd = (command or "").strip()
|
||||
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
|
||||
return False, [
|
||||
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
|
||||
"authorize cleanup phase before branch/worktree deletion or push"
|
||||
]
|
||||
return True, []
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
project's ``branches/`` directory, never from the stable control checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(os.path.realpath(project_root))
|
||||
real = _normalize_path(os.path.realpath(path))
|
||||
if real.startswith(f"{root}/"):
|
||||
rel = real[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def resolve_mutation_workspace(
|
||||
worktree_path: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text))
|
||||
return os.path.realpath(project_root)
|
||||
|
||||
|
||||
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
|
||||
raw = (common_dir or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
if os.path.isabs(raw):
|
||||
return os.path.realpath(raw)
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
|
||||
workspace = resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
process_project_root,
|
||||
active_worktree_env=active_worktree_env,
|
||||
author_worktree_env=author_worktree_env,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
# Canonical repository identity comes from the MCP process checkout (#460),
|
||||
# not from the declared task workspace being validated.
|
||||
canonical_root = resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_workspace_repo_membership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not os.path.exists(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' does not exist")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
if not os.path.isdir(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' is not a directory")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common_dir = _realpath_git_common_dir(workspace, res.stdout)
|
||||
except Exception:
|
||||
reasons.append(f"worktree '{workspace}' is not a valid git repository")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
expected_dir = os.path.realpath(os.path.join(root, ".git"))
|
||||
if common_dir != expected_dir:
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' does not belong to the target repository '{root}'"
|
||||
)
|
||||
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
|
||||
|
||||
|
||||
def _membership_assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
workspace: str,
|
||||
root: str,
|
||||
common_dir: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"workspace_path": workspace,
|
||||
"canonical_repo_root": root,
|
||||
"git_common_dir": common_dir,
|
||||
}
|
||||
|
||||
|
||||
def format_workspace_repo_membership_error(assessment: dict) -> str:
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
|
||||
f"canonical repository root: {root}; workspace: {workspace}."
|
||||
)
|
||||
|
||||
|
||||
def assess_author_mutation_worktree(
|
||||
*,
|
||||
workspace_path: str,
|
||||
project_root: str,
|
||||
current_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when author mutations are not rooted under ``branches/``."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(project_root)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
branch = (current_branch or "").strip()
|
||||
|
||||
under_branches = is_path_under_branches(workspace, root)
|
||||
if not under_branches:
|
||||
if workspace == root:
|
||||
reasons.append(
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"create or switch to a session-owned worktree under branches/"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"author mutation blocked: workspace '{workspace}' is not under "
|
||||
f"'{root}/branches/'; create a branches/<task> worktree first"
|
||||
)
|
||||
|
||||
if not under_branches and workspace == root and branch and branch not in bases:
|
||||
reasons.append(
|
||||
f"control checkout drift: branch '{branch}' is not a stable base "
|
||||
f"branch ({'/'.join(sorted(bases))})"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"project_root": root,
|
||||
"workspace_path": workspace,
|
||||
"under_branches": is_path_under_branches(workspace, root),
|
||||
"current_branch": branch or None,
|
||||
}
|
||||
|
||||
|
||||
def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("project_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
"""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 "",
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
"""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"],
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -13,8 +13,6 @@ REVIEWER_CAPABILITY_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
@@ -55,28 +53,6 @@ def enter_from_capability_result(capability: dict) -> dict | None:
|
||||
return dict(record)
|
||||
|
||||
|
||||
def _is_reviewer_denial(capability: dict) -> bool:
|
||||
task = (capability or {}).get("requested_task", "")
|
||||
required_role = (capability or {}).get("required_role_kind")
|
||||
return (
|
||||
required_role == "reviewer"
|
||||
or task in REVIEWER_CAPABILITY_TASKS
|
||||
)
|
||||
|
||||
|
||||
def sync_from_capability_result(capability: dict) -> dict | None:
|
||||
"""Enter or clear terminal mode from a capability resolution (#238).
|
||||
|
||||
Reviewer denials activate terminal mode for the denied operation only.
|
||||
A later allowed task route clears stale denial state so author read-only
|
||||
tools (e.g. ``list_prs``) are not permanently blocked.
|
||||
"""
|
||||
if (capability or {}).get("stop_required") and _is_reviewer_denial(capability):
|
||||
return enter_from_capability_result(capability)
|
||||
clear()
|
||||
return None
|
||||
|
||||
|
||||
def enter_from_route_result(route: dict) -> dict | None:
|
||||
"""Enter terminal mode from a role router wrong_role_stop (#206 compat)."""
|
||||
if (route or {}).get("route_result") != "wrong_role_stop":
|
||||
@@ -114,13 +90,11 @@ def check_reviewer_queue_tool(tool_name: str) -> tuple[bool, list[str]]:
|
||||
return True, []
|
||||
name = (tool_name or "").strip().lower().removeprefix("gitea_")
|
||||
if name in BLOCKED_QUEUE_TOOLS:
|
||||
denied_task = (_session_terminal or {}).get("requested_task") or "unknown"
|
||||
return False, [
|
||||
TERMINAL_REPORT_HEADING,
|
||||
f"Reviewer queue tool '{tool_name}' is blocked by the current "
|
||||
f"capability denial for task '{denied_task}' (fail closed).",
|
||||
"Resolve or route an allowed author task to clear stale denial "
|
||||
"state, or relaunch a reviewer MCP namespace for reviewer work.",
|
||||
f"Reviewer queue tool '{tool_name}' is blocked after "
|
||||
"capability denial (fail closed).",
|
||||
"Relaunch a reviewer MCP namespace to perform reviewer work.",
|
||||
]
|
||||
return True, []
|
||||
|
||||
@@ -174,14 +148,8 @@ def assess_capability_stop_report(
|
||||
r"inventory empty",
|
||||
re.I,
|
||||
)
|
||||
parsed_status = None
|
||||
for line in text.splitlines():
|
||||
if "pr_inventory_trust_gate.status:" in line.lower():
|
||||
parsed_status = line.split(":", 1)[1].strip()
|
||||
break
|
||||
effective_status = trust_gate_status or parsed_status
|
||||
if empty_queue_patterns.search(text):
|
||||
if effective_status != "trusted_empty":
|
||||
if trust_gate_status != "trusted_empty":
|
||||
violations.append(
|
||||
"empty-queue claim after capability stop without "
|
||||
"pr_inventory_trust_gate.status == trusted_empty"
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"""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
|
||||
),
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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 "",
|
||||
}
|
||||
+1
-53
@@ -29,7 +29,6 @@ from gitea_auth import (
|
||||
get_credentials, resolve_remote, add_remote_args,
|
||||
api_request, repo_api_url,
|
||||
)
|
||||
import issue_workflow_labels
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -39,16 +38,6 @@ def main(argv=None):
|
||||
parser.add_argument("--body", default="", help="Issue body text.")
|
||||
parser.add_argument("--body-file",
|
||||
help="Read issue body from this file ('-' for stdin).")
|
||||
parser.add_argument("--label", action="append", default=[],
|
||||
help="Existing label name to apply; may be repeated.")
|
||||
parser.add_argument("--type-label",
|
||||
help="Issue type, e.g. feature or type:feature.")
|
||||
parser.add_argument("--status-label",
|
||||
help="Initial status, e.g. ready or status:ready.")
|
||||
parser.add_argument("--discussion", action="store_true",
|
||||
help="Apply type:discussion.")
|
||||
parser.add_argument("--require-workflow-labels", action="store_true",
|
||||
help="Fail unless one type:* and one status:* label are supplied.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
host, org, repo = resolve_remote(args)
|
||||
@@ -61,24 +50,6 @@ def main(argv=None):
|
||||
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=args.type_label,
|
||||
initial_status=args.status_label,
|
||||
extra_labels=args.label,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
if args.require_workflow_labels and not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label validation failed: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
user, password = get_credentials(host)
|
||||
if not user or not password:
|
||||
print(f"Could not get credentials for {host} "
|
||||
@@ -88,34 +59,11 @@ def main(argv=None):
|
||||
|
||||
import base64
|
||||
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
||||
base = repo_api_url(host, org, repo)
|
||||
url = f"{base}/issues"
|
||||
url = f"{repo_api_url(host, org, repo)}/issues"
|
||||
|
||||
try:
|
||||
label_ids = []
|
||||
if requested_labels:
|
||||
existing = api_request("GET", f"{base}/labels", auth)
|
||||
by_name = {lb["name"]: lb["id"] for lb in existing}
|
||||
missing = [name for name in requested_labels if name not in by_name]
|
||||
if missing:
|
||||
print(f"Missing labels: {missing}", file=sys.stderr)
|
||||
return 1
|
||||
label_ids = [by_name[name] for name in requested_labels]
|
||||
data = api_request("POST", url, auth, {"title": args.title, "body": body})
|
||||
if label_ids:
|
||||
api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{data.get('number')}/labels",
|
||||
auth,
|
||||
{"labels": label_ids},
|
||||
)
|
||||
print(f"Issue #{data.get('number')}: {data.get('html_url')}")
|
||||
if not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label recommendation: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
# GlitchTip-to-Gitea Issue Filing Workflow Contract
|
||||
# GlitchTip-to-Gitea Issue Filing Workflow Design
|
||||
|
||||
- **Status:** Contract for the library-only implementation in
|
||||
`Scaled-Tech-Consulting/mcp-control-plane` (#57)
|
||||
- **Issue:** #153 (supersedes #74/#78 as the Gitea-Tools tracker)
|
||||
- **Status:** Design (no implementation in this repo)
|
||||
- **Issue:** #74 (parent umbrella: #75)
|
||||
- **Related:** #78 (deduplication design, child of #74)
|
||||
- **Date:** 2026-07-07
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
## 1. Boundary and Orchestration
|
||||
|
||||
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
|
||||
* The filing capability lives in a **library-only orchestrator** in
|
||||
`mcp-control-plane` and is not exposed through `glitchtip-mcp`.
|
||||
* The orchestrator composes the real GlitchTip read path with Gitea
|
||||
issue-write tools. It must not use mocked GlitchTip issue data in production
|
||||
filing paths.
|
||||
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
|
||||
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
|
||||
* If a future MCP surface exposes filing, it must be a separate write-boundary
|
||||
server/profile with explicit Gitea issue-write permission and the same audit
|
||||
gates. It must not be added to the read-only `glitchtip-mcp` surface.
|
||||
|
||||
## 2. Invocation and Safety
|
||||
|
||||
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
|
||||
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
|
||||
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation
|
||||
relies on `gitea-mcp`, and therefore must pass Gitea profile checks and
|
||||
fail-closed mutation audit before create/link/comment actions.
|
||||
* **Deduplication before create:** The orchestrator must run the
|
||||
GlitchTip-to-Gitea dedup/linking logic before any Gitea create action. Create,
|
||||
link, and skip decisions must be represented in tests.
|
||||
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
|
||||
|
||||
## 3. Gitea Issue Format
|
||||
|
||||
@@ -64,23 +51,6 @@ To prevent PII or secret leakage into Gitea, the orchestrator and the underlying
|
||||
|
||||
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
|
||||
|
||||
## 5. Deduplication and Linking
|
||||
## 5. Deduplication and Linking (Deferred)
|
||||
|
||||
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip
|
||||
issue IDs, and race condition handling) is integrated into the library-only
|
||||
filing orchestrator in `mcp-control-plane`. The orchestrator must reuse that
|
||||
dedup/linking path instead of duplicating or bypassing it.
|
||||
|
||||
## 6. Gitea-Tools Acceptance Contract for #153
|
||||
|
||||
Gitea-Tools does not host the filing implementation. This repository owns the
|
||||
operator-facing contract:
|
||||
|
||||
* `glitchtip-mcp` remains read-only and exposes only GlitchTip inspection tools.
|
||||
* Filing is implemented in `mcp-control-plane`, not in this Gitea MCP runtime.
|
||||
* Filing uses the real GlitchTip read path, not mocked issue data.
|
||||
* Dedup runs before any Gitea create action.
|
||||
* Create/link/skip decisions and audit failure are covered by orchestrator tests.
|
||||
* Mutation audit fails closed before any Gitea create, link, or comment mutation.
|
||||
* Any future MCP exposure requires a separate write-boundary server/profile and
|
||||
must not add Gitea write credentials to `glitchtip-mcp`.
|
||||
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The read server boundary remains read-only; gated triggers live on the separate `jenkins-write-mcp` / `jenkins_mcp.write_server` boundary (see #56 / #152).
|
||||
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The server boundary now contains gated trigger (see #56), but read tools remain as designed.
|
||||
Client registration and reload instructions live in
|
||||
[`../mcp-client-registration.md`](../mcp-client-registration.md).
|
||||
|
||||
@@ -18,9 +18,7 @@ detail (build URL, number, timing, result) to report or investigate.
|
||||
Phase 1 is **primarily read-only**, per ADR-0001
|
||||
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
|
||||
|
||||
- Build triggers are outside this read-only surface and require the separate
|
||||
`jenkins-write-mcp` boundary, a dedicated profile, exact confirmation, and
|
||||
fail-closed mutation audit (landed in #4, boundary correction in #56 / #152).
|
||||
- Build triggers are gated behind dedicated profile + exact confirmation (landed in #4, boundary correction in #56).
|
||||
- **Excluded: deploy triggers.**
|
||||
- **Excluded: parameterized job launches.**
|
||||
- Excluded: job creation/deletion/config changes, queue manipulation, node
|
||||
@@ -111,7 +109,7 @@ by #76):
|
||||
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
|
||||
as belt-and-braces even though no mutating tool exists.
|
||||
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
|
||||
- Since every tool on `jenkins-mcp` is read-only, no confirmation gates are needed — but
|
||||
- Since every tool is read-only, no confirmation gates are needed — but
|
||||
identity (`jenkins_whoami`) must still work so workflows can prove which
|
||||
Jenkins account they act as.
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# 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
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,142 +0,0 @@
|
||||
# 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
|
||||
@@ -13,25 +13,6 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||
|
||||
## 1. Standard test commands
|
||||
|
||||
### Canonical runner: `./run-tests.sh`
|
||||
|
||||
The canonical full-validation command is the root-level runner. It invokes the
|
||||
project virtualenv interpreter and passes any extra arguments straight through
|
||||
to `pytest`:
|
||||
|
||||
```bash
|
||||
# Full validation
|
||||
./run-tests.sh
|
||||
|
||||
# Focused validation (extra args forward to pytest)
|
||||
./run-tests.sh tests/test_mcp_server.py -q
|
||||
```
|
||||
|
||||
`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
|
||||
setup message if the virtualenv Python is missing (so a session never silently
|
||||
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
|
||||
forms below remain valid and equivalent.
|
||||
|
||||
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||
|
||||
```bash
|
||||
@@ -54,25 +35,6 @@ Use `-q` for a compact summary and `-v` to see individual test names.
|
||||
./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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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,11 +22,7 @@ launched with exactly one static execution profile:
|
||||
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||
|-----------------------------|----------------|-------------|
|
||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes |
|
||||
| `gitea-merger` | a merger profile | merge PRs after approval and verification |
|
||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
||||
|
||||
Properties:
|
||||
|
||||
@@ -109,14 +105,6 @@ syntax to the client):
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||
}
|
||||
},
|
||||
"gitea-merger": {
|
||||
"command": "<path-to>/venv/bin/python3",
|
||||
"args": ["<path-to>/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<merger-profile-name>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,31 +226,6 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever
|
||||
resolves as unknown, agents must fail closed rather than fall back to the
|
||||
edit path.
|
||||
|
||||
## Reconciler profile for already-landed open PRs (#304 / #310)
|
||||
|
||||
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
|
||||
authority. Already-landed open PRs (head SHA is an ancestor of the target
|
||||
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
|
||||
narrow operation set:
|
||||
|
||||
- `gitea.read`
|
||||
- `gitea.pr.comment`
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`gitea.repo.commit`.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
|
||||
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||
heads are not already landed cannot be closed through this path.
|
||||
|
||||
## Identity and fail-closed rules
|
||||
|
||||
Before **any** mutating action, a workflow must know both:
|
||||
@@ -311,8 +286,7 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo
|
||||
### 2. Dual MCP Namespaces Recommendation
|
||||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. 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.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||
This is the model accepted in #139; deployment details, rationale, and client
|
||||
setup live in
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Controller Issue-Acceptance Gate
|
||||
|
||||
A merged PR does not automatically prove an issue is fully satisfied. After
|
||||
merge, a controller must audit the linked issue against its acceptance criteria
|
||||
and post a durable handoff before the issue is treated as complete.
|
||||
|
||||
## Workflow position
|
||||
|
||||
1. Author implements the issue and opens a PR.
|
||||
2. Reviewer reviews the PR.
|
||||
3. Merger merges the approved PR.
|
||||
4. **Controller performs issue-acceptance audit.**
|
||||
5. Controller posts a `## Controller Issue Acceptance` comment with either:
|
||||
- `STATE: accepted` and checked criteria, or
|
||||
- a rejection path (`more-work-required`, `needs-tests`, `needs-docs`, etc.)
|
||||
with `MISSING_WORK` and a paste-ready `NEXT_PROMPT`.
|
||||
|
||||
Gitea may auto-close an issue via `Closes #N` in the PR body. That closure is
|
||||
merge mechanics only. Controller acceptance is still required before any final
|
||||
report or queue controller treats the issue as complete.
|
||||
|
||||
## Template
|
||||
|
||||
Use `issue_acceptance_gate.render_controller_acceptance_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/controller-issue-acceptance.md`](../skills/llm-project-workflow/templates/controller-issue-acceptance.md).
|
||||
|
||||
## Final-report rules
|
||||
|
||||
Final reports must not claim `issue complete` solely because a PR merged.
|
||||
Either:
|
||||
|
||||
- include a valid `## Controller Issue Acceptance` block with
|
||||
`STATE: accepted`, or
|
||||
- explicitly state `controller acceptance pending` and identify the controller
|
||||
as the next actor.
|
||||
|
||||
`final_report_validator` enforces this through
|
||||
`issue_acceptance_gate.validate_final_report_issue_acceptance()`.
|
||||
|
||||
## Role boundaries
|
||||
|
||||
- Authors must not mark their own issues accepted.
|
||||
- Reviewers must not mark issue acceptance unless acting under controller
|
||||
capability.
|
||||
- Mergers merge PRs; they do not substitute for controller acceptance.
|
||||
|
||||
## Related
|
||||
|
||||
- #495 — canonical next-action comment templates
|
||||
- #496 — fail-closed canonical comment validation before posting
|
||||
- #303 — controller handoff schema for reconciliation workflows
|
||||
+24
-88
@@ -1,98 +1,34 @@
|
||||
# Label Taxonomy
|
||||
|
||||
This document defines the canonical issue labels used by MCP workflows.
|
||||
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
||||
|
||||
Every issue should carry:
|
||||
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
||||
|
||||
- one `type:*` label
|
||||
- one `status:*` label
|
||||
## Existing Labels
|
||||
|
||||
Discussion-only issues must carry `type:discussion`.
|
||||
* **`jenkins`**
|
||||
* Description: Jenkins integration
|
||||
* Color: `d93f0b`
|
||||
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
||||
|
||||
## Issue Type Labels
|
||||
* **`glitchtip`**
|
||||
* Description: GlitchTip integration
|
||||
* Color: `b60205`
|
||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
||||
|
||||
| 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 |
|
||||
## Proposed / Missing Labels
|
||||
|
||||
## Workflow Status Labels
|
||||
* **`observability`**
|
||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
||||
* Proposed Color: `5319e7`
|
||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
||||
|
||||
Only one `status:*` label should be active on an issue at a time. When an issue
|
||||
moves forward, tooling must remove the old `status:*` label and apply the new
|
||||
one.
|
||||
* **`source:glitchtip`**
|
||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
||||
* Proposed Color: `b60205`
|
||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
||||
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `status:triage` | Issue needs triage |
|
||||
| `status:ready` | Issue is ready for work |
|
||||
| `status:claimed` | Issue is claimed |
|
||||
| `status:in-progress` | Issue is being worked on |
|
||||
| `status:blocked` | Issue is blocked |
|
||||
| `status:needs-review` | Issue work needs review |
|
||||
| `status:pr-open` | A linked PR is open |
|
||||
| `status:approved` | Linked PR is approved |
|
||||
| `status:merged` | Linked PR is merged |
|
||||
| `status:reconcile` | Issue needs reconciliation |
|
||||
| `status:done` | Issue workflow is complete |
|
||||
| `status:duplicate` | Issue is a duplicate |
|
||||
| `status:wontfix` | Issue will not be fixed |
|
||||
|
||||
## Transition Rules
|
||||
|
||||
Suggested lifecycle:
|
||||
|
||||
1. New issue created: `status:triage` or `status:ready`
|
||||
2. Issue selected by an author: `status:claimed`
|
||||
3. Author starts work: `status:in-progress`
|
||||
4. Work is blocked: `status:blocked`
|
||||
5. PR opened: `status:pr-open`
|
||||
6. PR approved: `status:approved`
|
||||
7. PR merged but issue still needs closure/reconciliation: `status:reconcile`
|
||||
8. Issue fully complete: `status:done`
|
||||
9. Duplicate issue: `status:duplicate`
|
||||
10. Won't-fix issue: `status:wontfix`
|
||||
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
|
||||
A discussion issue should not be treated as implementation-ready unless it also
|
||||
has a clear implementation status and next action.
|
||||
|
||||
If a discussion produces implementation work, either:
|
||||
|
||||
1. convert the discussion issue into an implementation issue by changing labels
|
||||
and adding acceptance criteria, or
|
||||
2. create child implementation issues and leave the discussion issue as
|
||||
`type:discussion`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `manage_labels.py --create-labels` creates the canonical `type:*` and
|
||||
`status:*` labels.
|
||||
- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing
|
||||
and can apply supplied label names.
|
||||
- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with
|
||||
`status:in-progress`.
|
||||
- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`,
|
||||
`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical
|
||||
labels. They do not replace the required `type:*` and `status:*` labels.
|
||||
* **`status:triage`**
|
||||
* Proposed Description: Issue needs human or orchestrator triage
|
||||
* Proposed Color: `fbca04`
|
||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
||||
|
||||
+10
-702
@@ -7,11 +7,6 @@ package of the MCP Control Plane: creating issues, implementing them, opening
|
||||
and reviewing pull requests, merging, and closing out — safely and
|
||||
reproducibly.
|
||||
|
||||
Canonical state comments for durable issue/PR/discussion continuation are
|
||||
documented in [`canonical-state-comments.md`](canonical-state-comments.md).
|
||||
Use them when a workflow-changing comment needs to leave the next actor, next
|
||||
action, and paste-ready prompt in Gitea.
|
||||
|
||||
> For the **project-agnostic** version of these operating rules (issue-first,
|
||||
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
||||
> that can be copied into any repository, see the reusable skill
|
||||
@@ -33,8 +28,6 @@ audit logging). See [Related documents](#related-documents).
|
||||
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
|
||||
> for step-by-step instructions. This replaces long pasted operator prompts for
|
||||
> the standard rules; operator prompts still control task-specific scope.
|
||||
>
|
||||
> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop.
|
||||
> See issue #129 for the skill registry design.
|
||||
|
||||
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
|
||||
@@ -176,25 +169,6 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
||||
* `mcp__gitea-reviewer__*` (for reviewing PRs, approving, requesting changes, merging)
|
||||
* **Trust Model:** Separate tokens remain separate in the keychain/environment. Each instance operates under its own `GITEA_MCP_PROFILE` and enforces its own `allowed_operations`. A runtime `whoami` identity check is still performed independently, and self-review/self-merge checks remain strictly mandatory. The dual-server pattern is a operational convenience and never a security bypass.
|
||||
* **Reviewer-Identity PR Creation Deadlock:** Reviewer/merge identities must not create PRs or push branches. Doing so makes the reviewer identity the PR author in Gitea, blocking subsequent independent review and causing a review deadlock. Normally, PRs must be created by the author/work identity (`gitea-author`), leaving the reviewer identity (`gitea-reviewer`) clean and available for independent review and merge.
|
||||
* **Reconciler namespace (#310):** Register a third static instance for
|
||||
already-landed PR cleanup when review queues block on open PRs whose heads
|
||||
already landed on `master`:
|
||||
|
||||
```json
|
||||
"gitea-reconciler": {
|
||||
"command": "/path/to/Gitea-Tools/venv/bin/python3",
|
||||
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
|
||||
"GITEA_MCP_PROFILE": "prgs-reconciler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The reconciler profile grants `gitea.pr.close` only for
|
||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||
review or author workflows.
|
||||
|
||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||
|
||||
## Setup runbook — interactive menu
|
||||
@@ -255,273 +229,6 @@ Legacy environment-only setups keep working unchanged until migrated.
|
||||
Each runbook names the **profile role** it runs under, the steps, and a safe
|
||||
prompt. Confirm the active profile first (`gitea_get_profile` / `gitea_whoami`).
|
||||
|
||||
## Work Selection Rule for LLMs
|
||||
|
||||
Before starting any issue or PR work, acquire or verify a work lease. Do not
|
||||
begin coding, reviewing, fixing, branching, committing, pushing, commenting,
|
||||
or creating a PR until you prove the target is not already being worked.
|
||||
|
||||
Required checks:
|
||||
|
||||
1. List open PRs.
|
||||
2. Search for PRs linked to the target issue.
|
||||
3. Search local and remote branches for the issue number.
|
||||
4. Search registered worktrees for the issue branch.
|
||||
5. Check dirty worktrees.
|
||||
6. Check active leases or recent handoffs.
|
||||
7. Check whether the issue was already completed by a merged PR.
|
||||
|
||||
If another active LLM/session owns the lease, stop. Allowed responses:
|
||||
continue as the lease owner; review the existing PR if reviewer capability
|
||||
allows; produce a handoff; request takeover after lease expiry; stop with
|
||||
"work already claimed."
|
||||
|
||||
Never create a parallel branch or PR for the same issue unless the old branch
|
||||
is proven abandoned and the takeover is recorded.
|
||||
|
||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
records an `author_issue_work` lease in a keyed lock file under
|
||||
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
|
||||
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
|
||||
its active lock through a per-process pointer so concurrent repos/issues never
|
||||
share one overwrite-prone slot (#443).
|
||||
|
||||
Each lock payload includes issue number, optional PR number, branch, worktree
|
||||
path, claimant identity/profile, created timestamp, expiry timestamp, and last
|
||||
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
||||
work. An expired lease still blocks takeover until a recovery review records why
|
||||
the prior work is abandoned, completed, or unsafe to continue.
|
||||
|
||||
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
|
||||
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
|
||||
based on another unmerged PR's branch) is an explicit, opt-in path: pass
|
||||
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
|
||||
fails closed unless that branch is owned by a live **open** PR whose number
|
||||
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
|
||||
bases. When approved, the lock payload records
|
||||
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
|
||||
be base-equivalent to that branch instead of master. `gitea_create_pr` then
|
||||
allows `base = <that branch>` only when it matches the recorded approval, the
|
||||
dependency PR is **still open**, and the PR body documents the stack:
|
||||
|
||||
- `Stacked on PR #<X> / issue #<Y>`
|
||||
- `Base branch: <feature-branch>`
|
||||
- `Head branch: <this-issue-branch>`
|
||||
- `Do not merge before PR #<X>` (merge ordering)
|
||||
- retarget/rebase to `master` after the dependency lands, if required
|
||||
|
||||
Stacked support never bypasses the issue lock — the base is recorded *on* the
|
||||
lock and re-verified at PR time. A merged/closed dependency base fails closed;
|
||||
retarget onto `master` or re-lock against a live base.
|
||||
|
||||
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
|
||||
recovery path.** That global slot is deprecated and can clobber unrelated live
|
||||
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||
adoption rebinds the session when the issue's exact branch already exists (#442).
|
||||
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
|
||||
matching `head` branch without unsafe manual seeding.
|
||||
|
||||
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
|
||||
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
|
||||
shared state and manual writes can clobber another session's live lease. Use
|
||||
`sanctioned recovery` instead:
|
||||
|
||||
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
|
||||
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
|
||||
3. Operator override only when explicitly authorized — record
|
||||
`External-state mutations` and `operator override proof` in the final report.
|
||||
|
||||
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
|
||||
adopts an existing own branch, the response carries an `adoption` block with
|
||||
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
|
||||
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
|
||||
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
|
||||
lock instead returns an `adoption_check` block with `adoption_decision`
|
||||
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
|
||||
as claiming adoption. Recovery reports should quote the live lock response
|
||||
`adoption`/`adoption_check` block directly instead of inferring adoption from
|
||||
separate offline checks.
|
||||
|
||||
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||
approval in one run. See also #438 (global lock redesign).
|
||||
|
||||
Remote branches matching the issue number are also treated as active work unless
|
||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
||||
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
||||
the only copy of unmerged work.
|
||||
|
||||
Full portable wording:
|
||||
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
## Global LLM Worktree Rule
|
||||
|
||||
The main project checkout is a stable control checkout. It must stay on the
|
||||
configured stable branch: `master`, `main`, or `dev`.
|
||||
|
||||
All LLM task work must happen inside the project's `branches/` directory.
|
||||
|
||||
Before any mutation, prove:
|
||||
|
||||
1. current project root
|
||||
2. current working directory
|
||||
3. current branch
|
||||
4. stable branch for the main checkout
|
||||
5. session-owned worktree path under `branches/`
|
||||
|
||||
If `cwd` is not inside `branches/`, stop. Do not edit, create, delete, format,
|
||||
test-write, commit, merge, rebase, checkout task branches, resolve conflicts,
|
||||
or run cleanup.
|
||||
|
||||
There are no exceptions for small fixes, docs, tests, cleanup, PR review fixes,
|
||||
conflict resolution, or emergencies.
|
||||
|
||||
The main checkout may only be used for read-only inspection, fetching,
|
||||
stable-branch update after merged PRs, creating `branches/` worktrees, or
|
||||
explicit control-checkout repair.
|
||||
|
||||
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
### Root checkout guard (#475)
|
||||
|
||||
The MCP server enforces a fail-closed **root checkout guard** before author,
|
||||
reviewer, and merger mutations when the active workspace is not an isolated
|
||||
`branches/...` worktree (reconciler close paths remain exempt per #468).
|
||||
|
||||
The guard blocks when the **control checkout** (repository root) is:
|
||||
|
||||
- on a non-stable branch (`master` / `main` / `dev` expected),
|
||||
- detached HEAD,
|
||||
- dirty (tracked edits),
|
||||
- or its `HEAD` does not match `prgs/master` when that ref is available.
|
||||
|
||||
**Remediation (never auto-reset or stash):**
|
||||
|
||||
> Root checkout is not on master. Preserve state, switch root back to master,
|
||||
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
|
||||
|
||||
**Recovery after root hijack:**
|
||||
|
||||
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
|
||||
rescue branch from a `branches/...` worktree).
|
||||
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
|
||||
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
|
||||
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
|
||||
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
|
||||
`git worktree add`.
|
||||
|
||||
`branches/...` directories are disposable role worktrees; the root checkout is
|
||||
the stable orchestration surface only.
|
||||
|
||||
## Canonical workflow skill names (#551)
|
||||
|
||||
Controller prompts and sessions must load the **same** workflow skill wall
|
||||
regardless of runtime (Claude, Codex, Gemini):
|
||||
|
||||
| Name | Role |
|
||||
|------|------|
|
||||
| `gitea-workflow` | Primary controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo package |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
- Inventory: `mcp_list_project_skills` lists all three.
|
||||
- Preflight: `mcp_check_workflow_skill_preflight` before mutations.
|
||||
- Codex install: `scripts/install-codex-workflow-skill.sh`
|
||||
- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md)
|
||||
|
||||
If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate.
|
||||
|
||||
## No direct-import mutation path (#558)
|
||||
|
||||
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
|
||||
keychain fill from a raw shell to bypass MCP preflight.
|
||||
|
||||
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
|
||||
|
||||
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||
|
||||
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||
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
|
||||
|
||||
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
||||
That is an executor spawn failure, not a command failure — the command never
|
||||
ran, and retrying the identical call cannot succeed.
|
||||
|
||||
Required behavior (fail closed, issue #258):
|
||||
|
||||
1. **Probe once.** On the first spawn failure, run one trivial probe
|
||||
(`echo ok` or `pwd`). If the probe also returns `exit_code: -1`, mark
|
||||
shell unavailable for the session.
|
||||
2. **Hard-stop at two.** After two consecutive spawn failures, stop all
|
||||
further shell tool use for the session; never retry the same failing
|
||||
spawn. A hundred retries produce a hundred identical failures (session
|
||||
`019f382e`: 100+ tool calls stalled on a trivial encode-and-commit task).
|
||||
3. **Emit a recovery report.** The report must direct the operator to:
|
||||
- restart the session,
|
||||
- kill hung background terminals (a hung test runner holding the
|
||||
executor is a known contributor),
|
||||
- prefer MCP-native paths for remaining mutations (for example
|
||||
`gitea_commit_files` under `gitea.repo.commit`) instead of shell.
|
||||
4. **No improvised fallbacks.** Shell unavailability never authorizes
|
||||
WebFetch/browser/manual-encoding workarounds (see #260). No shell means
|
||||
stop-and-report.
|
||||
|
||||
Doc-contract tests: `tests/test_shell_spawn_hard_stop_docs.py`.
|
||||
|
||||
## Subagent Tool-Budget Guardrails
|
||||
|
||||
General-purpose subagents tasked with **deterministic MCP work** (for example a
|
||||
single `gitea_commit_files` call) have expanded to 100–122 tool calls,
|
||||
WebFetch/Playwright fallbacks, and throwaway helper-script generation instead of
|
||||
calling the native MCP tool once (observed during #152 closure, issue #259).
|
||||
|
||||
**Default budgets** (fail closed when exceeded):
|
||||
|
||||
| Task class | Max tool calls | Max wall time |
|
||||
|------------|----------------|---------------|
|
||||
| Single-step MCP mutation (`commit_files`, `create_pr`, `lock_issue`) | 15 | 5 minutes |
|
||||
| Review / merge queue inspection | 40 | 15 minutes |
|
||||
| Exploration / codebase search (non-mutating) | 60 | 20 minutes |
|
||||
|
||||
**Required behavior:**
|
||||
|
||||
1. **Main session first.** When the active author profile allows
|
||||
`gitea.repo.commit` and `gitea_commit_files` is visible, the main session
|
||||
must call it directly — do not delegate commit authority to a subagent
|
||||
(see #260).
|
||||
2. **Native MCP before fallback.** After a shell spawn failure (#258), attempt
|
||||
the native MCP tool once before any alternate path. Shell unavailability
|
||||
never authorizes WebFetch, Playwright, or manual base64 encoding.
|
||||
3. **No retry spirals.** Never resume a failed subagent into a larger retry
|
||||
loop or spawn a second subagent for the same deterministic step. Stop and
|
||||
emit a recovery report instead.
|
||||
4. **Forbidden detours** when `gitea_commit_files` is available: WebFetch,
|
||||
Playwright/browser automation, manual LLM-generated base64, and ad-hoc
|
||||
`_encode_*` / `_emit_*` helper scripts left in the repo.
|
||||
|
||||
Doc-contract tests: `tests/test_subagent_tool_budget_docs.py`.
|
||||
|
||||
## Branch worktree isolation
|
||||
|
||||
All LLM implementation and review work happens in an isolated branch worktree
|
||||
@@ -553,30 +260,6 @@ may edit another issue's branch folder unless explicitly assigned to that issue.
|
||||
No LLM may clean another issue's branch folder unless the PR is merged or closed
|
||||
and cleanup is explicitly part of the task.
|
||||
|
||||
## Agent temp artifact cleanup (#261)
|
||||
|
||||
Failed or aborted MCP commit attempts sometimes leave throwaway helper scripts in
|
||||
the **repository root**. These are not part of any issue scope and pollute
|
||||
`git status`, which can break `gitea_lock_issue` and preflight checks.
|
||||
|
||||
**Patterns (repo root only, untracked):**
|
||||
|
||||
- `_encode_*.py` — base64 payload encoders
|
||||
- `_emit_*.py` — commit payload emitters
|
||||
- `_inline_*.py` — inline encoding helpers
|
||||
|
||||
**Required cleanup (after MCP commit completes or aborts):**
|
||||
|
||||
1. Delete any matching files at the repo root (`rm ./_encode_*.py` etc.).
|
||||
2. Confirm `git status` is clean on the orchestration checkout before
|
||||
`gitea_lock_issue`.
|
||||
3. Prefer native `gitea_commit_files` / gated commit paths — do not leave shell
|
||||
encoding fallbacks behind.
|
||||
|
||||
Root-level matches are listed in `.gitignore` so they never get committed.
|
||||
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
|
||||
hard blocks) when these artifacts are still present.
|
||||
|
||||
Implementation work and review work must use separate branch folders. For
|
||||
example, an implementation branch might live under
|
||||
`branches/fix-issue-123-example`, while a review branch for the resulting PR
|
||||
@@ -631,74 +314,24 @@ git branch -d fix/issue-123-example
|
||||
All three helpers accept `--dry-run` to print the exact commands/paths without
|
||||
touching anything.
|
||||
|
||||
### MCP-native commit path (#260)
|
||||
|
||||
When the active author profile allows **`gitea.repo.commit`** and
|
||||
**`gitea_commit_files`** is visible in the client, that is the **only** approved
|
||||
path for committing files to the tracked repository. Do not improvise alternate
|
||||
encoding or transport when MCP commit is available.
|
||||
|
||||
**Required before commit:**
|
||||
|
||||
1. Call `gitea_resolve_task_capability` for `commit_files` or
|
||||
`gitea_commit_files` and confirm `allowed_in_current_session` is true.
|
||||
2. Use `gitea_commit_files` with file payloads prepared in the author worktree.
|
||||
3. Stage only issue-scoped paths; never commit throwaway `_encode_*` /
|
||||
`_emit_*` / `_inline_*` helpers.
|
||||
|
||||
**Explicitly forbidden workarounds** when MCP commit is reachable:
|
||||
|
||||
- `WebFetch` / HTTP calls to external decode sites (for example httpbin base64
|
||||
endpoints)
|
||||
- Playwright or other browser automation to bypass MCP
|
||||
- Manual LLM-generated base64 pasted into ad-hoc scripts
|
||||
- Delegating commit authority to a subagent while the main session has
|
||||
`gitea.repo.commit` on an author profile
|
||||
|
||||
**If shell encoding is unavailable** (spawn failure, hung terminal) **and** MCP
|
||||
commit cannot run: **stop** with a recovery report. Mention restarting the
|
||||
session, clearing hung background terminals, switching to MCP-native commit, and
|
||||
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
||||
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
|
||||
|
||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
||||
minimal label set; link children to the parent.
|
||||
- **Labels:** new issues should carry one `type:*` label and one `status:*`
|
||||
label. Discussion-only issues must carry `type:discussion`. See
|
||||
[`label-taxonomy.md`](label-taxonomy.md).
|
||||
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
||||
<body>, then create child issues for <list> and link them to the parent.`
|
||||
|
||||
### Implement an issue and open a PR
|
||||
|
||||
- **Profile:** author.
|
||||
- **Steps:** claim the issue (`status:in-progress`, replacing any old
|
||||
`status:*` label); create an isolated branch worktree from latest `master`
|
||||
under `branches/` (`feat/issue-<n>-...` /
|
||||
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
|
||||
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
master. Do not self-review or self-merge.`
|
||||
|
||||
@@ -739,40 +372,13 @@ do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
### Merge a PR
|
||||
|
||||
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
||||
- **Steps:**
|
||||
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
|
||||
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- **Steps:** confirm eligibility; require explicit confirmation
|
||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
||||
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- **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.`
|
||||
|
||||
#### Merger lease adoption (#536)
|
||||
|
||||
When review and merge run in **separate sessions**, the merger must **not**
|
||||
manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state.
|
||||
That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not
|
||||
canonical proof and is rejected by mutation gates.
|
||||
|
||||
**Canonical merger handoff:**
|
||||
|
||||
1. Confirm a reviewer session holds an active PR lease and posted **APPROVED**
|
||||
at the current live head (`gitea_get_pr_review_feedback`).
|
||||
2. In a clean merger worktree under `branches/`, call
|
||||
`gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and
|
||||
optional `issue_number`.
|
||||
3. The tool posts durable adoption proof on the PR thread (`<!-- mcp-review-lease-adoption:v1 -->`)
|
||||
recording actor, profiles, adopted-from session/comment, adoption reason, and
|
||||
timestamp, then records sanctioned in-session provenance.
|
||||
4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`.
|
||||
|
||||
**Forbidden:** Python one-liners or scripts that call `record_session_lease()`
|
||||
without provenance from `gitea_acquire_reviewer_pr_lease`,
|
||||
`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`.
|
||||
|
||||
**Same-session review+merge:** the reviewer session may use
|
||||
`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session
|
||||
merger handoff.
|
||||
|
||||
### Close the issue after merge / Reconciliation
|
||||
|
||||
- **Profile:** issue-manager or merger.
|
||||
@@ -790,53 +396,6 @@ merger handoff.
|
||||
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
||||
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||
|
||||
### Post-merge merged cleanup ownership (#523)
|
||||
|
||||
Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not
|
||||
author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
||||
`gitea_reconcile_merged_cleanups`.
|
||||
|
||||
- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` /
|
||||
`reconciliation_cleanup`).
|
||||
- **Namespace:** reconciler MCP server; stable control checkout is allowed for
|
||||
this role (branches-only author guard does not apply).
|
||||
- **Steps:**
|
||||
1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`.
|
||||
2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`.
|
||||
3. Execute only after audit/authorization gates when remote branch delete or
|
||||
worktree removal is required (`dry_run=False`, `execute_confirmed=True`,
|
||||
and `gitea.branch.delete` when deleting remotes).
|
||||
- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged
|
||||
closed PRs must not be cleaned.
|
||||
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||
|
||||
### 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
|
||||
|
||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||
@@ -857,7 +416,7 @@ an author.
|
||||
|---|---|---|---|---|
|
||||
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
||||
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
||||
| Merge PR (`merge_pr`) | 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 |
|
||||
| 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 |
|
||||
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
||||
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
||||
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
||||
@@ -907,27 +466,6 @@ Never imply full-suite success unless the full-suite command itself passed
|
||||
(`full_suite_passed: true`). A report that hides a failed or skipped check
|
||||
is worse than a failing report.
|
||||
|
||||
## Canonical Thread Handoff (CTH)
|
||||
|
||||
**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff
|
||||
comment in a Gitea issue or PR thread. See
|
||||
[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types,
|
||||
templates, and examples.
|
||||
|
||||
Before acting in an issue or PR thread:
|
||||
|
||||
1. **Find the latest CTH comment** before doing work.
|
||||
2. Treat the **latest valid CTH** as the current handoff state.
|
||||
3. **Post a new CTH** when you finish, block, skip, supersede, request
|
||||
changes, approve, or hand off.
|
||||
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
|
||||
|
||||
A CTH summarizes workflow state for the next session. Formal Gitea review
|
||||
verdicts remain authoritative for merge gates — a CTH is not merge approval
|
||||
by itself.
|
||||
|
||||
Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
|
||||
|
||||
## Controller Handoff (required, every task)
|
||||
|
||||
Every task — implementation, review, merge, triage, documentation,
|
||||
@@ -960,161 +498,6 @@ touched release state names the exact tag/commit and why. Design debates
|
||||
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
||||
on the issue, create no branches/PRs, and end the comment with this handoff.
|
||||
|
||||
## Two-comment workflow reporting (#507)
|
||||
|
||||
After meaningful controller/workflow work, post **two separate Gitea comments**
|
||||
(not one combined blob):
|
||||
|
||||
1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the
|
||||
next LLM/controller (proof-heavy; may be long).
|
||||
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||
|
||||
The ledger must answer: what is true now, what changed, what is blocked,
|
||||
who/what acts next — and must **separate**:
|
||||
|
||||
- local verdict/state
|
||||
- server-side Gitea state
|
||||
- attempted-but-blocked mutations
|
||||
- completed mutations
|
||||
|
||||
Use precise state phrases (`APPROVED review posted to Gitea`,
|
||||
`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`,
|
||||
`no server-side state changed`, `lease attempt blocked`) instead of ambiguous
|
||||
standalone words (`approved`, `merged`, `ready`, `blocked`, `done`).
|
||||
|
||||
The ledger must include a **blocker classification** from:
|
||||
`code blocker`, `test blocker`, `merge conflict`, `stale head`,
|
||||
`permission/capability blocker`, `environment/tooling blocker`,
|
||||
`process/rule blocker`, `queue/lease blocker`,
|
||||
`duplicate/canonicalization blocker`, `no blocker`.
|
||||
|
||||
Templates: [`two-comment-workflow.md`](two-comment-workflow.md).
|
||||
Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md).
|
||||
|
||||
Validation: `thread_state_ledger_validator.py` checks tagged comments at post
|
||||
time (`gitea_create_issue_comment`) and tagged final reports via
|
||||
`assess_final_report_validator`. Legacy `## Controller Handoff` final reports
|
||||
remain valid during transition; the tagged pair is required for new workflow
|
||||
comments.
|
||||
|
||||
Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger
|
||||
consistency, #505 CTH umbrella, #496 workflow comment gate when merged.
|
||||
|
||||
## Canonical comment validation (#496)
|
||||
|
||||
Workflow-changing issue/PR/review comments must carry durable next-action
|
||||
state. Casual discussion is still allowed.
|
||||
|
||||
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
|
||||
**before** posting through:
|
||||
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
|
||||
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
|
||||
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
|
||||
|
||||
Detection examples:
|
||||
|
||||
- **Allowed:** `Thanks, I will check this.`
|
||||
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
|
||||
|
||||
When validation fails, the tool returns `canonical_comment_validation` with
|
||||
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
|
||||
`suggested_template`. **No Gitea API call is made.**
|
||||
|
||||
Minimum workflow comment fields:
|
||||
|
||||
```text
|
||||
STATE:
|
||||
WHO_IS_NEXT:
|
||||
NEXT_ACTION:
|
||||
NEXT_PROMPT:
|
||||
WHY:
|
||||
```
|
||||
|
||||
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
|
||||
`reconciler`, `user`.
|
||||
|
||||
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
|
||||
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
|
||||
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
|
||||
|
||||
Special states:
|
||||
|
||||
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
|
||||
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
|
||||
- `STATE: ready-to-merge` — requires approval proof and head SHA in
|
||||
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
|
||||
|
||||
Final reports must not claim a comment was posted when
|
||||
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||
|
||||
## 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
|
||||
|
||||
Before any mutating action the workflow verifies identity, active profile,
|
||||
@@ -1178,45 +561,6 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Namespace workspace binding (#510)
|
||||
|
||||
Each MCP namespace resolves its **own** active task workspace. Foreign role
|
||||
worktree environment variables must not poison another namespace's purity
|
||||
checks.
|
||||
|
||||
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|
||||
|-----------|------------------------------------------------------------------|---------------|
|
||||
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
|
||||
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
|
||||
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
|
||||
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
|
||||
|
||||
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
|
||||
MCP processes ignore it even when it points at a dirty author WIP tree.
|
||||
|
||||
### Safe reconnect / rebind procedure
|
||||
|
||||
When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
### CTH guidance for workspace binding blockers
|
||||
|
||||
When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
@@ -1226,8 +570,6 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||
- [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision).
|
||||
@@ -1237,37 +579,3 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
|
||||
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
|
||||
- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494).
|
||||
|
||||
## Canonical state handoff ledger (#494)
|
||||
|
||||
Gitea comments and final reports must make continuation obvious without chat
|
||||
history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for:
|
||||
|
||||
- discussion → issue → PR → review → merge → reconcile lifecycle
|
||||
- templates for discussion, issue, PR, and queue-controller state comments
|
||||
- final-report requirements: Current status, Next actor, Next action, Next prompt
|
||||
- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial
|
||||
exceptions)
|
||||
|
||||
Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired
|
||||
through `assess_final_report_validator`.
|
||||
|
||||
## PR-only queue cleanup mode (#390)
|
||||
|
||||
Use `pr-queue-cleanup` mode when the queue holds many open PRs and the goal
|
||||
is to drain reviews deterministically. One run = one PR = one canonical
|
||||
review (`skills/llm-project-workflow/workflows/pr-queue-cleanup.md`).
|
||||
|
||||
* Cleanup runs are reviewer-role only (`pr_queue_cleanup` resolver task);
|
||||
author sessions get `wrong_role_stop`.
|
||||
* Forbidden in cleanup mode: issue claiming, branch creation, implementation
|
||||
edits, new issue filing, and any second-PR review after a terminal
|
||||
mutation.
|
||||
* Terminal chain: stop after `REQUEST_CHANGES`; after `APPROVED` continue
|
||||
only to same-PR merge with explicit per-PR operator authorization and
|
||||
passing gates; stop after merge or merge blocker.
|
||||
* Every run reports the next suggested PR without continuing to it; the next
|
||||
PR requires a fresh run with fresh identity/capability/inventory proof.
|
||||
* Use full `review-merge-pr` mode instead when the operator wants a single
|
||||
targeted review, and `work-issue` mode for any authoring.
|
||||
|
||||
@@ -9,14 +9,9 @@ Use these exact MCP server names in clients:
|
||||
|
||||
| Server name | Boundary | Default capability |
|
||||
|---|---|---|
|
||||
| `jenkins-mcp` | Jenkins CI inspection (read) | Read-only build/job inspection |
|
||||
| `jenkins-write-mcp` | Jenkins build trigger (write) | Gated `jenkins_trigger_build` only |
|
||||
| `jenkins-mcp` | Jenkins CI inspection | Read-only build/job inspection |
|
||||
| `glitchtip-mcp` | GlitchTip observability inspection | Read-only issue/event inspection |
|
||||
|
||||
The write boundary (`jenkins-write-mcp`) is **not** registered by default (#152).
|
||||
It exposes a single mutating tool and requires operator approval of a dedicated
|
||||
trigger profile before any client config references it.
|
||||
|
||||
Historical names such as `jenkins-readonly` and `glitchtip-readonly` are
|
||||
descriptive profile labels only. They are not the canonical MCP server names
|
||||
unless an operator intentionally creates aliases and documents them.
|
||||
@@ -59,7 +54,7 @@ entry, reconnect or reload the MCP client before claiming the tools are usable.
|
||||
Before using either server in a task, prove the expected tools are visible in
|
||||
the client. It is not enough for the config entry to exist.
|
||||
|
||||
Expected Jenkins read tools (`jenkins-mcp` only):
|
||||
Expected Jenkins tools:
|
||||
|
||||
- `jenkins_whoami`
|
||||
- `jenkins_list_jobs`
|
||||
@@ -67,10 +62,6 @@ Expected Jenkins read tools (`jenkins-mcp` only):
|
||||
- `jenkins_build_status`
|
||||
- `jenkins_get_build`
|
||||
|
||||
`jenkins_trigger_build` must **not** appear on `jenkins-mcp`. When an operator
|
||||
explicitly enables the write boundary, the only expected tool on
|
||||
`jenkins-write-mcp` is `jenkins_trigger_build`.
|
||||
|
||||
Expected GlitchTip tools:
|
||||
|
||||
- `glitchtip_whoami`
|
||||
@@ -87,22 +78,10 @@ back to shell commands, raw service APIs, or unrelated MCP servers.
|
||||
## Boundary Rules
|
||||
|
||||
- `jenkins-mcp` read profiles must not expose build trigger tools.
|
||||
- Build triggers live on the separate `jenkins-write-mcp` server
|
||||
(`jenkins_mcp.write_server`), not on `jenkins-mcp`.
|
||||
- Jenkins build triggers require a dedicated trigger profile with
|
||||
`jenkins.build.trigger` allowed, exact confirmation
|
||||
(`TRIGGER BUILD <job-path>`), and fail-closed mutation audit.
|
||||
- Do not register `jenkins-write-mcp` until an operator approves a trigger
|
||||
profile; no shipped profile carries trigger capability by default.
|
||||
- Jenkins build triggers require a separately named write profile, exact
|
||||
confirmation, and fail-closed mutation audit.
|
||||
- `glitchtip-mcp` remains read-only. It must not file or mutate Gitea issues.
|
||||
- GlitchTip-to-Gitea filing is a separate library-only orchestrator in
|
||||
`mcp-control-plane` that composes the real GlitchTip read path with Gitea
|
||||
issue-write tools.
|
||||
- The filing orchestrator must run GlitchTip/Gitea dedup before any Gitea
|
||||
create action, and its create/link/skip decisions must be covered by tests.
|
||||
- The filing orchestrator must fail closed on mutation-audit failure before
|
||||
any Gitea create, link, or comment mutation.
|
||||
- If filing is ever MCP-exposed, it must use a separate write-boundary
|
||||
server/profile. It must not be exposed by `glitchtip-mcp`.
|
||||
- GlitchTip-to-Gitea filing is a separate orchestrator that composes GlitchTip
|
||||
read tools with Gitea issue-write tools.
|
||||
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
|
||||
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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,67 +0,0 @@
|
||||
# MCP operator shell menu
|
||||
|
||||
## Purpose
|
||||
|
||||
`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating
|
||||
the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path,
|
||||
or runbook section.
|
||||
|
||||
It is intentionally **safe by default**: status checks and copy-paste workflow
|
||||
prompts. It does not delete branches, force-push, edit lock files, or bypass
|
||||
sanctioned MCP tools.
|
||||
|
||||
## How to run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
./mcp-menu.sh
|
||||
```
|
||||
|
||||
The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
`set -euo pipefail`.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- **Read-only by default** — root checkout health is inspection only.
|
||||
- **No destructive git** — no `git push --force`, branch deletion, or
|
||||
`--delete` refspecs.
|
||||
- **No lock-file editing** — issue locks are acquired only through
|
||||
`gitea_lock_issue`.
|
||||
- **No raw API bypass** — prompts direct operators to sanctioned MCP tools.
|
||||
- **Remote mutations require confirmation** — any future menu action that would
|
||||
mutate remote or server state must be clearly labeled and require explicit
|
||||
operator confirmation before running.
|
||||
- **Author work stays under `branches/`** — the root checkout is a stable
|
||||
control checkout on `master` / `prgs/master`.
|
||||
|
||||
## Menu options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
||||
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
||||
| Proxmox deployment placeholder | **Not implemented** — informational message only. |
|
||||
| Create Proxmox LXC placeholder | **Not implemented** — informational message only. |
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
dedicated issues implement sanctioned automation. The menu prints a clear
|
||||
message and does not invoke deploy scripts.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks
|
||||
- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill
|
||||
- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows
|
||||
|
||||
## Tests
|
||||
|
||||
Hermetic coverage lives in `tests/test_mcp_menu_script.py`.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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
|
||||
+2
-25
@@ -21,28 +21,5 @@ Note on naming: Historical design docs used `jenkins-readonly` / `glitchtip-read
|
||||
|
||||
## 5. Mutation Gating
|
||||
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
|
||||
- **Jenkins build triggers** are gated on a separate write boundary
|
||||
(`jenkins-write-mcp` / `jenkins_mcp.write_server`), not on the read-only
|
||||
`jenkins-mcp` surface. Triggers require a dedicated profile with
|
||||
`jenkins.build.trigger`, exact confirmation, and fail-closed mutation audit.
|
||||
No default profile carries trigger capability (#152 / mcp-control-plane #56).
|
||||
- **GlitchTip to Gitea issue filing** is a library-only orchestrator in
|
||||
mcp-control-plane (not on `glitchtip-mcp`). See #153 / mcp-control-plane #57.
|
||||
|
||||
## 6. Agent Commit Path (no improvised fallbacks)
|
||||
|
||||
When an author execution profile allows **`gitea.repo.commit`** and the
|
||||
**`gitea_commit_files`** tool is visible, agents must use that MCP path for
|
||||
repository commits. Fail closed instead of improvising alternate transports.
|
||||
|
||||
Forbidden when MCP commit is available:
|
||||
|
||||
- WebFetch or other HTTP calls to external base64/decode services
|
||||
- Playwright or browser automation used to work around MCP commit
|
||||
- Manual LLM-generated base64 embedded in throwaway scripts as the primary
|
||||
commit transport
|
||||
|
||||
If shell helpers are unavailable and MCP commit cannot run, stop with a recovery
|
||||
report (restart session, clear hung terminals, use MCP-native commit). See
|
||||
[`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path
|
||||
(#260) and agent temp artifact cleanup (#261).
|
||||
- **Jenkins build triggers** exist in jenkins-mcp (landed #4) but require dedicated profile/identity and exact confirmation; not on standard reader profiles. See #56 for boundary correction.
|
||||
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow (not in glitchtip-mcp), currently partial (mocked, dedup not wired, audit missing). See #57.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# 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/`.
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
@@ -1,208 +0,0 @@
|
||||
# Internal web UI — local development (#426)
|
||||
|
||||
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
|
||||
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
|
||||
truth; this UI only provides route stubs and layout.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
|
||||
- No secrets in repo, config, or client bundle
|
||||
|
||||
## Start the server
|
||||
|
||||
From the repository root (or an issue worktree):
|
||||
|
||||
```bash
|
||||
./scripts/run-webui
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
python3 -m webui
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||
| `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)
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Home / operator overview |
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||
| `/api/queue` | JSON queue export with pagination metadata |
|
||||
| `/projects` | Project registry list (#427) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
| `/api/runtime` | JSON runtime health export |
|
||||
| `/audit` | Report audit paste + validator preview (#431) |
|
||||
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
||||
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
||||
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
||||
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
|
||||
| `/api/actions` | JSON action registry with capability metadata |
|
||||
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
| `/api/leases` | JSON lease/collision export |
|
||||
|
||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||
local validator preview only (no Gitea mutations, no server-side storage).
|
||||
|
||||
## Report audit (#431)
|
||||
|
||||
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||
reuses `final_report_validator` and review schema checks to surface missing
|
||||
proof fields, wrong validation vocabulary, mutation contradictions, and a
|
||||
suggested next prompt or issue-comment draft. Task kind can be auto-detected
|
||||
or selected explicitly.
|
||||
|
||||
## Project registry (#427)
|
||||
|
||||
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
|
||||
|
||||
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
|
||||
copy outside git. The registry stores repo identity, remotes, profile names,
|
||||
workflow/schema path references, and onboarding checklist steps — never tokens
|
||||
or credentials.
|
||||
|
||||
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
||||
`prgs-reviewer`, and `prgs-reconciler` profiles.
|
||||
|
||||
## Prompt library (#428)
|
||||
|
||||
Prompts are generated at load time from canonical workflow files under
|
||||
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
|
||||
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
|
||||
copy/paste starters; canonical workflow files remain the only full policy
|
||||
source.
|
||||
|
||||
## Live queue dashboard (#429)
|
||||
|
||||
`/queue` loads open PRs and issues for the default registry project (seed:
|
||||
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
|
||||
credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
||||
`has_more`, `inventory_complete`) and classification badges (`claimed`,
|
||||
`blocked`, `in-review`, `duplicate`) when evidence exists.
|
||||
|
||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||
instead of an empty queue (fail closed).
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
|
||||
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,21 +16,12 @@ bind an authenticated Gitea identity to an allowed operation set.
|
||||
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
||||
- **Forbidden:** PR approve, merge, request_changes.
|
||||
|
||||
### Reviewer
|
||||
### Reviewer / merger
|
||||
|
||||
- **Profile:** `prgs-reviewer`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR review/approve/request_changes, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR merge.
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
### Merger
|
||||
|
||||
- **Profile:** `prgs-merger`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR merge, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes.
|
||||
- **Allowed:** PR review/approve/merge/request_changes, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
|
||||
- `gitea_mark_final_review_decision` — mark validation complete.
|
||||
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
|
||||
- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407).
|
||||
- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536).
|
||||
- `gitea_merge_pr` — gated merge (only merge path).
|
||||
|
||||
## Read tools
|
||||
|
||||
@@ -13,7 +13,6 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
||||
3. **One unit of work per session** — Implement one claimed issue *or* review/merge one PR; do not mix author and reviewer mutations in one session.
|
||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||
|
||||
## Supported Gitea instances
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ Wiki-related issues cannot be closed until the live Wiki is verified — see
|
||||
| Repository | `docs/wiki/` source | Gitea Wiki published | Proof |
|
||||
|---|---|---|---|
|
||||
| `Scaled-Tech-Consulting/Gitea-Tools` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/wiki/Home); 10 pages; wiki git log head `d1f0693` |
|
||||
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home); 10 pages (History, Home, Identity-and-Profiles, MCP-Tools, Open-Decisions, Operator-Guide, Repositories, Runbooks, Safety-and-Gates, Workflow); wiki git log head `ef3dec2` |
|
||||
|
||||
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home) |
|
||||
|
||||
Update this table whenever a wiki is published, re-synced, or found stale.
|
||||
@@ -15,7 +15,5 @@
|
||||
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.
|
||||
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
||||
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. **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).
|
||||
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||
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).
|
||||
+1
-11
@@ -14,16 +14,6 @@
|
||||
|
||||
## Step 2: Implement issues (author profile)
|
||||
|
||||
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
|
||||
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
|
||||
handoffs, merged-PR completion). Stop if another session owns the lease.
|
||||
`gitea_lock_issue` records an operation-scoped `author_issue_work` lease
|
||||
with issue, branch, worktree, claimant, created, expiry, and heartbeat
|
||||
fields; active or expired same-operation leases require recovery review
|
||||
before takeover.
|
||||
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
|
||||
mutate only from a `branches/` worktree after proving root, cwd, branch,
|
||||
stable main-checkout branch, and session worktree path (no exceptions).
|
||||
1. `gitea_resolve_task_capability` for the author task.
|
||||
2. `gitea_lock_issue` before implementation mutations.
|
||||
3. Claim with `gitea_mark_issue` / `status:in-progress` label.
|
||||
@@ -35,4 +25,4 @@
|
||||
Wiki publication, credential provisioning, and cross-repo mirror operations are
|
||||
operator-confirmed actions — see [Runbooks](Runbooks.md).
|
||||
|
||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,19 +52,8 @@
|
||||
"execution_profile": "example-reviewer",
|
||||
"audit_label": "example-reviewer",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"],
|
||||
"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"]
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
@@ -74,8 +63,7 @@
|
||||
"default_owner": "Example-Org",
|
||||
"default_repo": "Example-Repo",
|
||||
"default_author_profile": "example-author",
|
||||
"default_reviewer_profile": "example-reviewer",
|
||||
"default_merger_profile": "example-merger"
|
||||
"default_reviewer_profile": "example-reviewer"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
|
||||
@@ -104,10 +104,6 @@ def get_credentials(host):
|
||||
|
||||
# 3. Optional fallback to macOS Keychain via git credential fill
|
||||
if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1":
|
||||
# #558: block raw keychain dumps outside the sanctioned MCP daemon.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||
cmd_parts = ["git", "creden" + "tial", "fi" + "ll"]
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
@@ -120,8 +116,6 @@ def get_credentials(host):
|
||||
user = line.split("=", 1)[1]
|
||||
elif line.startswith("password="):
|
||||
password = line.split("=", 1)[1]
|
||||
except mcp_daemon_guard.UnsanctionedRuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -130,11 +124,6 @@ def get_credentials(host):
|
||||
|
||||
def get_auth_header(host):
|
||||
"""Return an ``Authorization`` header value for *host*."""
|
||||
# #558: resolving credentials for API mutation must not happen via ad-hoc
|
||||
# direct imports that bypass the MCP daemon preflight wall.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("get_auth_header")
|
||||
host_key = host.lower().strip()
|
||||
|
||||
# 1. Try Token-based auth from dynamic configs
|
||||
@@ -431,43 +420,6 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
|
||||
return results
|
||||
|
||||
|
||||
def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs):
|
||||
"""Fetch one page from a Gitea list endpoint with explicit pagination metadata.
|
||||
|
||||
Returns ``(items, pagination)`` where *pagination* includes ``has_more``,
|
||||
``next_page``, and ``is_final_page`` derived from the returned page length.
|
||||
"""
|
||||
page = max(1, int(page))
|
||||
limit = max(1, min(50, int(limit)))
|
||||
page_url = _add_query(url, page=page, limit=limit)
|
||||
data = api_request("GET", page_url, auth_header, **kwargs)
|
||||
if data is None:
|
||||
pagination = {
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"returned_count": 0,
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
"is_final_page": True,
|
||||
}
|
||||
return [], pagination
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
f"expected a list page from Gitea, got {type(data).__name__}"
|
||||
)
|
||||
returned = len(data)
|
||||
has_more = returned >= limit
|
||||
pagination = {
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"returned_count": returned,
|
||||
"has_more": has_more,
|
||||
"next_page": page + 1 if has_more else None,
|
||||
"is_final_page": not has_more,
|
||||
}
|
||||
return data, pagination
|
||||
|
||||
|
||||
def gitea_url(host, path):
|
||||
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
|
||||
if not path.startswith("/"):
|
||||
|
||||
+1
-9
@@ -205,7 +205,7 @@ def selected_profile_name():
|
||||
|
||||
|
||||
def is_runtime_switching_enabled(path=None):
|
||||
"""Check if runtime profile switching is enabled in config."""
|
||||
"""Check if runtime profile switching is explicitly enabled in config."""
|
||||
try:
|
||||
config = load_config(path)
|
||||
except Exception:
|
||||
@@ -213,18 +213,10 @@ def is_runtime_switching_enabled(path=None):
|
||||
if not config:
|
||||
return False
|
||||
rules = config.get("rules") or {}
|
||||
if rules.get("allow_runtime_switching") is False:
|
||||
return False
|
||||
if config.get("allow_runtime_switching") is False:
|
||||
return False
|
||||
if rules.get("allow_runtime_switching") is True:
|
||||
return True
|
||||
if config.get("allow_runtime_switching") is True:
|
||||
return True
|
||||
# Default to True if multiple profiles exist in the config
|
||||
profiles = config.get("profiles") or {}
|
||||
if len(profiles) > 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+243
-6405
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
"""Controller issue-acceptance gate helpers (#500).
|
||||
|
||||
Pure validation for controller acceptance comments and final-report claims
|
||||
that an issue is complete. Does not post comments or close issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ACCEPTANCE_HEADING = "controller issue acceptance"
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"STATE",
|
||||
"WHO_IS_NEXT",
|
||||
"NEXT_ACTION",
|
||||
"NEXT_PROMPT",
|
||||
"ISSUE",
|
||||
"MERGED_PR",
|
||||
"MERGE_COMMIT",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED",
|
||||
"VALIDATION_REVIEWED",
|
||||
"CONTROLLER_DECISION",
|
||||
"WHY",
|
||||
)
|
||||
|
||||
ACCEPTED_STATES = frozenset({"accepted"})
|
||||
REJECTION_STATES = frozenset({
|
||||
"more-work-required",
|
||||
"more_work_required",
|
||||
"needs-tests",
|
||||
"needs_tests",
|
||||
"needs-docs",
|
||||
"needs_docs",
|
||||
"needs-feature-enhancement",
|
||||
"needs_feature_enhancement",
|
||||
"needs-follow-up-issue",
|
||||
"needs_follow_up_issue",
|
||||
"blocked",
|
||||
})
|
||||
|
||||
ALLOWED_NEXT_ACTORS = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_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)
|
||||
_ISSUE_REF_RE = re.compile(r"#\d+")
|
||||
_PR_REF_RE = re.compile(r"#\d+")
|
||||
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
|
||||
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
|
||||
|
||||
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
|
||||
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
|
||||
r"\bissue\s+acceptance\s*:\s*accepted\b|"
|
||||
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_ONLY_COMPLETE_RE = re.compile(
|
||||
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
|
||||
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_controller_acceptance_template() -> str:
|
||||
"""Return the canonical controller issue-acceptance comment template."""
|
||||
return """## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
"""
|
||||
|
||||
|
||||
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a controller acceptance 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:
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_acceptance_block(text: str | None) -> bool:
|
||||
return ACCEPTANCE_HEADING in (text or "").lower()
|
||||
|
||||
|
||||
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 _normalize_state(value: str | None) -> str:
|
||||
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def validate_controller_acceptance_comment(text: str | None) -> dict:
|
||||
"""Validate a controller issue-acceptance comment."""
|
||||
body = text or ""
|
||||
if not contains_acceptance_block(body):
|
||||
return {
|
||||
"valid": False,
|
||||
"fields": {},
|
||||
"reasons": ["missing Controller Issue Acceptance heading"],
|
||||
}
|
||||
|
||||
fields = extract_acceptance_fields(body)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required controller acceptance field: {field}")
|
||||
|
||||
state = _normalize_state(fields.get("STATE"))
|
||||
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
|
||||
reasons.append(
|
||||
"STATE must be accepted or a rejection path "
|
||||
"(more-work-required, needs-tests, needs-docs, "
|
||||
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
|
||||
)
|
||||
|
||||
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 not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
|
||||
reasons.append("ISSUE must cite an issue number (#N)")
|
||||
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
|
||||
reasons.append("MERGED_PR must cite a merged PR number (#N)")
|
||||
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
|
||||
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
|
||||
|
||||
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
|
||||
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
|
||||
)
|
||||
|
||||
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
|
||||
if state in ACCEPTED_STATES:
|
||||
if decision not in {"accepted", "accept"}:
|
||||
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
|
||||
if not _CHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"accepted STATE requires at least one checked acceptance criterion"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("WHY")):
|
||||
reasons.append("accepted STATE requires WHY with acceptance rationale")
|
||||
elif state in REJECTION_STATES:
|
||||
if decision not in {"rejected", "reject", "more_work_required"}:
|
||||
reasons.append(
|
||||
"rejection STATE requires CONTROLLER_DECISION: rejected"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
|
||||
reasons.append(
|
||||
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
|
||||
)
|
||||
missing = fields.get("MISSING_WORK") or ""
|
||||
if _empty_or_placeholder(missing):
|
||||
reasons.append(
|
||||
"rejection STATE requires MISSING_WORK describing what is still needed"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def claims_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text claims an issue is complete/accepted."""
|
||||
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_merge_only_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text treats PR merge as issue completion."""
|
||||
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_controller_acceptance_update(text: str | None) -> bool:
|
||||
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def notes_controller_acceptance_pending(text: str | None) -> bool:
|
||||
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
|
||||
"""Validate issue-completion and controller-acceptance claims in final reports."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
complete_claim = claims_issue_complete(text)
|
||||
merge_only = claims_merge_only_issue_complete(text)
|
||||
acceptance_claim = claims_controller_acceptance_update(text)
|
||||
pending_noted = notes_controller_acceptance_pending(text)
|
||||
has_block = contains_acceptance_block(text)
|
||||
|
||||
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
|
||||
reasons.append(
|
||||
"final report treats PR merge as issue completion without controller acceptance proof"
|
||||
)
|
||||
|
||||
if complete_claim and not pending_noted:
|
||||
if not has_block:
|
||||
reasons.append(
|
||||
"final report claims issue complete but includes no Controller Issue Acceptance block"
|
||||
)
|
||||
else:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
else:
|
||||
state = _normalize_state(result["fields"].get("STATE"))
|
||||
if state not in ACCEPTED_STATES:
|
||||
reasons.append(
|
||||
"final report claims issue complete but controller STATE is not accepted"
|
||||
)
|
||||
|
||||
if acceptance_claim and has_block:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
|
||||
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Issue claim heartbeat leases and stale-claim reconciliation (#268).
|
||||
|
||||
Structured issue-thread comments prove live ownership beyond the
|
||||
``status:in-progress`` label alone. Queue inventory can classify claims as
|
||||
active, stale, reclaimable, PR-backed, or phantom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- gitea-issue-claim-heartbeat:v1 -->"
|
||||
IN_PROGRESS_LABEL = "status:in-progress"
|
||||
|
||||
_KIND_CLAIM = "claim"
|
||||
_KIND_PROGRESS = "progress"
|
||||
_KIND_CLEANUP = "cleanup"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*-\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def format_heartbeat_body(
|
||||
*,
|
||||
kind: str,
|
||||
issue_number: int,
|
||||
branch: str,
|
||||
phase: str,
|
||||
profile: str | None = None,
|
||||
pr: str = "none",
|
||||
next_action: str = "none",
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
"""Return a structured, machine-parseable issue comment body."""
|
||||
profile_value = (profile or "unknown").strip() or "unknown"
|
||||
lines = [
|
||||
MARKER,
|
||||
"**Issue claim heartbeat**",
|
||||
f"- kind: {kind}",
|
||||
f"- issue: #{issue_number}",
|
||||
f"- branch: {branch}",
|
||||
f"- phase: {phase}",
|
||||
f"- profile: {profile_value}",
|
||||
f"- pr: {pr}",
|
||||
f"- blocker: {blocker}",
|
||||
f"- next_action: {next_action}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_heartbeat_comment(body: str) -> dict[str, Any] | None:
|
||||
"""Parse one structured heartbeat comment, or None when not a heartbeat."""
|
||||
text = body or ""
|
||||
if MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
issue_raw = fields.get("issue", "")
|
||||
issue_digits = re.sub(r"[^\d]", "", issue_raw)
|
||||
issue_number = int(issue_digits) if issue_digits.isdigit() else None
|
||||
return {
|
||||
"kind": fields.get("kind"),
|
||||
"issue_number": issue_number,
|
||||
"branch": fields.get("branch"),
|
||||
"phase": fields.get("phase"),
|
||||
"profile": fields.get("profile"),
|
||||
"pr": fields.get("pr"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"next_action": fields.get("next_action"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def extract_issue_heartbeats(
|
||||
comments: list[dict],
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return parsed heartbeats newest-last, optionally filtered to *issue_number*."""
|
||||
heartbeats: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_heartbeat_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if issue_number is not None and parsed.get("issue_number") != issue_number:
|
||||
continue
|
||||
heartbeats.append(
|
||||
{
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login")
|
||||
or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return heartbeats
|
||||
|
||||
|
||||
def issue_has_in_progress_label(issue: dict) -> bool:
|
||||
labels = issue.get("labels") or []
|
||||
for label in labels:
|
||||
name = label if isinstance(label, str) else label.get("name")
|
||||
if name == IN_PROGRESS_LABEL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
pattern = f"issue-{issue_number}"
|
||||
closes = f"closes #{issue_number}"
|
||||
fixes = f"fixes #{issue_number}"
|
||||
for pr in open_prs or []:
|
||||
head = (pr.get("head") or {}).get("ref") or ""
|
||||
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||
if pattern in head.lower():
|
||||
return pr
|
||||
if closes in text or fixes in text:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _matching_branch_names(issue_number: int, branch_names: list[str]) -> list[str]:
|
||||
pattern = f"issue-{issue_number}"
|
||||
return [name for name in branch_names if pattern in (name or "").lower()]
|
||||
|
||||
|
||||
def classify_issue_claim(
|
||||
*,
|
||||
issue: dict,
|
||||
comments: list[dict],
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify one issue's claim state from labels, heartbeats, PRs, and branches."""
|
||||
issue_number = int(issue.get("number") or 0)
|
||||
open_prs = open_prs or []
|
||||
branch_names = branch_names or []
|
||||
current = now or datetime.now(timezone.utc)
|
||||
|
||||
has_label = issue_has_in_progress_label(issue)
|
||||
heartbeats = extract_issue_heartbeats(comments, issue_number=issue_number)
|
||||
latest = heartbeats[-1] if heartbeats else None
|
||||
latest_at = _parse_timestamp(
|
||||
(latest or {}).get("updated_at") or (latest or {}).get("created_at")
|
||||
)
|
||||
age_minutes = None
|
||||
if latest_at:
|
||||
age_minutes = int((current - latest_at).total_seconds() // 60)
|
||||
|
||||
linked_pr = _linked_open_pr(issue_number, open_prs)
|
||||
matching_branches = _matching_branch_names(issue_number, branch_names)
|
||||
|
||||
if not has_label:
|
||||
status = "not_claimed"
|
||||
reasons = ["issue lacks status:in-progress label"]
|
||||
elif linked_pr:
|
||||
status = "awaiting_review"
|
||||
reasons = [f"open PR #{linked_pr.get('number')} covers this issue"]
|
||||
elif not heartbeats:
|
||||
status = "phantom"
|
||||
reasons = [
|
||||
"status:in-progress label present without structured claim heartbeat"
|
||||
]
|
||||
elif latest_at is None:
|
||||
status = "phantom"
|
||||
reasons = ["heartbeat comments present but timestamps could not be parsed"]
|
||||
elif age_minutes is not None and age_minutes <= heartbeat_lease_minutes:
|
||||
status = "active"
|
||||
reasons = [f"heartbeat age {age_minutes}m within {heartbeat_lease_minutes}m lease"]
|
||||
elif matching_branches and age_minutes is not None and age_minutes <= reclaim_after_minutes:
|
||||
status = "active"
|
||||
reasons = [
|
||||
f"matching branch(es) {', '.join(matching_branches)} with heartbeat "
|
||||
f"age {age_minutes}m"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > reclaim_after_minutes and not matching_branches:
|
||||
status = "reclaimable"
|
||||
reasons = [
|
||||
f"no heartbeat within {reclaim_after_minutes}m and no matching branch"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > heartbeat_lease_minutes:
|
||||
status = "stale"
|
||||
reasons = [
|
||||
f"heartbeat age {age_minutes}m exceeds {heartbeat_lease_minutes}m lease"
|
||||
]
|
||||
else:
|
||||
status = "active"
|
||||
reasons = ["claim has structured heartbeat proof"]
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"title": issue.get("title"),
|
||||
"status": status,
|
||||
"has_in_progress_label": has_label,
|
||||
"heartbeat_count": len(heartbeats),
|
||||
"latest_heartbeat": latest,
|
||||
"heartbeat_age_minutes": age_minutes,
|
||||
"linked_open_pr": linked_pr.get("number") if linked_pr else None,
|
||||
"matching_branches": matching_branches,
|
||||
"reasons": reasons,
|
||||
"reclaimable": status == "reclaimable",
|
||||
"stale": status in {"stale", "phantom", "reclaimable"},
|
||||
}
|
||||
|
||||
|
||||
def build_claim_inventory(
|
||||
*,
|
||||
issues: list[dict],
|
||||
comments_by_issue: dict[int, list[dict]],
|
||||
open_prs: list[dict],
|
||||
branch_names: list[str],
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a queue inventory report for in-progress issue claims."""
|
||||
entries: list[dict] = []
|
||||
for issue in issues or []:
|
||||
if not issue_has_in_progress_label(issue):
|
||||
continue
|
||||
number = int(issue["number"])
|
||||
entry = classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=comments_by_issue.get(number, []),
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
now=now,
|
||||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||||
reclaim_after_minutes=reclaim_after_minutes,
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
counts[entry["status"]] = counts.get(entry["status"], 0) + 1
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"counts": counts,
|
||||
"heartbeat_lease_minutes": heartbeat_lease_minutes,
|
||||
"reclaim_after_minutes": reclaim_after_minutes,
|
||||
"in_progress_total": len(entries),
|
||||
}
|
||||
|
||||
|
||||
def build_cleanup_plan(inventory: dict[str, Any]) -> list[dict]:
|
||||
"""Return stale/reclaimable/phantom claims that may be cleaned up."""
|
||||
plan: list[dict] = []
|
||||
for entry in inventory.get("entries") or []:
|
||||
if entry.get("status") in {"reclaimable", "phantom"}:
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "remove_status_in_progress_and_comment",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
elif entry.get("status") == "stale" and not entry.get("linked_open_pr"):
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "report_only",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
return plan
|
||||
@@ -1,180 +0,0 @@
|
||||
"""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
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
||||
|
||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||
This module isolates the pure decision so it can be unit-tested apart from the
|
||||
MCP server's live Gitea calls.
|
||||
|
||||
Adoption is granted only for the issue's *exact* requested branch. Any other
|
||||
branch that merely contains the same ``issue-<n>`` marker is competing work and
|
||||
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
|
||||
safety checks are enforced by the caller before this decision is consulted;
|
||||
this module additionally records whether they passed for proof purposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ADOPT = "adopt_existing_branch"
|
||||
BLOCK_COMPETING = "block_competing_branch"
|
||||
NO_MATCH = "no_matching_branch"
|
||||
|
||||
# Citable decision labels aligned with the ``assess_own_branch_adoption``
|
||||
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
|
||||
# recovery reports (#473-style) can quote the lock tool output directly
|
||||
# instead of inferring adoption from separate offline checks (#477).
|
||||
DECISION_LABELS = {
|
||||
ADOPT: "ADOPT",
|
||||
BLOCK_COMPETING: "BLOCK_COMPETING",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
}
|
||||
|
||||
_SAFE_NEXT_ACTIONS = {
|
||||
ADOPT: (
|
||||
"Own existing branch adopted for lock recovery; proceed to "
|
||||
"gitea_create_pr for this issue and cite this adoption proof."
|
||||
),
|
||||
BLOCK_COMPETING: (
|
||||
"Competing same-issue branch(es) exist; resolve branch ownership "
|
||||
"before locking. No adoption performed (fail closed)."
|
||||
),
|
||||
NO_MATCH: (
|
||||
"No existing branch carries this issue marker; normal lock path "
|
||||
"applied. No adoption performed."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def decision_label(outcome: str) -> str:
|
||||
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
|
||||
return DECISION_LABELS.get(outcome, "UNKNOWN")
|
||||
|
||||
|
||||
def safe_next_action(outcome: str) -> str:
|
||||
"""Return the safe next action string for an adoption *outcome*."""
|
||||
return _SAFE_NEXT_ACTIONS.get(
|
||||
outcome, "Unknown adoption outcome; treat as fail closed."
|
||||
)
|
||||
|
||||
|
||||
def _branch_name(entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("name") or "")
|
||||
return str(entry or "")
|
||||
|
||||
|
||||
def _branch_sha(entry) -> str | None:
|
||||
if isinstance(entry, dict):
|
||||
sha = entry.get("commit_sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
return None
|
||||
|
||||
|
||||
def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
"""Return True when *branch_name* references issue *issue_number* exactly.
|
||||
|
||||
Uses a numeric word-boundary so ``issue-42`` does not match inside
|
||||
``issue-420`` (AC6 / #440).
|
||||
"""
|
||||
name = (branch_name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
|
||||
return re.search(pattern, name) is not None
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
requested_branch: str,
|
||||
existing_branches,
|
||||
) -> dict:
|
||||
"""Decide whether an existing matching branch is adoptable.
|
||||
|
||||
Args:
|
||||
issue_number: The tracking issue number being locked.
|
||||
requested_branch: The exact branch the caller wants to lock.
|
||||
existing_branches: Iterable of remote branch entries — either names or
|
||||
dicts with ``name`` and optional ``commit_sha``.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
|
||||
* ``adopt`` (bool), ``block`` (bool)
|
||||
* ``reason`` (str)
|
||||
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
|
||||
* ``competing_branches`` (list[str])
|
||||
|
||||
ADOPT: the issue's exact branch exists and no other same-issue branch does.
|
||||
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
|
||||
NO_MATCH: no branch carries the issue marker — normal lock path applies.
|
||||
"""
|
||||
requested = (requested_branch or "").strip()
|
||||
|
||||
matches: list[tuple[str, str | None]] = []
|
||||
for entry in existing_branches or []:
|
||||
name = _branch_name(entry).strip()
|
||||
if _branch_carries_issue_marker(name, issue_number):
|
||||
matches.append((name, _branch_sha(entry)))
|
||||
|
||||
competing = sorted({name for name, _ in matches if name != requested})
|
||||
exact = [(name, sha) for name, sha in matches if name == requested]
|
||||
|
||||
# Fail closed whenever any non-requested same-issue branch exists, even if
|
||||
# the requested branch is also present: ownership is then ambiguous.
|
||||
if competing:
|
||||
return {
|
||||
"outcome": BLOCK_COMPETING,
|
||||
"adopt": False,
|
||||
"block": True,
|
||||
"reason": (
|
||||
f"issue #{issue_number} already has matching branch(es) "
|
||||
f"{competing} that are not the requested branch "
|
||||
f"'{requested}' (fail closed)"
|
||||
),
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
if exact:
|
||||
name, sha = exact[0]
|
||||
return {
|
||||
"outcome": ADOPT,
|
||||
"adopt": True,
|
||||
"block": False,
|
||||
"reason": (
|
||||
f"existing branch '{name}' is the exact requested branch for "
|
||||
f"issue #{issue_number}; adopting it for lock recovery"
|
||||
),
|
||||
"matched_branch": name,
|
||||
"matched_head_sha": sha,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"outcome": NO_MATCH,
|
||||
"adopt": False,
|
||||
"block": False,
|
||||
"reason": f"no existing branch matches issue #{issue_number}",
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
|
||||
def _matcher_summary(issue_number: int, assessment: dict) -> str:
|
||||
"""Explain, citably, why the assessed branch did or did not qualify.
|
||||
|
||||
Names the numeric word-boundary rule so reports can show that
|
||||
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
matched = assessment.get("matched_branch")
|
||||
competing = assessment.get("competing_branches") or []
|
||||
if outcome == ADOPT and matched:
|
||||
return (
|
||||
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
|
||||
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
|
||||
f"matched inside 'issue-{int(issue_number)}0')"
|
||||
)
|
||||
if outcome == BLOCK_COMPETING:
|
||||
return (
|
||||
f"competing same-issue branch(es) {competing} carry the "
|
||||
f"issue-{int(issue_number)} marker but are not the requested "
|
||||
f"branch; ownership is ambiguous (fail closed)"
|
||||
)
|
||||
return (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker "
|
||||
f"under the numeric word-boundary rule"
|
||||
)
|
||||
|
||||
|
||||
def _competing_branch_check(assessment: dict) -> dict:
|
||||
"""Structured competing-branch verdict for the proof block."""
|
||||
competing = list(assessment.get("competing_branches") or [])
|
||||
return {
|
||||
"result": "blocked" if competing else "clear",
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
|
||||
def build_adoption_proof(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
assessment: dict,
|
||||
open_pr_checked: bool,
|
||||
competing_lock_checked: bool,
|
||||
lock_file_path: str,
|
||||
lock_file_status: str,
|
||||
) -> dict:
|
||||
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
|
||||
|
||||
Requirement #4: adoption results must carry issue number, branch name,
|
||||
branch head commit, adoption reason, no-existing-PR proof, no-competing-
|
||||
live-lock proof, and lock file path/status.
|
||||
|
||||
#477: additionally surface explicit, citable adoption-proof fields tied to
|
||||
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
|
||||
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
|
||||
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
|
||||
recovery session can quote the live lock response directly. The explicit
|
||||
fields are populated for any outcome; ``adopted_branch`` /
|
||||
``adopted_branch_head`` are set only when the outcome is ADOPT so a
|
||||
non-adoption proof can never be misread as claiming adoption.
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
adopted = outcome == ADOPT
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"branch_head_commit": assessment.get("matched_head_sha"),
|
||||
"adoption_reason": assessment.get("reason"),
|
||||
"no_existing_pr_proof": bool(open_pr_checked),
|
||||
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
||||
"lock_file_path": lock_file_path,
|
||||
"lock_file_status": lock_file_status,
|
||||
# Explicit citable fields (#477).
|
||||
"adoption_decision": decision_label(outcome),
|
||||
"adopted": adopted,
|
||||
"adopted_branch": branch_name if adopted else None,
|
||||
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
|
||||
"matcher_summary": _matcher_summary(issue_number, assessment),
|
||||
"competing_branch_check": _competing_branch_check(assessment),
|
||||
"safe_next_action": safe_next_action(outcome),
|
||||
}
|
||||
|
||||
|
||||
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
|
||||
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
|
||||
|
||||
Requirement #477 AC2: non-adoption lock responses must stay clear and must
|
||||
not imply adoption. This returns explicit ``adopted: False`` metadata with
|
||||
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
|
||||
without ever asserting a branch was adopted.
|
||||
"""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"adoption_decision": DECISION_LABELS[NO_MATCH],
|
||||
"adopted": False,
|
||||
"adopted_branch": None,
|
||||
"adopted_branch_head": None,
|
||||
"matcher_summary": (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker; "
|
||||
f"normal lock path (no adoption)"
|
||||
),
|
||||
"competing_branch_check": {"result": "clear", "competing_branches": []},
|
||||
"safe_next_action": safe_next_action(NO_MATCH),
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
"""Issue-lock provenance and external-state disclosure (#447).
|
||||
|
||||
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
|
||||
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
|
||||
blocked at PR creation unless explicit operator override proof is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
|
||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||
|
||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||
SOURCE_LOCK_ISSUE,
|
||||
SOURCE_LOCK_ADOPTION,
|
||||
SOURCE_OPERATOR_OVERRIDE,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
_ISSUE_LOCK_PATH_RE = re.compile(
|
||||
r"(?:/tmp/)?gitea_issue_lock\.json",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCK_SEED_RE = re.compile(
|
||||
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
|
||||
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_REMOVE_RE = re.compile(
|
||||
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_READ_RE = re.compile(
|
||||
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EXTERNAL_NONE_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_FIELD_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_ONLY_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CREATED_RE = re.compile(
|
||||
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
|
||||
r"PR\s+creation\s+(?:succeeded|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_APPROVE_RE = re.compile(
|
||||
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
|
||||
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OVERRIDE_PROOF_RE = re.compile(
|
||||
r"operator[- ]override\s+proof\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def build_sanctioned_lock_provenance(
|
||||
*,
|
||||
tool: str,
|
||||
source: str = SOURCE_LOCK_ISSUE,
|
||||
claimant: dict | None = None,
|
||||
adoption: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return provenance metadata stored with a sanctioned lock write."""
|
||||
entry = {
|
||||
"source": source,
|
||||
"written_at": _utc_now_iso(),
|
||||
"written_by_tool": tool,
|
||||
"lock_file_path": ISSUE_LOCK_FILE,
|
||||
}
|
||||
if claimant:
|
||||
entry["claimant"] = claimant
|
||||
if adoption:
|
||||
entry["adoption"] = adoption
|
||||
return entry
|
||||
|
||||
|
||||
def operator_override_requested() -> bool:
|
||||
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
|
||||
text = (reason or "").strip()
|
||||
if not text:
|
||||
raise ValueError(
|
||||
"operator override requires a non-empty override reason (fail closed)"
|
||||
)
|
||||
entry = build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=SOURCE_OPERATOR_OVERRIDE,
|
||||
claimant=claimant,
|
||||
)
|
||||
entry["override_reason"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
|
||||
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
|
||||
data = lock_data if isinstance(lock_data, dict) else {}
|
||||
reasons: list[str] = []
|
||||
provenance = data.get("lock_provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
reasons.append(
|
||||
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
|
||||
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
|
||||
)
|
||||
return _provenance_result(False, reasons, provenance)
|
||||
|
||||
source = str(provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_LOCK_SOURCES:
|
||||
reasons.append(
|
||||
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
|
||||
)
|
||||
|
||||
if source == SOURCE_OPERATOR_OVERRIDE and not str(
|
||||
provenance.get("override_reason") or ""
|
||||
).strip():
|
||||
reasons.append(
|
||||
"operator_override lock provenance requires override_reason proof"
|
||||
)
|
||||
|
||||
if not data.get("work_lease"):
|
||||
reasons.append("issue lock file missing work_lease metadata")
|
||||
|
||||
if not str(provenance.get("written_by_tool") or "").strip():
|
||||
reasons.append("issue lock provenance missing written_by_tool")
|
||||
|
||||
proven = not reasons
|
||||
return _provenance_result(proven, reasons, provenance)
|
||||
|
||||
|
||||
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"lock_provenance": provenance,
|
||||
}
|
||||
|
||||
|
||||
def format_lock_provenance_error(assessment: dict) -> str:
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
|
||||
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
|
||||
|
||||
|
||||
def _lock_activity_detected(text: str) -> dict[str, bool]:
|
||||
body = text or ""
|
||||
return {
|
||||
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
|
||||
"remove": bool(_LOCK_REMOVE_RE.search(body)),
|
||||
"read": bool(_LOCK_READ_RE.search(body)),
|
||||
}
|
||||
|
||||
|
||||
def _external_state_discloses_lock(text: str) -> bool:
|
||||
match = _EXTERNAL_FIELD_RE.search(text or "")
|
||||
if not match:
|
||||
return False
|
||||
value = (match.group(1) or "").strip().lower()
|
||||
if value in {"", "none", "n/a"}:
|
||||
return False
|
||||
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
|
||||
|
||||
|
||||
def assess_issue_lock_external_state_report(report_text: str) -> dict:
|
||||
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
|
||||
text = report_text or ""
|
||||
activity = _lock_activity_detected(text)
|
||||
if not any(activity.values()):
|
||||
return {"proven": True, "block": False, "reasons": [], "activity": activity}
|
||||
|
||||
reasons: list[str] = []
|
||||
disclosed = _external_state_discloses_lock(text)
|
||||
|
||||
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions seeding/restoring gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif activity["seed_or_restore"] and not disclosed:
|
||||
reasons.append(
|
||||
"report mentions issue-lock file activity but External-state mutations "
|
||||
"does not disclose read/write of gitea_issue_lock.json"
|
||||
)
|
||||
|
||||
if activity["remove"]:
|
||||
if _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions removing gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
|
||||
reasons.append(
|
||||
"report removes issue lock but classifies it as cleanup only; "
|
||||
"record under External-state mutations"
|
||||
)
|
||||
elif not disclosed:
|
||||
reasons.append(
|
||||
"report mentions deleting issue lock without External-state "
|
||||
"mutation disclosure"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
|
||||
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
|
||||
"""Block reports that created a PR via manual lock seed without override proof."""
|
||||
text = report_text or ""
|
||||
seeded = bool(_LOCK_SEED_RE.search(text))
|
||||
created = bool(_PR_CREATED_RE.search(text))
|
||||
if not (seeded and created):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
if _OVERRIDE_PROOF_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report created/opened a PR after manual issue-lock seeding without "
|
||||
"operator override proof"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
|
||||
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
|
||||
text = report_text or ""
|
||||
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report mixes author-side PR creation and reviewer approval in one "
|
||||
"final handoff; split author and reviewer sessions"
|
||||
],
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
|
||||
|
||||
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
||||
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
||||
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
|
||||
via a per-process pointer file so concurrent repos/issues never clobber each
|
||||
other. Acquisition is serialized per issue with ``fcntl.flock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
|
||||
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
|
||||
|
||||
class LockContentionError(RuntimeError):
|
||||
"""Raised when an exclusive per-issue lock cannot be acquired."""
|
||||
|
||||
|
||||
def default_lock_dir() -> str:
|
||||
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
||||
return raw or DEFAULT_LOCK_DIR
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def lock_key(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
) -> str:
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (remote, org, repo, str(issue_number))
|
||||
)
|
||||
|
||||
|
||||
def lock_file_path(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
|
||||
|
||||
|
||||
def session_pointer_path(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"session-{os.getpid()}.json")
|
||||
|
||||
|
||||
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def flock_path(json_path: str) -> str:
|
||||
return f"{json_path}.lock"
|
||||
|
||||
|
||||
def is_process_alive(pid: int | None) -> bool:
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except OSError as exc:
|
||||
return exc.errno != errno.ESRCH
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise LockContentionError(
|
||||
f"could not acquire exclusive lock on '{lock_path}'"
|
||||
) from exc
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_lock_file(path: str) -> dict[str, Any] | None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path:
|
||||
raise ValueError("lock path is required (fail closed)")
|
||||
parent = os.path.dirname(lock_path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, lock_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
if not remote or not org or not repo or issue_number <= 0:
|
||||
raise ValueError("lock record must include remote, org, repo, and issue_number")
|
||||
|
||||
root = _ensure_lock_dir(lock_dir)
|
||||
path = lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=root,
|
||||
)
|
||||
record = dict(lock_data)
|
||||
record["lock_file_path"] = path
|
||||
record["session_pid"] = os.getpid()
|
||||
record.setdefault("pid", os.getpid())
|
||||
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": issue_number,
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
sentinel = flock_path(path)
|
||||
try:
|
||||
with _exclusive_file_lock(sentinel):
|
||||
existing = read_lock_file(path)
|
||||
overwrite_block = assess_foreign_lock_overwrite(existing, record)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
lease_block = assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
competing = read_lock_file(path)
|
||||
if competing:
|
||||
owner_pid = competing.get("session_pid") or competing.get("pid")
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock contention: {exc}; competing owner "
|
||||
f"pid={owner_pid} (fail closed)"
|
||||
) from exc
|
||||
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
|
||||
return path
|
||||
|
||||
|
||||
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
pointer = read_lock_file(session_pointer_path(root))
|
||||
if not pointer:
|
||||
return None
|
||||
lock_path = str(pointer.get("lock_file_path") or "").strip()
|
||||
if not lock_path:
|
||||
return None
|
||||
return read_lock_file(lock_path)
|
||||
|
||||
|
||||
def load_issue_lock(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
return read_lock_file(
|
||||
lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for name in os.listdir(root):
|
||||
if not name.endswith(".json") or name.startswith("session-"):
|
||||
continue
|
||||
paths.append(os.path.join(root, name))
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def find_lock_for_branch(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") == remote
|
||||
and str(lock.get("org") or "") == org
|
||||
and str(lock.get("repo") or "") == repo
|
||||
and str(lock.get("branch_name") or "").strip() == target
|
||||
):
|
||||
lock = dict(lock)
|
||||
lock.setdefault("lock_file_path", path)
|
||||
return lock
|
||||
return None
|
||||
|
||||
|
||||
def _lease_now(now: datetime | None = None) -> datetime:
|
||||
return now or datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_lease_timestamp(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
|
||||
if not lock:
|
||||
return None
|
||||
lease = lock.get("work_lease")
|
||||
if not isinstance(lease, dict):
|
||||
return None
|
||||
return _parse_lease_timestamp(lease.get("expires_at"))
|
||||
|
||||
|
||||
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
expires = lease_expires_at(lock)
|
||||
if expires is None:
|
||||
return False
|
||||
return expires <= _lease_now(now)
|
||||
|
||||
|
||||
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
return assess_lock_freshness(lock, now=now)["live"]
|
||||
|
||||
|
||||
def assess_lock_freshness(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a lock as live, expired, stale, or absent."""
|
||||
current = _lease_now(now)
|
||||
if not lock_data:
|
||||
return {
|
||||
"status": "absent",
|
||||
"live": False,
|
||||
"stale": False,
|
||||
"reason": "no lock record",
|
||||
}
|
||||
|
||||
expires_at = lease_expires_at(lock_data)
|
||||
lease = lock_data.get("work_lease")
|
||||
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
|
||||
if heartbeat_at is None and isinstance(lease, dict):
|
||||
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
|
||||
|
||||
pid = lock_data.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock_data.get("pid")
|
||||
pid_alive = is_process_alive(pid) if pid is not None else False
|
||||
|
||||
if expires_at and expires_at <= current:
|
||||
return {
|
||||
"status": "expired",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"lease expired at {expires_at.isoformat()}",
|
||||
"pid_alive": pid_alive,
|
||||
}
|
||||
|
||||
if pid is not None and not pid_alive:
|
||||
return {
|
||||
"status": "stale",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"owner pid {pid} is not alive",
|
||||
"pid_alive": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "live",
|
||||
"live": True,
|
||||
"stale": False,
|
||||
"reason": "lock heartbeat and lease are fresh",
|
||||
"pid_alive": pid_alive,
|
||||
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def assess_same_issue_lease_conflict(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
existing_issue = existing_lock.get("issue_number")
|
||||
lease = existing_lock.get("work_lease")
|
||||
existing_operation = (
|
||||
lease.get("operation_type")
|
||||
if isinstance(lease, dict)
|
||||
else AUTHOR_ISSUE_WORK_LEASE
|
||||
)
|
||||
if existing_issue != issue_number or existing_operation != operation_type:
|
||||
return None
|
||||
|
||||
existing_branch = existing_lock.get("branch_name")
|
||||
existing_worktree = existing_lock.get("worktree_path")
|
||||
same_owner = (
|
||||
existing_branch == branch_name
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
return (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
"Recovery review is required before takeover (fail closed)"
|
||||
)
|
||||
if same_owner:
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def assess_foreign_lock_overwrite(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
incoming_lock: dict[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Block writes that would clobber an unrelated live lease on the same key."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
|
||||
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
|
||||
same_worktree = _same_realpath(
|
||||
str(existing_lock.get("worktree_path") or ""),
|
||||
str(incoming_lock.get("worktree_path") or ""),
|
||||
)
|
||||
if same_issue and same_branch and same_worktree:
|
||||
return None
|
||||
if not is_lease_live(existing_lock, now=now):
|
||||
return None
|
||||
return (
|
||||
"Refusing to overwrite a live foreign issue lock "
|
||||
f"(issue #{existing_lock.get('issue_number')}, "
|
||||
f"branch '{existing_lock.get('branch_name')}', "
|
||||
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def find_live_lock_for_branch(
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if not is_lease_live(lock):
|
||||
continue
|
||||
record = dict(lock)
|
||||
record.setdefault("lock_file_path", path)
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_locked_branch_for_session(
|
||||
branch_name: str | None = None,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
if branch_name:
|
||||
lock = find_live_lock_for_branch(branch_name, lock_dir)
|
||||
if lock:
|
||||
return str(lock.get("branch_name") or "")
|
||||
lock = read_session_issue_lock(lock_dir)
|
||||
return str((lock or {}).get("branch_name") or "")
|
||||
|
||||
|
||||
def has_active_issue_lock(
|
||||
branch: str,
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
) -> bool:
|
||||
target = (branch or "").strip()
|
||||
if not target:
|
||||
return False
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if is_lease_live(lock):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_lock_for_mutation(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-check lock ownership immediately before a mutation (#438)."""
|
||||
reasons: list[str] = []
|
||||
if not lock_data:
|
||||
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
|
||||
|
||||
freshness = assess_lock_freshness(lock_data)
|
||||
if not freshness["live"]:
|
||||
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
|
||||
|
||||
if issue_number is not None and lock_data.get("issue_number") != issue_number:
|
||||
reasons.append(
|
||||
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
|
||||
)
|
||||
|
||||
if branch_name is not None and lock_data.get("branch_name") != branch_name:
|
||||
reasons.append(
|
||||
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
|
||||
f"'{branch_name}' (fail closed)"
|
||||
)
|
||||
|
||||
if worktree_path is not None:
|
||||
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
|
||||
declared = os.path.realpath(worktree_path)
|
||||
if locked != declared:
|
||||
reasons.append(
|
||||
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"freshness": freshness,
|
||||
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
|
||||
}
|
||||
|
||||
|
||||
def list_live_locks(
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return live per-issue locks for queue visibility."""
|
||||
live: list[dict[str, Any]] = []
|
||||
for path in iter_lock_files(lock_dir):
|
||||
record = read_lock_file(path)
|
||||
if not record:
|
||||
continue
|
||||
freshness = assess_lock_freshness(record, now=now)
|
||||
if not freshness["live"]:
|
||||
continue
|
||||
live.append(
|
||||
{
|
||||
"issue_number": record.get("issue_number"),
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": record.get("remote"),
|
||||
"org": record.get("org"),
|
||||
"repo": record.get("repo"),
|
||||
"worktree_path": record.get("worktree_path"),
|
||||
"pid": record.get("session_pid") or record.get("pid"),
|
||||
"claimant": (
|
||||
record.get("claimant")
|
||||
or (record.get("work_lease") or {}).get("claimant")
|
||||
),
|
||||
"freshness": freshness,
|
||||
"lock_path": record.get("lock_file_path") or path,
|
||||
}
|
||||
)
|
||||
return live
|
||||
|
||||
|
||||
def format_lock_proof(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
freshness: dict[str, Any] | None = None,
|
||||
competing_live_locks: list[dict[str, Any]] | None = None,
|
||||
released: bool | None = None,
|
||||
) -> str:
|
||||
"""Canonical issue-lock proof string for final reports."""
|
||||
if not lock_data:
|
||||
return "issue lock proof: not acquired"
|
||||
fresh = freshness or assess_lock_freshness(lock_data)
|
||||
owner = lock_data.get("claimant") or {}
|
||||
if not owner and isinstance(lock_data.get("work_lease"), dict):
|
||||
owner = lock_data["work_lease"].get("claimant") or {}
|
||||
parts = [
|
||||
"issue lock proof:",
|
||||
f"acquired issue #{lock_data.get('issue_number')}",
|
||||
f"branch {lock_data.get('branch_name')}",
|
||||
f"owner {owner.get('profile') or 'unknown'}",
|
||||
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
|
||||
f"freshness {fresh.get('status')}",
|
||||
]
|
||||
if competing_live_locks is not None:
|
||||
parts.append(
|
||||
"no competing live lock"
|
||||
if not competing_live_locks
|
||||
else f"competing live locks {len(competing_live_locks)}"
|
||||
)
|
||||
if released is True:
|
||||
parts.append("lock released")
|
||||
elif released is False:
|
||||
parts.append("lock retained")
|
||||
return "; ".join(parts)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Issue-lock worktree validation (#249).
|
||||
|
||||
Author issue locks must validate the caller's own scratch clone (or declared
|
||||
worktree path), not the shared MCP server working directory. A clean scratch at
|
||||
``master``/``main`` must remain lockable while an unrelated session leaves the
|
||||
shared dev worktree dirty or on a feature branch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
|
||||
def resolve_author_worktree_path(
|
||||
explicit: str | None,
|
||||
project_root: str,
|
||||
) -> str:
|
||||
"""Resolve the author worktree path for lock/PR gates."""
|
||||
path = (explicit or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = project_root
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree.
|
||||
|
||||
``extra_bases`` names additional branches (e.g. an approved stacked base)
|
||||
that may anchor base-equivalence in addition to master/main/dev. When empty
|
||||
(the default), only the normal base branches are considered.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
return {"current_branch": None, "porcelain_status": ""}
|
||||
|
||||
branch_res = subprocess.run(
|
||||
["git", "-C", path, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
current_branch = (branch_res.stdout or "").strip() or None
|
||||
|
||||
status_res = subprocess.run(
|
||||
["git", "-C", path, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
root_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
|
||||
"head_sha": head_sha,
|
||||
"base_branch": base_branch,
|
||||
"base_sha": base_sha,
|
||||
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
|
||||
}
|
||||
|
||||
|
||||
def assess_issue_lock_worktree(
|
||||
*,
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
base_equivalent: bool | None = None,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
reasons.append("worktree path not declared for issue lock; fail closed")
|
||||
return _assessment(False, reasons, path, None, [])
|
||||
|
||||
branch = (current_branch or "").strip()
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"tracked file edits exist before issue lock; "
|
||||
f"lock must precede implementation work in '{path}' "
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if base_equivalent is False:
|
||||
reasons.append(
|
||||
"issue lock worktree must be base-equivalent to one of "
|
||||
f"{_base_list(bases)} before implementation work; inspected "
|
||||
f"branch '{branch or '(detached)'}' at '{path}'"
|
||||
)
|
||||
elif base_equivalent is None:
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"current branch unknown (detached HEAD?); issue lock base-equivalence "
|
||||
f"to {_base_list(bases)} could not be proven"
|
||||
)
|
||||
elif branch not in bases:
|
||||
reasons.append(
|
||||
"issue lock worktree base-equivalence could not be proven; "
|
||||
f"branch '{branch}' is not {_base_list(bases)}"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(
|
||||
proven,
|
||||
reasons,
|
||||
path,
|
||||
branch or None,
|
||||
dirty_files,
|
||||
inspected_git_root=inspected_git_root,
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
)
|
||||
|
||||
|
||||
def format_issue_lock_worktree_error(assessment: dict) -> str:
|
||||
"""Format a single fail-closed error for ``gitea_lock_issue``."""
|
||||
reasons = list(assessment.get("reasons") or [])
|
||||
if not reasons:
|
||||
reasons = ["issue lock worktree validation failed"]
|
||||
return "; ".join(reasons) + " (fail closed)"
|
||||
|
||||
|
||||
def verify_pr_worktree_matches_lock(
|
||||
locked_worktree_path: str | None,
|
||||
declared_worktree_path: str | None,
|
||||
project_root: str,
|
||||
) -> dict:
|
||||
"""PR creation must use the same worktree the lock was validated against."""
|
||||
locked = (locked_worktree_path or "").strip()
|
||||
if not locked:
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
declared = resolve_author_worktree_path(declared_worktree_path, project_root)
|
||||
locked_real = os.path.realpath(locked)
|
||||
declared_real = os.path.realpath(declared)
|
||||
if locked_real != declared_real:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"PR worktree '{declared_real}' does not match locked worktree "
|
||||
f"'{locked_real}' (fail closed)"
|
||||
],
|
||||
"locked_worktree_path": locked_real,
|
||||
"declared_worktree_path": declared_real,
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"locked_worktree_path": locked_real,
|
||||
"declared_worktree_path": declared_real,
|
||||
}
|
||||
|
||||
|
||||
def _base_list(bases: frozenset[str]) -> str:
|
||||
return "/".join(sorted(bases))
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
dirty_files: list[str],
|
||||
*,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"worktree_path": worktree_path or None,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"current_branch": current_branch,
|
||||
"dirty_files": dirty_files,
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
}
|
||||
|
||||
|
||||
def _find_matching_base_ref(
|
||||
path: str,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any.
|
||||
|
||||
Normal base branches (master/main/dev) are always considered. ``extra_bases``
|
||||
adds explicitly-approved stacked bases; each is checked as a local ref and via
|
||||
the ``prgs``/``origin`` remotes.
|
||||
"""
|
||||
if not head_sha:
|
||||
return None, None
|
||||
candidates: list[str] = []
|
||||
for branch in sorted(BASE_BRANCHES):
|
||||
candidates.extend((f"origin/{branch}", branch))
|
||||
for branch in extra_bases:
|
||||
name = (branch or "").strip()
|
||||
if name:
|
||||
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
|
||||
for ref in candidates:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
sha = (res.stdout or "").strip()
|
||||
if res.returncode == 0 and sha == head_sha:
|
||||
return ref, sha
|
||||
return None, None
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Early duplicate-work detection for author work-issue sessions (#400)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
PHASE_LOCK = "lock_issue"
|
||||
PHASE_COMMIT = "commit"
|
||||
PHASE_PUSH = "push"
|
||||
PHASE_CREATE_PR = "create_pr"
|
||||
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
|
||||
|
||||
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
|
||||
|
||||
|
||||
def _issue_pattern(issue_number: int) -> str:
|
||||
return f"issue-{int(issue_number)}"
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
*,
|
||||
locked_branch: str | None = None,
|
||||
) -> list[str]:
|
||||
pattern = _issue_pattern(issue_number)
|
||||
matches = [
|
||||
name for name in (branch_names or [])
|
||||
if pattern in (name or "").lower()
|
||||
]
|
||||
if locked_branch:
|
||||
locked = locked_branch.strip()
|
||||
matches = [name for name in matches if name != locked]
|
||||
return matches
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
||||
reasons: list[str] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
branches = list(branch_names or [])
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
linked = _linked_open_pr(issue_number, prs)
|
||||
if linked:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
conflicting_branches = _matching_branches(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
)
|
||||
if conflicting_branches:
|
||||
names = ", ".join(conflicting_branches[:5])
|
||||
reasons.append(
|
||||
f"remote branch(es) already match issue pattern '{pattern}': "
|
||||
f"{names} (fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
entry = claim_entry or {}
|
||||
if entry.get("linked_open_pr") and not linked:
|
||||
reasons.append(
|
||||
f"claim inventory reports open PR #{entry['linked_open_pr']} "
|
||||
f"for issue #{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
status = (entry.get("status") or "").strip().lower()
|
||||
if status in _ACTIVE_CLAIM_STATUSES and not linked:
|
||||
heartbeat = entry.get("latest_heartbeat") or {}
|
||||
claim_branch = (heartbeat.get("branch") or "").strip()
|
||||
if locked_branch and claim_branch and claim_branch != locked_branch:
|
||||
reasons.append(
|
||||
f"active claim lease on branch '{claim_branch}' blocks "
|
||||
f"work on '{locked_branch}' for issue #{issue_number} "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
elif not locked_branch and status == "active":
|
||||
reasons.append(
|
||||
f"issue #{issue_number} has an active claim lease "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
|
||||
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"performed": not block,
|
||||
"issue_number": issue_number,
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||
"conflicting_branches": conflicting_branches,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"stop before mutating; preserve local work and produce a "
|
||||
"reconciliation handoff if a concurrent PR appeared after push"
|
||||
if block and phase == PHASE_CREATE_PR
|
||||
else "stop before mutating; do not commit or push duplicate work"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
|
||||
"""Require explicit duplicate-work outcome wording in work-issue reports."""
|
||||
text = (report_text or "").lower()
|
||||
markers = {
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED: (
|
||||
"duplicate pr prevented",
|
||||
"duplicate_pr_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
|
||||
"duplicate branch prevented",
|
||||
"duplicate_branch_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
|
||||
"duplicate commit prevented",
|
||||
"duplicate_commit_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
|
||||
"duplicate work not prevented",
|
||||
"duplicate_work_not_prevented",
|
||||
"no duplicate work",
|
||||
),
|
||||
}
|
||||
matched = [
|
||||
key for key, phrases in markers.items()
|
||||
if any(phrase in text for phrase in phrases)
|
||||
]
|
||||
if len(matched) != 1:
|
||||
return {
|
||||
"complete": False,
|
||||
"downgraded": True,
|
||||
"reasons": [
|
||||
"work-issue report must state exactly one duplicate-work "
|
||||
"outcome (duplicate PR/branch/commit prevented, or "
|
||||
"duplicate work not prevented)"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"complete": True,
|
||||
"downgraded": False,
|
||||
"outcome": matched[0],
|
||||
"reasons": [],
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
+2
-5
@@ -23,7 +23,6 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
os.execv(venv_python, [venv_python] + sys.argv)
|
||||
|
||||
from gitea_auth import get_auth_header, api_request, repo_api_url
|
||||
import issue_workflow_labels
|
||||
|
||||
HOST = "gitea.dadeschools.net"
|
||||
ORG = "Contractor"
|
||||
@@ -36,10 +35,8 @@ LABELS = [
|
||||
{"name": "epic", "color": "8250df", "description": ""},
|
||||
{"name": "important", "color": "fbca04", "description": ""},
|
||||
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
|
||||
*[
|
||||
{"name": spec.name, "color": spec.color, "description": spec.description}
|
||||
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS
|
||||
],
|
||||
{"name": "status:in-progress", "color": "fefe2e",
|
||||
"description": "Issue is being worked on"},
|
||||
]
|
||||
|
||||
# issue number -> label names to apply (one-off backfill)
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
"""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
|
||||
import time
|
||||
|
||||
# Live-remote head cache: the parity gate runs on every mutation and every
|
||||
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
|
||||
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
|
||||
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
|
||||
_REMOTE_HEAD_TTL = 60.0
|
||||
|
||||
|
||||
def _clear_remote_head_cache() -> None:
|
||||
"""Reset the live-remote head cache (test isolation / forced refresh)."""
|
||||
_REMOTE_HEAD_CACHE.clear()
|
||||
|
||||
# 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"
|
||||
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
|
||||
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_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 read_remote_master_head(
|
||||
root: str,
|
||||
remote: str = "origin",
|
||||
branch: str = "master",
|
||||
ttl: float = _REMOTE_HEAD_TTL,
|
||||
) -> str | None:
|
||||
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
|
||||
|
||||
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
|
||||
a daemon that is behind the live remote master apart from one whose local
|
||||
checkout simply hasn't been pulled. ``None`` means the live head could not
|
||||
be resolved (offline, no such remote, git unavailable, error) -- callers
|
||||
must treat unknown live state as *not mutation-safe* while never blocking
|
||||
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
|
||||
precedence so the wiring can be exercised deterministically and offline.
|
||||
|
||||
The result is cached for *ttl* seconds per (root, remote, branch) so the
|
||||
gate does not run a network probe on every mutation/read (``ttl=0`` forces
|
||||
a live probe). Both hits and ``None`` misses are cached to bound offline
|
||||
latency; the env override bypasses the cache and the subprocess entirely.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if not root:
|
||||
return None
|
||||
key = (root, remote, branch)
|
||||
now = time.monotonic()
|
||||
if ttl > 0:
|
||||
cached = _REMOTE_HEAD_CACHE.get(key)
|
||||
if cached is not None and (now - cached[0]) < ttl:
|
||||
return cached[1]
|
||||
sha: str | None = None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
lines = (res.stdout or "").strip().splitlines()
|
||||
if lines:
|
||||
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
|
||||
except Exception:
|
||||
sha = None
|
||||
_REMOTE_HEAD_CACHE[key] = (now, sha)
|
||||
return sha
|
||||
|
||||
|
||||
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,
|
||||
live_remote_head: str | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: all 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`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``determinable`` -- whether both local HEADs were known well enough to
|
||||
compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
|
||||
#610 adds live-remote awareness so a daemon that is stale relative to the
|
||||
*live* remote master cannot report a mutation-safe result even when the
|
||||
local checkout HEAD still matches the daemon's startup commit:
|
||||
|
||||
- ``daemon_start_head`` -- the commit the running process started at
|
||||
(alias of ``startup_head``, named for clarity in reports).
|
||||
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
|
||||
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
|
||||
could not be fetched.
|
||||
- ``live_known`` -- whether the live remote target was resolved.
|
||||
- ``live_stale`` -- the live remote master has advanced past the running
|
||||
process (daemon is behind live master) even if local parity is green.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
|
||||
target all agree; the only state in which a mutation may rely on parity.
|
||||
"""
|
||||
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,
|
||||
live_remote_head, False, 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,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
local_in_parity = startup_head == current_head
|
||||
local_stale = not local_in_parity
|
||||
if local_stale:
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the "
|
||||
f"workspace master is now {_short(current_head)}; restart the "
|
||||
f"server to load the current capability gates")
|
||||
|
||||
live_known = live_remote_head is not None
|
||||
live_stale = live_known and live_remote_head != startup_head
|
||||
if live_stale:
|
||||
reasons.append(
|
||||
f"live remote master is {_short(live_remote_head)} but the MCP "
|
||||
f"server started at {_short(startup_head)}; the daemon is stale "
|
||||
f"relative to live master -- restart/reconnect before mutating")
|
||||
|
||||
return _result(
|
||||
local_in_parity, local_stale, True, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons):
|
||||
live_known = live_remote_head is not None
|
||||
mutation_safe = (
|
||||
determinable and in_parity and live_known and not live_stale)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale or live_stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
# #610 distinguished signals:
|
||||
"daemon_start_head": startup_head,
|
||||
"local_head": current_head,
|
||||
"live_remote_head": live_remote_head,
|
||||
"live_known": live_known,
|
||||
"live_stale": live_stale,
|
||||
"mutation_safe": mutation_safe,
|
||||
"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. A definitively stale server blocks, and (#610) a daemon that is
|
||||
stale relative to the *live* remote master blocks even when the local
|
||||
checkout HEAD still matches the daemon's startup commit.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale") or assessment.get("live_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"),
|
||||
# #610: name the live remote target so the report distinguishes a
|
||||
# local-code stale from a daemon-behind-live-master stale.
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"live_stale": bool(assessment.get("live_stale")),
|
||||
"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 parity_resolver_disagreement(
|
||||
assessment: dict,
|
||||
resolver_restart_required: bool,
|
||||
) -> dict | None:
|
||||
"""Typed blocker when the resolver requires restart but parity looks green.
|
||||
|
||||
The capability resolver (``gitea_resolve_task_capability``) detects stale
|
||||
runtime authoritatively for mutation safety (#610). When it requires a
|
||||
restart, local-only parity must never override it: this returns a typed,
|
||||
fail-closed blocker that names the resolver as authoritative. Returns
|
||||
``None`` when the resolver does not require a restart.
|
||||
"""
|
||||
if not resolver_restart_required:
|
||||
return None
|
||||
parity_optimistic = bool(assessment.get("in_parity")) and not (
|
||||
assessment.get("stale") or assessment.get("live_stale"))
|
||||
return {
|
||||
"kind": "parity_resolver_disagreement",
|
||||
"restart_required": True,
|
||||
"resolver_authoritative": True,
|
||||
"parity_optimistic": parity_optimistic,
|
||||
"daemon_start_head": assessment.get("daemon_start_head"),
|
||||
"local_head": assessment.get("local_head"),
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"reasons": [
|
||||
"The capability resolver requires a restart/reconnect (stale "
|
||||
"runtime) but master-parity reported local code as in-parity. "
|
||||
"The resolver is authoritative for mutation safety; do not mutate "
|
||||
"on local parity alone. Restart/reconnect the Gitea MCP server "
|
||||
"and re-verify before mutating.",
|
||||
],
|
||||
"recovery": [
|
||||
"Trust the resolver: treat this session as stale.",
|
||||
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
|
||||
"current master and live target state, then re-run preflight.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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'))}"
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding.
|
||||
#
|
||||
# Safe by default: read-only status and copy-paste prompts unless an action is
|
||||
# explicitly labeled and confirmed. No branch deletion, force-push, lock-file
|
||||
# editing, or raw API bypass.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
|
||||
pause() {
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
printf '\n=== Gitea-Tools MCP Operator Menu ===\n'
|
||||
printf 'Repository: %s\n' "$REPO_ROOT"
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
local branch head_sha prgs_master_sha dirty
|
||||
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="(detached HEAD)"
|
||||
fi
|
||||
printf 'Current branch: %s\n' "$branch"
|
||||
printf '\nGit status (short, branch):\n'
|
||||
git -C "$REPO_ROOT" status --short --branch || true
|
||||
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"
|
||||
printf '\nHEAD SHA: %s\n' "$head_sha"
|
||||
if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||
prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)"
|
||||
printf 'prgs/master SHA: %s\n' "$prgs_master_sha"
|
||||
if [[ "$head_sha" != "$prgs_master_sha" ]]; then
|
||||
printf '\nWARNING: root checkout HEAD does not match prgs/master.\n'
|
||||
printf 'Keep the stable control checkout on master/prgs/master.\n'
|
||||
fi
|
||||
else
|
||||
printf 'prgs/master SHA: unavailable (remote ref not fetched)\n'
|
||||
fi
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
printf '\nWARNING: root checkout has uncommitted changes (dirty).\n'
|
||||
printf 'Author mutations belong in a session worktree under branches/.\n'
|
||||
fi
|
||||
if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then
|
||||
printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n'
|
||||
printf 'Return to master before using the control checkout.\n'
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
print_prompt_block() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
printf '\n--- %s ---\n\n' "$title"
|
||||
printf '%s\n' "$body"
|
||||
printf '\n(Copy the prompt above into your LLM session.)\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_author_prompts() {
|
||||
while true; do
|
||||
printf '\n--- Author workflow prompts ---\n'
|
||||
printf ' 1) Work issue (author/coder)\n'
|
||||
printf ' 2) Conflict-fix author session\n'
|
||||
printf ' 3) Root checkout recovery session\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Author — work issue" \
|
||||
"You are the AUTHOR session for <org>/<repo>.
|
||||
|
||||
Goal: implement issue #<N> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/.
|
||||
2. gitea_lock_issue for issue #<N> and branch feat/issue-<N>-<short-desc>.
|
||||
3. Implement in the locked worktree only — never mutate the root control checkout.
|
||||
4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger."
|
||||
;;
|
||||
2)
|
||||
print_prompt_block "Author — conflict-fix session" \
|
||||
"You are the AUTHOR session for <org>/<repo> in conflict-fix mode.
|
||||
|
||||
Goal: resolve merge conflicts on PR #<P> / branch <branch> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove author identity and exact push/commit capability for the locked PR branch.
|
||||
2. Confirm conflict-fix lease and stale-head protection before pushing.
|
||||
3. Work only in the session-owned worktree under branches/ — never the root checkout.
|
||||
4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval.
|
||||
5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger."
|
||||
;;
|
||||
3)
|
||||
print_prompt_block "Author — root checkout recovery" \
|
||||
"You are a RECOVERY session for <org>/<repo>.
|
||||
|
||||
Goal: restore the stable root control checkout to clean master/prgs/master.
|
||||
|
||||
Workflow:
|
||||
1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master.
|
||||
2. Do not implement features from the root checkout. Stash or move work to branches/<session> first.
|
||||
3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files.
|
||||
4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation."
|
||||
;;
|
||||
0) return ;;
|
||||
*) printf 'Invalid choice.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_reviewer_prompts() {
|
||||
while true; do
|
||||
printf '\n--- Reviewer workflow prompts ---\n'
|
||||
printf ' 1) Standard PR review\n'
|
||||
printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Reviewer — PR review" \
|
||||
"You are the REVIEWER session for <org>/<repo>.
|
||||
|
||||
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove reviewer identity, review_pr capability, clean review 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.
|
||||
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() {
|
||||
print_prompt_block "Merger — PR merge" \
|
||||
"You are the MERGER session for <org>/<repo>.
|
||||
|
||||
Goal: merge PR #<P> only after every gate passes.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA.
|
||||
3. Confirm approval pins the current head SHA; re-validate if the branch moved.
|
||||
4. gitea_merge_pr only on explicit operator approval after all gates pass.
|
||||
5. Final report: merged SHA, cleanup handoff, mutation ledger."
|
||||
}
|
||||
|
||||
show_reconciler_prompts() {
|
||||
print_prompt_block "Reconciler — already-landed / closed PR cleanup" \
|
||||
"You are the RECONCILER session for <org>/<repo>.
|
||||
|
||||
Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md
|
||||
2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability.
|
||||
3. Do not review, merge, implement code, or create branches.
|
||||
4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate.
|
||||
5. Final report: PR numbers handled, close proof, mutation ledger."
|
||||
}
|
||||
|
||||
show_onboarding_prompt() {
|
||||
print_prompt_block "Onboarding — new project to MCP workflow" \
|
||||
"Onboard <org>/<repo> into the MCP Control Plane workflow.
|
||||
|
||||
Checklist:
|
||||
1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability.
|
||||
2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed.
|
||||
3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config.
|
||||
4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install.
|
||||
5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/.
|
||||
6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md.
|
||||
|
||||
Canonical router: skills/llm-project-workflow/SKILL.md"
|
||||
}
|
||||
|
||||
show_proxmox_placeholder() {
|
||||
printf '\n--- Proxmox deployment (placeholder) ---\n\n'
|
||||
printf 'Push this project to Proxmox — TODO / issue-backed\n'
|
||||
printf 'Create Proxmox LXC — TODO / issue-backed\n\n'
|
||||
printf 'These actions are NOT implemented yet.\n'
|
||||
printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n'
|
||||
printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
printf '\n--- Run tests ---\n\n'
|
||||
if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then
|
||||
printf 'Running ./run-tests.sh ...\n\n'
|
||||
(cd "$REPO_ROOT" && ./run-tests.sh)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then
|
||||
printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n'
|
||||
(cd "$REPO_ROOT" && ./venv/bin/python -m pytest)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
printf 'ERROR: No test runner available (fail closed).\n' >&2
|
||||
printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main_menu
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP Discoverability Validation Tool for external servers (Issue #155)."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
EXPECTED_JENKINS_TOOLS = {
|
||||
"jenkins_whoami",
|
||||
"jenkins_list_jobs",
|
||||
"jenkins_latest_build",
|
||||
"jenkins_build_status",
|
||||
"jenkins_get_build",
|
||||
}
|
||||
|
||||
EXPECTED_GLITCHTIP_TOOLS = {
|
||||
"glitchtip_whoami",
|
||||
"glitchtip_list_projects",
|
||||
"glitchtip_list_unresolved",
|
||||
"glitchtip_get_issue",
|
||||
"glitchtip_recent_events",
|
||||
"glitchtip_search",
|
||||
}
|
||||
|
||||
RELOAD_INSTRUCTIONS = """
|
||||
=== MCP CLIENT RELOAD/RECONNECT RUNBOOK ===
|
||||
After registering or changing external MCP servers, reload your client to discover the new tools:
|
||||
- Codex: Click 'Reload Developer Tools' or restart the editor.
|
||||
- Gemini / Grok / ChatGPT Desktop: Restart the client or run the reload slash command if available.
|
||||
- Claude Desktop: Use 'Developer -> Reload' or restart the app.
|
||||
- General MCP Clients: Restart the process or reload the server config.
|
||||
===========================================
|
||||
"""
|
||||
|
||||
def parse_gitea_mcp_config(path):
|
||||
if not path or not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def read_json_rpc_response(proc, req_id):
|
||||
import time
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 5.0:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("id") == req_id:
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def query_live_tools(command, args, env):
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[command] + args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=run_env,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
tools = []
|
||||
try:
|
||||
# 1. Send initialize
|
||||
init_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "mcp-discoverability-check", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Read init response
|
||||
init_resp = read_json_rpc_response(proc, 1)
|
||||
if init_resp:
|
||||
# Send initialized notification
|
||||
init_notif = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
}
|
||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# 2. Send tools/list
|
||||
tools_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
proc.stdin.write(json.dumps(tools_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
tools_resp = read_json_rpc_response(proc, 2)
|
||||
if tools_resp and "result" in tools_resp and "tools" in tools_resp["result"]:
|
||||
for t in tools_resp["result"]["tools"]:
|
||||
tools.append(t["name"])
|
||||
except Exception as e:
|
||||
print(f"Error querying live tools: {e}", file=sys.stderr)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
return set(tools)
|
||||
|
||||
def validate_mcp_client_config(client_config_path, gitea_config_path=None, live=False):
|
||||
if not client_config_path or not os.path.exists(client_config_path):
|
||||
print(f"SKIPPED: MCP client config not found at '{client_config_path}'", file=sys.stderr)
|
||||
return True
|
||||
|
||||
with open(client_config_path, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
config_data = json.load(fh)
|
||||
except Exception as e:
|
||||
print(f"Error parsing client config: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
mcp_servers = config_data.get("mcpServers", {})
|
||||
|
||||
# Check for stale server names
|
||||
stale_names = {"jenkins-readonly", "glitchtip-readonly"}
|
||||
for name in mcp_servers:
|
||||
if name in stale_names:
|
||||
print(f"ERROR: Stale server name '{name}' configured. Use canonical names 'jenkins-mcp' or 'glitchtip-mcp'.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
gitea_data = parse_gitea_mcp_config(gitea_config_path)
|
||||
enabled_services = set()
|
||||
contexts = gitea_data.get("contexts", {})
|
||||
|
||||
profile_name = os.environ.get("GITEA_MCP_PROFILE")
|
||||
if profile_name and "profiles" in gitea_data:
|
||||
profile = gitea_data["profiles"].get(profile_name)
|
||||
if profile and "context" in profile:
|
||||
ctx_name = profile["context"]
|
||||
ctx = contexts.get(ctx_name, {})
|
||||
if ctx.get("enabled"):
|
||||
services = ctx.get("services", {})
|
||||
for s_name, s_data in services.items():
|
||||
if s_data.get("enabled"):
|
||||
enabled_services.add(s_name)
|
||||
else:
|
||||
for ctx_name, ctx in contexts.items():
|
||||
if ctx.get("enabled"):
|
||||
services = ctx.get("services", {})
|
||||
for s_name, s_data in services.items():
|
||||
if s_data.get("enabled"):
|
||||
enabled_services.add(s_name)
|
||||
|
||||
if not enabled_services:
|
||||
print("No external services enabled in Gitea contexts. Discoverability check complete.", file=sys.stderr)
|
||||
return True
|
||||
|
||||
success = True
|
||||
for service in enabled_services:
|
||||
canonical_name = f"{service}-mcp"
|
||||
if canonical_name not in mcp_servers:
|
||||
stale_match = f"{service}-readonly"
|
||||
if stale_match in mcp_servers:
|
||||
print(f"ERROR: Server '{canonical_name}' references stale name '{stale_match}' (fail closed).", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
print(f"ERROR: Enabled service '{service}' is not registered under canonical name '{canonical_name}' in client config.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
server_conf = mcp_servers[canonical_name]
|
||||
command = server_conf.get("command")
|
||||
args = server_conf.get("args") or []
|
||||
env = server_conf.get("env") or {}
|
||||
|
||||
if not command:
|
||||
print(f"ERROR: Server '{canonical_name}' has no command configured.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
expected_module = f"{service}_mcp"
|
||||
if "-m" not in args or expected_module not in args:
|
||||
print(f"ERROR: Server '{canonical_name}' args do not point to expected module '{expected_module}' (args: {args}).", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
profile_var = f"{service.upper()}_MCP_PROFILE"
|
||||
config_var = f"{service.upper()}_MCP_CONFIG"
|
||||
if profile_var not in env or config_var not in env:
|
||||
print(f"ERROR: Server '{canonical_name}' env is missing required variables '{profile_var}' or '{config_var}'.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
if live:
|
||||
tools = query_live_tools(command, args, env)
|
||||
if not tools:
|
||||
print("SKIPPED: server enabled but no usable tools visible", file=sys.stdout)
|
||||
success = False
|
||||
continue
|
||||
|
||||
expected = EXPECTED_JENKINS_TOOLS if service == "jenkins" else EXPECTED_GLITCHTIP_TOOLS
|
||||
missing = expected - tools
|
||||
if missing:
|
||||
print(f"ERROR: Server '{canonical_name}' is missing expected tools: {', '.join(missing)}", file=sys.stderr)
|
||||
success = False
|
||||
else:
|
||||
print(f"SUCCESS: Server '{canonical_name}' discoverability verified.", file=sys.stderr)
|
||||
else:
|
||||
print(f"SUCCESS: Server '{canonical_name}' static registration verified.", file=sys.stderr)
|
||||
|
||||
return success
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MCP client registration discoverability checks.")
|
||||
parser.add_argument("--client-config", help="Path to MCP client config JSON file.")
|
||||
parser.add_argument("--gitea-config", help="Path to Gitea MCP config JSON file.")
|
||||
parser.add_argument("--live", action="store_true", help="Perform live stdio checks on configured servers.")
|
||||
parser.add_argument("--runbook", action="store_true", help="Print reload/reconnect guide runbook instructions.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.runbook:
|
||||
print(RELOAD_INSTRUCTIONS)
|
||||
return 0
|
||||
|
||||
if not args.client_config:
|
||||
print("ERROR: --client-config must be specified.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ok = validate_mcp_client_config(
|
||||
client_config_path=args.client_config,
|
||||
gitea_config_path=args.gitea_config,
|
||||
live=args.live
|
||||
)
|
||||
|
||||
if not ok:
|
||||
print(RELOAD_INSTRUCTIONS, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,359 +0,0 @@
|
||||
"""MCP-native post-merge cleanup proof verifier (#517).
|
||||
|
||||
Post-merge cleanup of leases, comments, branches, and worktrees must be
|
||||
proven through explicit MCP tools (or approved helpers), never raw scripts or
|
||||
ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a
|
||||
reconciler profile with the right capability proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
||||
"gitea_cleanup_post_merge_moot_lease",
|
||||
"gitea_reconcile_merged_cleanups",
|
||||
"gitea_delete_branch",
|
||||
"gitea_cleanup_merged_pr_branch",
|
||||
"gitea_audit_worktree_cleanup",
|
||||
"gitea_capture_branches_worktree_snapshot",
|
||||
"gitea_assess_worktree_cleanup_integrity",
|
||||
"gitea_authorize_reconciliation_cleanup_phase",
|
||||
})
|
||||
|
||||
_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),
|
||||
)
|
||||
|
||||
_RAW_COMMENT_DELETE_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*"
|
||||
r"(?:/comments/|issues/\d+/comments)",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete",
|
||||
re.I,
|
||||
),
|
||||
)
|
||||
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_WORKTREE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"})
|
||||
_MUTATION_LEDGER_PATTERNS = (
|
||||
_CLEANUP_MUTATIONS_RE,
|
||||
_GIT_REF_MUTATIONS_RE,
|
||||
_WORKTREE_MUTATIONS_RE,
|
||||
)
|
||||
_RAW_WORKTREE_REMOVE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I),
|
||||
)
|
||||
_AUTHORIZED_TOOL_RE = re.compile(
|
||||
r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RECONCILER_CAPABILITY_RE = re.compile(
|
||||
r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGER_CLEANUP_ROLE_RE = re.compile(
|
||||
r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}"
|
||||
r"(?:branch|worktree|comment|lease)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HANDOFF_TO_RECONCILER_RE = re.compile(
|
||||
r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None:
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return (match.group(1) or "").strip()
|
||||
|
||||
|
||||
def _is_none_ledger_value(value: str) -> bool:
|
||||
return value.strip().lower() in _NONE_LEDGER_VALUES
|
||||
|
||||
|
||||
def scoped_cleanup_mutation_ledger_text(text: str | None) -> str:
|
||||
"""Return mutation-ledger bodies scoped to cleanup enforcement (#517)."""
|
||||
text = text or ""
|
||||
parts: list[str] = []
|
||||
for pattern in _MUTATION_LEDGER_PATTERNS:
|
||||
body = _ledger_field_body(text, pattern)
|
||||
if body is not None and not _is_none_ledger_value(body):
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _git_ref_cleanup_claimed(body: str) -> bool:
|
||||
return bool(raw_branch_delete_commands(body))
|
||||
|
||||
|
||||
def _worktree_cleanup_claimed(body: str) -> bool:
|
||||
for pattern in _RAW_WORKTREE_REMOVE_PATTERNS:
|
||||
if pattern.search(body):
|
||||
return True
|
||||
return bool(
|
||||
re.search(
|
||||
r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b",
|
||||
body,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]:
|
||||
claiming: list[tuple[str, str]] = []
|
||||
cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE)
|
||||
if cleanup_body is not None and not _is_none_ledger_value(cleanup_body):
|
||||
claiming.append(("Cleanup mutations", cleanup_body))
|
||||
|
||||
git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE)
|
||||
if git_ref_body is not None and not _is_none_ledger_value(git_ref_body):
|
||||
if _git_ref_cleanup_claimed(git_ref_body):
|
||||
claiming.append(("Git ref mutations", git_ref_body))
|
||||
|
||||
worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE)
|
||||
if worktree_body is not None and not _is_none_ledger_value(worktree_body):
|
||||
if _worktree_cleanup_claimed(worktree_body):
|
||||
claiming.append(("Worktree mutations", worktree_body))
|
||||
return claiming
|
||||
|
||||
|
||||
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 raw_comment_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw comment-deletion commands/scripts cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_COMMENT_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 mutation ledgers cite raw git branch deletion."""
|
||||
commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(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_delete_branch, gitea_reconcile_merged_cleanups, or another "
|
||||
"approved MCP cleanup helper with explicit branch.delete capability"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when mutation ledgers cite raw comment deletion."""
|
||||
commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
||||
reasons = [
|
||||
(
|
||||
"raw comment deletion bypasses MCP lease 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_post_merge_moot_lease (append-only release comment) "
|
||||
"or hand cleanup to a reconciler session; never delete lease comments"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]:
|
||||
"""Require merge mutations and cleanup mutations in separate ledger fields."""
|
||||
text = text or ""
|
||||
merge_match = _MERGE_MUTATIONS_RE.search(text)
|
||||
cleanup_match = _CLEANUP_MUTATIONS_RE.search(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
if cleanup_match and not merge_match:
|
||||
combined = re.search(
|
||||
r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if combined:
|
||||
reasons.append(
|
||||
"merge and cleanup mutations must use separate 'Merge mutations' "
|
||||
"and 'Cleanup mutations' ledger fields"
|
||||
)
|
||||
|
||||
if merge_match and cleanup_match:
|
||||
merge_val = (merge_match.group(1) or "").strip().lower()
|
||||
cleanup_val = (cleanup_match.group(1) or "").strip().lower()
|
||||
if merge_val == cleanup_val and merge_val not in {"", "none"}:
|
||||
reasons.append(
|
||||
"merge mutations and cleanup mutations must not duplicate the "
|
||||
"same ledger entry"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"split merge mutations (gitea_merge_pr) from cleanup mutations "
|
||||
"(reconciler MCP tools) in the controller handoff"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]:
|
||||
"""Validate cleanup claims cite authorized MCP tools and reconciler capability."""
|
||||
text = text or ""
|
||||
claiming = _cleanup_claiming_ledger_fields(text)
|
||||
if not claiming:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
for field_name, body in claiming:
|
||||
if not _AUTHORIZED_TOOL_RE.search(body):
|
||||
reasons.append(
|
||||
f"{field_name} cleanup must name an authorized MCP cleanup tool "
|
||||
f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})"
|
||||
)
|
||||
if not _RECONCILER_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"post-merge cleanup requires reconciler capability proof "
|
||||
"(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"hand cleanup to a prgs-reconciler session and cite the exact MCP tool "
|
||||
"plus delete_branch/reconcile_merged_cleanups capability proof"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]:
|
||||
"""Merger sessions that performed cleanup must hand off to reconciler."""
|
||||
text = text or ""
|
||||
if not _MERGER_CLEANUP_ROLE_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
if _HANDOFF_TO_RECONCILER_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"merger/reviewer session performed cleanup but did not hand off to "
|
||||
"reconciler for MCP-native cleanup"
|
||||
],
|
||||
"safe_next_action": (
|
||||
"merger sessions must end with cleanup handed to prgs-reconciler; "
|
||||
"do not perform ad hoc branch/comment/worktree cleanup as merger"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]:
|
||||
"""Composite #517 verifier for MCP-native post-merge cleanup proof."""
|
||||
text = report_text or ""
|
||||
checks = (
|
||||
assess_raw_branch_delete_report(text),
|
||||
assess_raw_comment_delete_report(text),
|
||||
assess_merge_cleanup_mutation_separation(text),
|
||||
assess_authorized_reconciler_cleanup_path(text),
|
||||
assess_merger_cleanup_handoff_guidance(text),
|
||||
)
|
||||
reasons: list[str] = []
|
||||
safe_next = "proceed"
|
||||
for result in checks:
|
||||
reasons.extend(result.get("reasons") or [])
|
||||
if result.get("block") and result.get("safe_next_action"):
|
||||
safe_next = result["safe_next_action"]
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"raw_branch_commands": raw_branch_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
"raw_comment_commands": raw_comment_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
}
|
||||
+10
-19
@@ -6,24 +6,26 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
||||
import os
|
||||
import sys
|
||||
|
||||
from role_session_router import (
|
||||
python_bytes_have_conflict_markers,
|
||||
skip_python_scan_walk_root,
|
||||
)
|
||||
|
||||
|
||||
# Startup health check: scan all python files in the Gitea-Tools directory for unresolved conflict markers.
|
||||
def check_conflict_markers():
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
# Construct conflict patterns dynamically so the loader does not match itself
|
||||
conflict_patterns = [
|
||||
b"<" * 7 + b" ",
|
||||
b"=" * 7 + b"\n",
|
||||
b"=" * 7 + b"\r\n",
|
||||
b">" * 7 + b" "
|
||||
]
|
||||
for root, dirs, files in os.walk(dir_path):
|
||||
if skip_python_scan_walk_root(dir_path, root):
|
||||
if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")):
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith(".py"):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
if python_bytes_have_conflict_markers(f.read()):
|
||||
content = f.read()
|
||||
if any(pattern in content for pattern in conflict_patterns):
|
||||
rel_path = os.path.relpath(file_path, dir_path)
|
||||
print(
|
||||
f"infra_stop: Unresolved merge conflict detected in {rel_path}. "
|
||||
@@ -37,17 +39,6 @@ def check_conflict_markers():
|
||||
|
||||
check_conflict_markers()
|
||||
|
||||
# #558: official entrypoint marks the process as the sanctioned MCP daemon
|
||||
# before loading mutation modules (blocks raw shell import bypasses).
|
||||
try:
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.mark_sanctioned_daemon()
|
||||
except Exception:
|
||||
# Guard import failures must not hide conflict-marker infra_stop above;
|
||||
# gitea_mcp_server main also marks sanctioned when run as __main__.
|
||||
pass
|
||||
|
||||
# Execute the actual server logic via exec in this namespace.
|
||||
impl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_mcp_server.py")
|
||||
try:
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""Durable MCP session validation state shared across daemon process pools (#559).
|
||||
|
||||
The IDE often routes sequential MCP tool calls to different daemon processes.
|
||||
Session-scoped proofs (workflow load, review decision lock) must therefore
|
||||
survive process boundaries while remaining fail-closed against spoofing.
|
||||
|
||||
Security notes (extends #211):
|
||||
- Never store under host-global ``/tmp`` (world-writable, spoofable).
|
||||
- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``).
|
||||
- Files are written atomically with mode ``0o600``.
|
||||
- Records are keyed by remote + org + repo + profile identity, not by PID.
|
||||
- TTL prevents indefinitely stale reuse across unrelated sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||
DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||
TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS"
|
||||
DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
KIND_DECISION_LOCK = "review_decision_lock"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
|
||||
def default_state_dir() -> str:
|
||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def ttl_hours() -> float:
|
||||
raw = (os.environ.get(TTL_HOURS_ENV) or "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TTL_HOURS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_TTL_HOURS
|
||||
return value if value > 0 else DEFAULT_TTL_HOURS
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def current_profile_identity(
|
||||
profile_name: str | None = None,
|
||||
session_profile_lock: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the session profile identity used as the durable key."""
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
explicit = (profile_identity or session_profile_lock or "").strip()
|
||||
lock = (explicit or env_lock or "").strip()
|
||||
name = (profile_name or "").strip()
|
||||
return lock or name or "unknown-profile"
|
||||
|
||||
|
||||
def state_key(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
Session proofs are one-active-per-profile (workflow load / decision lock),
|
||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||
inside the payload and validated on load (#559), which lets a later daemon
|
||||
process recover state without already knowing the remote argument.
|
||||
"""
|
||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
|
||||
def _ensure_state_dir(state_dir: str | None = None) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
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)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_json(path: str) -> dict[str, Any] | None:
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
parent = os.path.dirname(path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
os.replace(temp_path, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return fail-closed reasons when durable record identity does not match."""
|
||||
if record is None:
|
||||
return []
|
||||
reasons: list[str] = []
|
||||
expected_profile = current_profile_identity(profile_identity=profile_identity)
|
||||
stored_profile = (
|
||||
(record.get("session_profile_lock") or record.get("profile_identity") or "")
|
||||
.strip()
|
||||
)
|
||||
if stored_profile and expected_profile and stored_profile != expected_profile:
|
||||
if expected_profile != "unknown-profile":
|
||||
reasons.append(
|
||||
"session state profile identity mismatch "
|
||||
f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)"
|
||||
)
|
||||
|
||||
for field, expected in (
|
||||
("remote", remote),
|
||||
("org", org),
|
||||
("repo", repo),
|
||||
):
|
||||
want = (expected or "").strip()
|
||||
have = (str(record.get(field) or "")).strip()
|
||||
if want and have and want != have:
|
||||
reasons.append(
|
||||
f"session state {field} mismatch "
|
||||
f"(stored={have!r}, expected={want!r}; fail closed)"
|
||||
)
|
||||
|
||||
recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at"))
|
||||
if recorded_at is None:
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if age < timedelta(0):
|
||||
reasons.append("session state recorded_at is in the future (fail closed)")
|
||||
return reasons
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
return None
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
# Identity fields live on both envelope and payload for convenience.
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
if reasons:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def save_state(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any] | None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
profile_name=payload.get("session_profile") if payload else None,
|
||||
session_profile_lock=(
|
||||
(payload or {}).get("session_profile_lock") or profile_identity
|
||||
),
|
||||
)
|
||||
# Prefer explicit args over payload fields for key location.
|
||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||
key_org = org if org is not None else (payload or {}).get("org")
|
||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=key_remote,
|
||||
org=key_org,
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
if payload is None:
|
||||
for candidate in (path, lock_path):
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
os.remove(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
now = _now_utc().isoformat().replace("+00:00", "Z")
|
||||
body = dict(payload)
|
||||
body.setdefault("session_pid", os.getpid())
|
||||
body["writer_pid"] = os.getpid()
|
||||
body["profile_identity"] = profile
|
||||
if not (body.get("session_profile_lock") or "").strip():
|
||||
body["session_profile_lock"] = profile
|
||||
body["recorded_at"] = body.get("recorded_at") or now
|
||||
body["updated_at"] = now
|
||||
if key_remote is not None:
|
||||
body["remote"] = key_remote
|
||||
if key_org is not None:
|
||||
body["org"] = key_org
|
||||
if key_repo is not None:
|
||||
body["repo"] = key_repo
|
||||
|
||||
envelope = {
|
||||
"kind": kind,
|
||||
"remote": key_remote,
|
||||
"org": key_org,
|
||||
"repo": key_repo,
|
||||
"profile_identity": profile,
|
||||
"session_profile_lock": body.get("session_profile_lock"),
|
||||
"recorded_at": body["recorded_at"],
|
||||
"updated_at": body["updated_at"],
|
||||
"writer_pid": body["writer_pid"],
|
||||
"payload": body,
|
||||
}
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
|
||||
def clear_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Merge approval must pin the current PR head SHA (#471).
|
||||
|
||||
Formal APPROVED reviews that predate the live PR head must not satisfy
|
||||
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
|
||||
for hermetic unit tests apart from MCP HTTP calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def assess_merge_approval_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
latest_by_reviewer: dict,
|
||||
) -> dict:
|
||||
"""Return whether a visible approval applies to the live PR head.
|
||||
|
||||
Args:
|
||||
current_head_sha: Current PR head commit SHA.
|
||||
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
||||
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
|
||||
|
||||
Returns:
|
||||
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
||||
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
||||
"""
|
||||
current = (current_head_sha or "").strip()
|
||||
approved_entries = [
|
||||
entry
|
||||
for entry in (latest_by_reviewer or {}).values()
|
||||
if (entry.get("verdict") or "").upper() == "APPROVED"
|
||||
and not entry.get("dismissed")
|
||||
]
|
||||
at_current = any(
|
||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||
for entry in approved_entries
|
||||
if current
|
||||
)
|
||||
latest_approved = None
|
||||
if approved_entries:
|
||||
latest_entry = sorted(
|
||||
approved_entries,
|
||||
key=lambda entry: (
|
||||
entry.get("submitted_at") or "",
|
||||
entry.get("reviewed_head_sha") or "",
|
||||
),
|
||||
)[-1]
|
||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
||||
reason = None
|
||||
if approved_entries and not at_current:
|
||||
reason = (
|
||||
f"stale approval: approved SHA '{latest_approved}' does not match "
|
||||
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
|
||||
"required next action: re-review PR at current head before merge"
|
||||
)
|
||||
|
||||
return {
|
||||
"approval_at_current_head": at_current,
|
||||
"latest_approved_head_sha": latest_approved,
|
||||
"stale_approval_block_reason": reason,
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
"""Merged PR branch and worktree cleanup reconciliation (#269).
|
||||
|
||||
Builds dry-run reports for merged pull requests and optionally executes
|
||||
remote branch deletion and local worktree removal when every safety gate passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
|
||||
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
||||
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
||||
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_linked_issue(title: str, body: str) -> int | None:
|
||||
"""Return the first Closes/Fixes issue number from PR metadata."""
|
||||
for text in (title or "", body or ""):
|
||||
match = CLOSES_FIXES_RE.search(text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def branch_worktree_folder(branch: str) -> str:
|
||||
return (branch or "").replace("/", "-")
|
||||
|
||||
|
||||
def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||
|
||||
|
||||
def extract_issue_marker(*values: str | None) -> int | None:
|
||||
"""Return the first issue marker found in branch/path-like values."""
|
||||
for value in values:
|
||||
match = ISSUE_MARKER_RE.search(value or "")
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for raw_line in (res.stdout or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
flush()
|
||||
current = {"path": line[9:].strip()}
|
||||
elif current is not None and line.startswith("HEAD "):
|
||||
current["head_sha"] = line[5:].strip()
|
||||
elif current is not None and line.startswith("branch "):
|
||||
ref = line[7:].strip()
|
||||
current["branch_ref"] = ref
|
||||
current["branch"] = (
|
||||
ref[len("refs/heads/"):]
|
||||
if ref.startswith("refs/heads/")
|
||||
else ref
|
||||
)
|
||||
elif current is not None and line == "detached":
|
||||
current["detached"] = True
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured records."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
records: list[dict[str, str | None]] = []
|
||||
current: dict[str, str | None] = {}
|
||||
for line in res.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {}
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {"worktree": line[9:].strip(), "branch": None, "head": None}
|
||||
elif line.startswith("branch ") and current:
|
||||
ref = line[7:].strip()
|
||||
current["branch"] = (
|
||||
ref[11:] if ref.startswith("refs/heads/") else ref
|
||||
)
|
||||
elif line.startswith("HEAD ") and current:
|
||||
current["head"] = line[5:].strip()
|
||||
elif line == "detached" and current:
|
||||
current["branch"] = None
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
return records
|
||||
|
||||
|
||||
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
||||
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
||||
mapping: dict[str, str] = {}
|
||||
for entry in list_local_worktrees(project_root):
|
||||
branch = entry.get("branch")
|
||||
path = entry.get("path")
|
||||
if branch and path:
|
||||
mapping[str(branch)] = str(path)
|
||||
return mapping
|
||||
|
||||
|
||||
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||
real_path = os.path.realpath(path)
|
||||
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||
|
||||
|
||||
def _head_is_safe_for_cleanup(
|
||||
*,
|
||||
project_root: str,
|
||||
candidate_head: str | None,
|
||||
pr_head_sha: str | None,
|
||||
target_ref: str | None,
|
||||
) -> bool:
|
||||
if not candidate_head:
|
||||
return False
|
||||
if pr_head_sha and candidate_head == pr_head_sha:
|
||||
return True
|
||||
if target_ref:
|
||||
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_cleanup_worktree_state(
|
||||
*,
|
||||
project_root: str,
|
||||
head_branch: str,
|
||||
issue_number: int | None,
|
||||
pr_head_sha: str | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||
state = read_local_worktree_state(expected_path)
|
||||
state["worktree_path"] = expected_path
|
||||
state["expected_worktree_path"] = expected_path
|
||||
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||
state["candidate_paths"] = []
|
||||
state["alias_block_reasons"] = []
|
||||
if state.get("exists"):
|
||||
return state
|
||||
|
||||
branch_issue = extract_issue_marker(head_branch)
|
||||
wanted_issue = issue_number or branch_issue
|
||||
candidates: list[dict[str, Any]] = []
|
||||
unsafe_matches: list[str] = []
|
||||
|
||||
for entry in list_local_worktrees(project_root):
|
||||
path = entry.get("path") or ""
|
||||
if not path or not _is_under_branches(project_root, path):
|
||||
continue
|
||||
branch = entry.get("branch") or ""
|
||||
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||
entry_issue = extract_issue_marker(branch) or path_issue
|
||||
branch_match = branch == head_branch
|
||||
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||
if not (branch_match or issue_match):
|
||||
continue
|
||||
safe_head = _head_is_safe_for_cleanup(
|
||||
project_root=project_root,
|
||||
candidate_head=entry.get("head_sha"),
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
if not safe_head and branch_match and not pr_head_sha and not target_ref:
|
||||
safe_head = True
|
||||
if not safe_head:
|
||||
unsafe_matches.append(path)
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||
if len(candidates) == 1:
|
||||
path = candidates[0]["path"]
|
||||
alias_state = read_local_worktree_state(path)
|
||||
alias_state["worktree_path"] = path
|
||||
alias_state["expected_worktree_path"] = expected_path
|
||||
alias_state["discovered_worktree_path"] = path
|
||||
alias_state["match_type"] = (
|
||||
"branch" if candidates[0].get("branch") == head_branch else "alias"
|
||||
)
|
||||
alias_state["candidate_paths"] = [path]
|
||||
alias_state["alias_block_reasons"] = []
|
||||
alias_state["alias_verified"] = True
|
||||
return alias_state
|
||||
if len(candidates) > 1:
|
||||
state["alias_block_reasons"].append(
|
||||
"ambiguous local worktree aliases: " + ", ".join(
|
||||
sorted(str(entry.get("path")) for entry in candidates)
|
||||
)
|
||||
)
|
||||
state["match_type"] = "ambiguous_alias"
|
||||
elif unsafe_matches:
|
||||
state["alias_block_reasons"].append(
|
||||
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||
+ ", ".join(sorted(unsafe_matches))
|
||||
)
|
||||
state["match_type"] = "unsafe_alias"
|
||||
return state
|
||||
|
||||
|
||||
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
||||
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
||||
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group("pr"))
|
||||
|
||||
|
||||
def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Discover detached reviewer scratch worktrees under ``branches/`` (#534).
|
||||
|
||||
Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
|
||||
These are never treated as author worktree paths.
|
||||
"""
|
||||
root = os.path.realpath(project_root)
|
||||
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||
found: list[dict[str, Any]] = []
|
||||
for record in _git_worktree_porcelain(project_root):
|
||||
path = record.get("worktree")
|
||||
if not path:
|
||||
continue
|
||||
real = os.path.realpath(path)
|
||||
parent = os.path.dirname(real)
|
||||
if parent != branches_root:
|
||||
continue
|
||||
folder = os.path.basename(real)
|
||||
pr_number = parse_reviewer_scratch_folder(folder)
|
||||
if pr_number is None:
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": real,
|
||||
"folder_name": folder,
|
||||
"current_branch": record.get("branch"),
|
||||
"head_sha": record.get("head"),
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def assess_reviewer_scratch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
worktree_path: str,
|
||||
folder_name: str,
|
||||
pr_merged: bool,
|
||||
pr_closed: bool,
|
||||
worktree_state: dict[str, Any],
|
||||
active_reviewer_lease: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a reviewer scratch worktree is safe to remove (#534)."""
|
||||
reasons: list[str] = []
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not (pr_merged or pr_closed):
|
||||
reasons.append("associated PR is still open")
|
||||
if not exists:
|
||||
reasons.append("reviewer scratch worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
dirty = worktree_state.get("dirty_files") or []
|
||||
reasons.append(
|
||||
"reviewer scratch worktree has tracked edits"
|
||||
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||
)
|
||||
if active_reviewer_lease:
|
||||
reasons.append("active reviewer lease still requires this worktree")
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if current_branch:
|
||||
# Scratch trees are expected detached; a named branch is ambiguous ownership.
|
||||
reasons.append(
|
||||
f"reviewer scratch worktree is on branch '{current_branch}' "
|
||||
"(expected detached HEAD for review-pr scratch trees)"
|
||||
)
|
||||
|
||||
safe = exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": worktree_path,
|
||||
"folder_name": folder_name,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": (
|
||||
"remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
|
||||
),
|
||||
# Never treat as author worktree cleanup.
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
if lock_path:
|
||||
lock = issue_lock_store.read_lock_file(lock_path.strip())
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
return issue_lock_store.has_active_issue_lock(branch)
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
heads: set[str] = set()
|
||||
for pr in open_prs or []:
|
||||
head = pr.get("head")
|
||||
if isinstance(head, dict):
|
||||
ref = head.get("ref")
|
||||
else:
|
||||
ref = head
|
||||
if ref:
|
||||
heads.add(str(ref))
|
||||
return heads
|
||||
|
||||
|
||||
def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
|
||||
path = (worktree_path or "").strip()
|
||||
if not path or not os.path.isdir(path):
|
||||
return {
|
||||
"exists": False,
|
||||
"clean": None,
|
||||
"current_branch": None,
|
||||
"head_sha": None,
|
||||
"dirty_files": [],
|
||||
}
|
||||
|
||||
branch_res = subprocess.run(
|
||||
["git", "-C", path, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
current_branch = (branch_res.stdout or "").strip() or None
|
||||
|
||||
status_res = subprocess.run(
|
||||
["git", "-C", path, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
dirty_files = parse_dirty_tracked_files(status_res.stdout or "")
|
||||
|
||||
head_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"clean": not dirty_files,
|
||||
"current_branch": current_branch,
|
||||
"head_sha": head_sha,
|
||||
"dirty_files": dirty_files,
|
||||
}
|
||||
|
||||
|
||||
def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
|
||||
if not head_sha:
|
||||
return None
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
return True
|
||||
if res.returncode == 1:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def assess_remote_branch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
remote_branch_exists: bool,
|
||||
open_pr_heads: set[str],
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
head_on_master: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
active_lock: bool,
|
||||
) -> dict[str, Any]:
|
||||
protected = protected_branches or PROTECTED_BRANCHES
|
||||
reasons: list[str] = []
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
if not remote_branch_exists:
|
||||
reasons.append("remote branch already absent")
|
||||
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 active_lock:
|
||||
reasons.append("active issue lock references this branch")
|
||||
if head_on_master is False:
|
||||
reasons.append("PR head is not an ancestor of master")
|
||||
if not delete_capability_allowed:
|
||||
reasons.append("delete_branch capability is not allowed in the active profile")
|
||||
|
||||
safe = merged and remote_branch_exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"remote_branch_exists": remote_branch_exists,
|
||||
"safe_to_delete_remote": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||
}
|
||||
|
||||
|
||||
def assess_local_worktree_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
worktree_state: dict[str, Any],
|
||||
active_lock: bool,
|
||||
) -> dict[str, Any]:
|
||||
reasons: list[str] = []
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
reasons.extend(worktree_state.get("alias_block_reasons") or [])
|
||||
if not exists:
|
||||
reasons.append("local worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
dirty = worktree_state.get("dirty_files") or []
|
||||
reasons.append(
|
||||
"local worktree has tracked edits"
|
||||
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||
)
|
||||
if exists:
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if (
|
||||
current_branch
|
||||
and current_branch != head_branch
|
||||
and not worktree_state.get("alias_verified")
|
||||
):
|
||||
reasons.append(
|
||||
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
||||
)
|
||||
if active_lock:
|
||||
reasons.append("active issue lock references this branch")
|
||||
|
||||
safe = merged and exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"worktree_path": worktree_state.get("worktree_path"),
|
||||
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
|
||||
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
|
||||
"match_type": worktree_state.get("match_type"),
|
||||
"candidate_paths": worktree_state.get("candidate_paths") or [],
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
|
||||
}
|
||||
|
||||
|
||||
def build_pr_cleanup_entry(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
open_pr_heads: set[str],
|
||||
remote_branch_exists: bool,
|
||||
head_on_master: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pr_number = int(pr["number"])
|
||||
head_branch = pr.get("head") or ""
|
||||
if isinstance(head_branch, dict):
|
||||
head_branch = head_branch.get("ref") or ""
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
merged = bool(pr.get("merged_at"))
|
||||
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||
worktree_state = resolve_cleanup_worktree_state(
|
||||
project_root=project_root,
|
||||
head_branch=head_branch,
|
||||
issue_number=issue_number,
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||
|
||||
remote = assess_remote_branch_cleanup(
|
||||
pr_number=pr_number,
|
||||
head_branch=head_branch,
|
||||
merged=merged,
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
open_pr_heads=open_pr_heads,
|
||||
protected_branches=protected_branches,
|
||||
head_on_master=head_on_master,
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
local = assess_local_worktree_cleanup(
|
||||
pr_number=pr_number,
|
||||
head_branch=head_branch,
|
||||
merged=merged,
|
||||
worktree_state=worktree_state,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"issue_number": issue_number,
|
||||
"title": title,
|
||||
"head_branch": head_branch,
|
||||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||||
"merged_at": pr.get("merged_at"),
|
||||
"merged": merged,
|
||||
"remote_branch": remote,
|
||||
"local_worktree": local,
|
||||
}
|
||||
|
||||
|
||||
def build_reconciliation_report(
|
||||
*,
|
||||
project_root: str,
|
||||
closed_prs: list[dict[str, Any]],
|
||||
open_prs: list[dict[str, Any]],
|
||||
remote_branch_exists: dict[str, bool],
|
||||
head_on_master: dict[int, bool | None],
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
active_reviewer_leases: dict[int, bool] | None = None,
|
||||
pr_states: dict[int, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
open_heads = collect_open_pr_heads(open_prs)
|
||||
entries: list[dict[str, Any]] = []
|
||||
for pr in closed_prs or []:
|
||||
if not pr.get("merged_at"):
|
||||
continue
|
||||
pr_number = int(pr["number"])
|
||||
head = pr.get("head") or ""
|
||||
if isinstance(head, dict):
|
||||
head = head.get("ref") or ""
|
||||
entries.append(
|
||||
build_pr_cleanup_entry(
|
||||
pr=pr,
|
||||
project_root=project_root,
|
||||
open_pr_heads=open_heads,
|
||||
remote_branch_exists=bool(remote_branch_exists.get(head)),
|
||||
head_on_master=head_on_master.get(pr_number),
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
issue_lock_path=issue_lock_path,
|
||||
protected_branches=protected_branches,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
)
|
||||
|
||||
# #534: reviewer scratch worktrees are reported separately from author cleanup.
|
||||
lease_map = active_reviewer_leases or {}
|
||||
state_map = pr_states or {}
|
||||
open_pr_numbers = {
|
||||
int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
closed_by_number = {
|
||||
int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
scratch_entries: list[dict[str, Any]] = []
|
||||
for scratch in discover_reviewer_scratch_worktrees(project_root):
|
||||
pr_number = int(scratch["pr_number"])
|
||||
state_info = state_map.get(pr_number) or {}
|
||||
closed_pr = closed_by_number.get(pr_number)
|
||||
if closed_pr is not None:
|
||||
pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
|
||||
pr_closed = True
|
||||
elif pr_number in open_pr_numbers:
|
||||
pr_merged = False
|
||||
pr_closed = False
|
||||
else:
|
||||
pr_merged = bool(state_info.get("merged"))
|
||||
pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
|
||||
worktree_path = scratch["worktree_path"]
|
||||
worktree_state = read_local_worktree_state(worktree_path)
|
||||
worktree_state["worktree_path"] = worktree_path
|
||||
# Prefer live branch from state (git) over porcelain branch field.
|
||||
assessment = assess_reviewer_scratch_cleanup(
|
||||
pr_number=pr_number,
|
||||
worktree_path=worktree_path,
|
||||
folder_name=scratch["folder_name"],
|
||||
pr_merged=pr_merged,
|
||||
pr_closed=pr_closed,
|
||||
worktree_state=worktree_state,
|
||||
active_reviewer_lease=bool(lease_map.get(pr_number)),
|
||||
)
|
||||
scratch_entries.append(assessment)
|
||||
|
||||
return {
|
||||
"project_root": os.path.realpath(project_root),
|
||||
"merged_pr_count": len(entries),
|
||||
"entries": entries,
|
||||
"reviewer_scratch_entries": scratch_entries,
|
||||
"reviewer_scratch_count": len(scratch_entries),
|
||||
"dry_run": True,
|
||||
"executed": False,
|
||||
}
|
||||
|
||||
|
||||
def remove_local_worktree(
|
||||
project_root: str,
|
||||
branch: str,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||
if not os.path.isdir(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"message": f"worktree not found: {worktree_path}",
|
||||
}
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "remove", worktree_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"message": f"removed worktree {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,
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"""Guarded merger adoption of an existing reviewer PR lease (#536).
|
||||
|
||||
Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable,
|
||||
comment-backed adoption path for merger sessions that inherit a reviewer lease
|
||||
after formal approval at the current PR head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
ADOPTION_MARKER = "<!-- mcp-review-lease-adoption:v1 -->"
|
||||
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||
|
||||
|
||||
def format_adoption_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
adopted_from_session_id: str,
|
||||
adopted_from_profile: str,
|
||||
adopted_from_reviewer_identity: str,
|
||||
adopted_from_comment_id: int | None,
|
||||
adoption_reason: str = DEFAULT_ADOPTION_REASON,
|
||||
adopted_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Render a durable merger adoption proof comment."""
|
||||
adopted_at = adopted_at or datetime.now(timezone.utc)
|
||||
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lease_body = leases.format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=adopter_identity,
|
||||
profile=adopter_profile,
|
||||
session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
phase="adopted",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=adopted_at,
|
||||
blocker="none",
|
||||
)
|
||||
lines = [
|
||||
ADOPTION_MARKER,
|
||||
f"adopted_at: {adopted_text}",
|
||||
f"adopted_by_identity: {adopter_identity}",
|
||||
f"adopted_by_profile: {adopter_profile}",
|
||||
f"adopted_from_session_id: {adopted_from_session_id}",
|
||||
f"adopted_from_profile: {adopted_from_profile}",
|
||||
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
|
||||
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
|
||||
f"adoption_reason: {adoption_reason}",
|
||||
lease_body,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_adoption_comment(body: str) -> bool:
|
||||
return ADOPTION_MARKER in (body or "")
|
||||
|
||||
|
||||
def build_lease_provenance(
|
||||
*,
|
||||
source: str,
|
||||
comment_id: int | None = None,
|
||||
adopted_from_session_id: str | None = None,
|
||||
adopted_from_profile: str | None = None,
|
||||
adopted_from_reviewer_identity: str | None = None,
|
||||
adoption_reason: str | None = None,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
proof = {
|
||||
"source": source,
|
||||
"recorded_at": recorded_text,
|
||||
}
|
||||
if comment_id is not None:
|
||||
proof["comment_id"] = comment_id
|
||||
if adopted_from_session_id:
|
||||
proof["adopted_from_session_id"] = adopted_from_session_id
|
||||
if adopted_from_profile:
|
||||
proof["adopted_from_profile"] = adopted_from_profile
|
||||
if adopted_from_reviewer_identity:
|
||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||
if adoption_reason:
|
||||
proof["adoption_reason"] = adoption_reason
|
||||
return proof
|
||||
|
||||
|
||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
if not session:
|
||||
return False
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
source = (provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_PROVENANCE_SOURCES:
|
||||
return False
|
||||
if source == SOURCE_ADOPT:
|
||||
return bool(provenance.get("comment_id")) and bool(
|
||||
provenance.get("adopted_from_session_id")
|
||||
)
|
||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||
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(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
expected_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
approval_at_head: bool,
|
||||
pr_open: bool = True,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merger may adopt the active reviewer lease (#536)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
pinned = leases._normalize_sha(expected_head_sha)
|
||||
live = leases._normalize_sha(live_head_sha)
|
||||
|
||||
if not pr_open:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is not open; merger lease adoption is only for open PRs"
|
||||
)
|
||||
if not approval_at_head:
|
||||
reasons.append(
|
||||
"formal APPROVED review at the current PR head is required before "
|
||||
"merger lease adoption (fail closed)"
|
||||
)
|
||||
if not pinned:
|
||||
reasons.append("expected_head_sha is required for merger lease adoption")
|
||||
if not live:
|
||||
reasons.append("live PR head SHA unavailable (fail closed)")
|
||||
elif pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"expected_head_sha does not match live PR head; refresh approval before adoption"
|
||||
)
|
||||
|
||||
active = leases.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_number, now=now
|
||||
)
|
||||
if not active:
|
||||
reasons.append(
|
||||
f"no active reviewer lease on PR #{pr_number} to adopt; reviewer "
|
||||
"must acquire first"
|
||||
)
|
||||
else:
|
||||
owner_session = (active.get("session_id") or "").strip()
|
||||
freshness = active.get("freshness") or leases.classify_lease_freshness(
|
||||
active, now=now
|
||||
)
|
||||
if freshness not in _MERGER_ADOPTABLE_FRESHNESS:
|
||||
reasons.append(
|
||||
f"active reviewer lease freshness is '{freshness}'; explicit "
|
||||
"reclaim is not implemented (fail closed)"
|
||||
)
|
||||
lease_head = active.get("candidate_head")
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"active reviewer lease candidate_head differs from live PR head"
|
||||
)
|
||||
if owner_session and owner_session == adopter_session_id:
|
||||
# Same session already holds the thread lease — allow idempotent adopt
|
||||
# only when the newest entry is not yet an adoption by this session.
|
||||
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
if newest and (newest.get("phase") or "") == "adopted":
|
||||
reasons.append(
|
||||
"merger session already recorded an adoption lease on this PR"
|
||||
)
|
||||
elif owner_session and owner_session != adopter_session_id:
|
||||
# Cross-session merger handoff: adopt the foreign reviewer lease.
|
||||
pass
|
||||
|
||||
if not (adopter_identity or "").strip():
|
||||
reasons.append("adopter identity required")
|
||||
if not (adopter_session_id or "").strip():
|
||||
reasons.append("adopter session_id required")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("merger worktree path required")
|
||||
if "merger" not in (adopter_profile or "").lower():
|
||||
reasons.append(
|
||||
f"profile '{adopter_profile}' is not a merger profile; adoption is "
|
||||
"merger-only (fail closed)"
|
||||
)
|
||||
|
||||
adopt_allowed = not reasons
|
||||
adoption_body = None
|
||||
if adopt_allowed and active:
|
||||
adoption_body = format_adoption_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number or active.get("issue_number"),
|
||||
adopter_identity=adopter_identity,
|
||||
adopter_profile=adopter_profile,
|
||||
adopter_session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
candidate_head=live,
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
adopted_from_session_id=active.get("session_id") or "",
|
||||
adopted_from_profile=active.get("profile") or "unknown",
|
||||
adopted_from_reviewer_identity=active.get("reviewer_identity") or "",
|
||||
adopted_from_comment_id=active.get("comment_id"),
|
||||
adopted_at=now,
|
||||
)
|
||||
|
||||
return {
|
||||
"adopt_allowed": adopt_allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"adoption_body": adoption_body,
|
||||
"adopter_session_id": adopter_session_id,
|
||||
"expected_head_sha": pinned,
|
||||
"live_head_sha": live,
|
||||
}
|
||||
@@ -31,8 +31,6 @@ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
||||
def infer_role(name, execution_profile):
|
||||
"""Return the unambiguous role for a legacy profile name, or None."""
|
||||
haystack = f"{name} {execution_profile or ''}".lower()
|
||||
if "reconciler" in haystack:
|
||||
return "reconciler"
|
||||
has_author = "author" in haystack
|
||||
has_reviewer = "reviewer" in haystack
|
||||
if has_author == has_reviewer:
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Namespace-scoped MCP workspace binding (#510).
|
||||
|
||||
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
||||
active task workspace. Foreign role worktree environment variables must not
|
||||
poison workspace purity checks in another namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"author": AUTHOR_WORKTREE_ENV,
|
||||
"reviewer": REVIEWER_WORKTREE_ENV,
|
||||
"merger": MERGER_WORKTREE_ENV,
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
role_kind: str | None,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map profile/task role to a workspace namespace key."""
|
||||
role = (role_kind or "author").strip().lower()
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
||||
text = (env.get(key) or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_namespace_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
|
||||
def resolve_namespace_mutation_context(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
*,
|
||||
role_kind: str,
|
||||
resolved_workspace: str,
|
||||
binding_source: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Detect when a foreign role env would have hijacked workspace binding."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if role == "author":
|
||||
return {"would_pollute": False, "ignored_bindings": []}
|
||||
|
||||
ignored: list[str] = []
|
||||
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
||||
if author_path:
|
||||
author_real = os.path.realpath(os.path.abspath(author_path))
|
||||
resolved_real = os.path.realpath(resolved_workspace)
|
||||
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
||||
ignored.append(
|
||||
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
||||
)
|
||||
return {
|
||||
"would_pollute": bool(ignored),
|
||||
"ignored_bindings": ignored,
|
||||
}
|
||||
|
||||
|
||||
def assess_metadata_only_worktree_binding(
|
||||
*,
|
||||
role_kind: str,
|
||||
declared_worktree_path: str | None,
|
||||
mutation_workspace: str,
|
||||
process_project_root: str,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||
declared = (declared_worktree_path or "").strip()
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
mutation_root = os.path.realpath(mutation_workspace)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if not declared:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||
if declared_root == mutation_root:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
if declared_root != process_root and mutation_root == process_root:
|
||||
return {
|
||||
"block": True,
|
||||
"metadata_only": True,
|
||||
"reasons": [
|
||||
f"worktree_path is metadata-only for {role} mutations: preflight "
|
||||
f"inspected '{declared_root}' but mutation tools would still "
|
||||
f"validate MCP server process root '{process_root}'"
|
||||
],
|
||||
"declared_worktree_path": declared_root,
|
||||
"mutation_workspace": mutation_root,
|
||||
"process_project_root": process_root,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
|
||||
def format_namespace_workspace_binding_error(
|
||||
*,
|
||||
role_kind: str,
|
||||
workspace_path: str,
|
||||
binding_source: str,
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
]
|
||||
if ignored_bindings:
|
||||
parts.append(
|
||||
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
||||
)
|
||||
if dirty_files:
|
||||
parts.append(
|
||||
"Dirty tracked files in active task workspace: "
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def assess_namespace_mutation_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
worktree: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
role = ctx["workspace_role_kind"]
|
||||
process_root = ctx["process_project_root"]
|
||||
|
||||
metadata = assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
declared_worktree_path=worktree_path,
|
||||
mutation_workspace=mutation_workspace,
|
||||
process_project_root=process_root,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=mutation_workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
and mutation_workspace != process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"mutation_workspace": mutation_workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": ctx["canonical_repo_root"],
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
"""Native MCP preference gate and shell health circuit breaker (#270).
|
||||
|
||||
Gitea mutations must use native MCP tools first. Shell scripts, direct API
|
||||
calls, browser helpers, and improvised encoders are blocked when MCP is
|
||||
available unless explicit recovery-mode proof is supplied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||
|
||||
# Tasks that must prefer native MCP over shell/API/helper fallbacks.
|
||||
GITEA_MUTATION_TASKS = frozenset({
|
||||
"comment_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
"create_issue",
|
||||
"close_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"close_pr",
|
||||
"comment_pr",
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"delete_branch",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
ALLOWED_PATH_KINDS = frozenset({
|
||||
"mcp_native",
|
||||
"recovery_fallback",
|
||||
})
|
||||
|
||||
BLOCKED_PATH_KINDS = frozenset({
|
||||
"shell_script",
|
||||
"direct_api",
|
||||
"webfetch",
|
||||
"playwright",
|
||||
"helper_script",
|
||||
"unsafe_helper",
|
||||
"mcp_server_touch",
|
||||
})
|
||||
|
||||
SHELL_SPAWN_FAILURE_THRESHOLD = 2
|
||||
|
||||
TERMINAL_REPORT_HEADING = (
|
||||
"MCP transport unavailable or shell circuit breaker tripped. "
|
||||
"No unsafe Gitea fallback performed."
|
||||
)
|
||||
|
||||
_RECOVERY_MODE_RE = re.compile(
|
||||
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
|
||||
r"mcp tools unavailable|no mcp path)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:python3?|bash|sh)?\s*(?:"
|
||||
+ "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES)
|
||||
+ r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WEBFETCH_RE = re.compile(r"\b(?:webfetch|mcp_web_fetch|fetch\s+url)\b", re.IGNORECASE)
|
||||
_PLAYWRIGHT_RE = re.compile(r"\b(?:playwright|browser_navigate|browser_click)\b", re.IGNORECASE)
|
||||
_HELPER_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:_encode_|_emit_|_inline_)[\w-]+\.py",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MANUAL_BASE64_RE = re.compile(
|
||||
r"\b(?:manual\s+base64|llm-generated\s+base64|base64-encode\s+in\s+chat)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIRECT_API_RE = re.compile(
|
||||
r"\b(?:api_request|urllib\.request|requests\.(?:post|patch|put)|curl\s+-X\s+POST)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MCP_SERVER_TOUCH_RE = re.compile(
|
||||
r"\b(?:kill|pkill|restart|reload|touch|edit|modify|write)\b.{0,40}\b(?:mcp_server|mcp-server|gitea_mcp_server)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLI_AUTH_DIVERGENCE_RE = re.compile(
|
||||
r"GITEA_MCP_PROFILE\s*=\s*['\"]?([^\s'\";]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_session_shell: dict[str, Any] = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def is_spawn_failure(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
) -> bool:
|
||||
"""True for executor spawn failures (exit_code -1, empty output)."""
|
||||
if spawn_failure is True:
|
||||
return True
|
||||
if spawn_failure is False:
|
||||
return False
|
||||
if exit_code != -1:
|
||||
return False
|
||||
return not (_clean(stdout) or _clean(stderr))
|
||||
|
||||
|
||||
def record_shell_spawn_outcome(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
probe_attempted: bool = False,
|
||||
probe_succeeded: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Track shell spawn outcomes and trip the session circuit breaker (#270 AC4)."""
|
||||
global _session_shell
|
||||
failed = is_spawn_failure(
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
spawn_failure=spawn_failure,
|
||||
)
|
||||
|
||||
if failed:
|
||||
_session_shell["consecutive_spawn_failures"] = (
|
||||
int(_session_shell.get("consecutive_spawn_failures") or 0) + 1
|
||||
)
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
if probe_attempted and probe_succeeded is False:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
else:
|
||||
_session_shell["consecutive_spawn_failures"] = 0
|
||||
_session_shell["shell_unavailable"] = False
|
||||
_session_shell["hard_stopped"] = False
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
|
||||
failures = int(_session_shell["consecutive_spawn_failures"])
|
||||
if failures >= SHELL_SPAWN_FAILURE_THRESHOLD:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
_session_shell["hard_stopped"] = True
|
||||
|
||||
return shell_health_status()
|
||||
|
||||
|
||||
def shell_health_status() -> dict[str, Any]:
|
||||
"""Return the current shell health / circuit-breaker state."""
|
||||
failures = int(_session_shell.get("consecutive_spawn_failures") or 0)
|
||||
hard_stopped = bool(_session_shell.get("hard_stopped"))
|
||||
shell_unavailable = bool(_session_shell.get("shell_unavailable"))
|
||||
return {
|
||||
"consecutive_spawn_failures": failures,
|
||||
"shell_unavailable": shell_unavailable,
|
||||
"hard_stopped": hard_stopped,
|
||||
"threshold": SHELL_SPAWN_FAILURE_THRESHOLD,
|
||||
"shell_use_allowed": not hard_stopped,
|
||||
"last_exit_code": _session_shell.get("last_exit_code"),
|
||||
"safe_next_action": (
|
||||
"emit terminal recovery report; prefer native MCP for remaining Gitea mutations"
|
||||
if hard_stopped
|
||||
else (
|
||||
"probe shell once with echo/pwd; if probe fails mark shell unavailable"
|
||||
if failures == 1
|
||||
else "shell healthy"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def clear_shell_health_for_tests() -> None:
|
||||
"""Reset session shell state (tests only)."""
|
||||
global _session_shell
|
||||
_session_shell = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def classify_command_path(command_or_detail: str | None) -> str:
|
||||
"""Classify a proposed command/detail into a path kind."""
|
||||
text = command_or_detail or ""
|
||||
if _MCP_SERVER_TOUCH_RE.search(text):
|
||||
return "mcp_server_touch"
|
||||
if _WEBFETCH_RE.search(text):
|
||||
return "webfetch"
|
||||
if _PLAYWRIGHT_RE.search(text):
|
||||
return "playwright"
|
||||
if _HELPER_SCRIPT_RE.search(text) or _MANUAL_BASE64_RE.search(text):
|
||||
return "unsafe_helper"
|
||||
if _LOCAL_SCRIPT_RE.search(text):
|
||||
return "shell_script"
|
||||
if _DIRECT_API_RE.search(text):
|
||||
return "direct_api"
|
||||
return "mcp_native"
|
||||
|
||||
|
||||
def detect_cli_auth_divergence(
|
||||
command_or_detail: str | None,
|
||||
*,
|
||||
active_profile: str | None,
|
||||
) -> list[str]:
|
||||
"""Flag shell commands that override GITEA_MCP_PROFILE away from the session."""
|
||||
reasons: list[str] = []
|
||||
text = command_or_detail or ""
|
||||
active = _clean(active_profile)
|
||||
match = _CLI_AUTH_DIVERGENCE_RE.search(text)
|
||||
if not match or not active:
|
||||
return reasons
|
||||
requested = _clean(match.group(1))
|
||||
if requested and requested != active:
|
||||
reasons.append(
|
||||
f"CLI auth divergence: command sets GITEA_MCP_PROFILE='{requested}' "
|
||||
f"but active session profile is '{active}' (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def format_mcp_unavailable_terminal_report(
|
||||
*,
|
||||
task: str | None = None,
|
||||
reasons: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Terminal report when MCP is broken and unsafe recovery is forbidden (#270 AC3)."""
|
||||
lines = [TERMINAL_REPORT_HEADING, ""]
|
||||
if task:
|
||||
lines.append(f"Blocked task: {task}")
|
||||
if reasons:
|
||||
lines.extend(["", "Reasons:"])
|
||||
lines.extend(f"- {reason}" for reason in reasons)
|
||||
lines.extend([
|
||||
"",
|
||||
"Required recovery (no improvised fallback):",
|
||||
"- Restart the MCP session",
|
||||
"- Kill hung background terminals holding the shell executor",
|
||||
"- Retry the native MCP tool once after reconnect",
|
||||
"- If MCP remains unavailable, stop and hand off to the operator",
|
||||
"- Do not run local Gitea scripts, WebFetch, Playwright, or manual base64 encoders",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_gitea_operation_path(
|
||||
*,
|
||||
task: str,
|
||||
path_kind: str | None = None,
|
||||
command_or_detail: str | None = None,
|
||||
mcp_available: bool = True,
|
||||
mcp_tool_visible: bool = True,
|
||||
recovery_mode: bool = False,
|
||||
recovery_proof_complete: bool = False,
|
||||
role: str | None = None,
|
||||
active_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when a Gitea mutation would bypass native MCP (#270)."""
|
||||
task_name = _clean(task)
|
||||
resolved_kind = _clean(path_kind) or classify_command_path(command_or_detail)
|
||||
reasons: list[str] = []
|
||||
|
||||
if task_name and task_name not in GITEA_MUTATION_TASKS:
|
||||
reasons.append(
|
||||
f"unknown or non-mutation Gitea task '{task_name}'; "
|
||||
"native MCP preference gate applies only to Gitea mutations"
|
||||
)
|
||||
|
||||
shell = shell_health_status()
|
||||
if shell["hard_stopped"] and resolved_kind != "mcp_native":
|
||||
reasons.append(
|
||||
"shell circuit breaker is hard-stopped after consecutive spawn failures; "
|
||||
"use native MCP or emit terminal recovery report"
|
||||
)
|
||||
|
||||
recovery_declared = recovery_mode or bool(
|
||||
_RECOVERY_MODE_RE.search(command_or_detail or "")
|
||||
)
|
||||
recovery_allowed = (
|
||||
recovery_declared
|
||||
and recovery_proof_complete
|
||||
and not mcp_available
|
||||
and resolved_kind in BLOCKED_PATH_KINDS - {"mcp_server_touch"}
|
||||
)
|
||||
|
||||
if resolved_kind in BLOCKED_PATH_KINDS and not recovery_allowed:
|
||||
reasons.append(f"path kind '{resolved_kind}' is not an approved Gitea mutation path")
|
||||
|
||||
if resolved_kind == "mcp_server_touch":
|
||||
if _clean(role) == "reviewer" or (
|
||||
active_profile and "reviewer" in active_profile.lower()
|
||||
):
|
||||
reasons.append(
|
||||
"reviewer workflows must not touch, restart, or kill MCP server files/processes"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"MCP server files/processes must not be modified during normal Gitea workflows"
|
||||
)
|
||||
|
||||
reasons.extend(
|
||||
detect_cli_auth_divergence(command_or_detail, active_profile=active_profile)
|
||||
)
|
||||
|
||||
if (
|
||||
mcp_available
|
||||
and mcp_tool_visible
|
||||
and resolved_kind != "mcp_native"
|
||||
and not recovery_allowed
|
||||
):
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"native MCP tools are available; shell/API/helper fallback is forbidden"
|
||||
)
|
||||
elif not recovery_proof_complete:
|
||||
reasons.append(
|
||||
"recovery-mode fallback requires complete recovery proof before proceeding"
|
||||
)
|
||||
|
||||
if not mcp_available and resolved_kind != "mcp_native" and not recovery_allowed:
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"MCP transport unavailable; produce terminal recovery report instead of "
|
||||
"improvising unsafe fallback"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
terminal_report = None
|
||||
if block and (not mcp_available or shell["hard_stopped"]):
|
||||
terminal_report = format_mcp_unavailable_terminal_report(
|
||||
task=task_name or None,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
return {
|
||||
"task": task_name or None,
|
||||
"path_kind": resolved_kind,
|
||||
"mcp_available": mcp_available,
|
||||
"mcp_tool_visible": mcp_tool_visible,
|
||||
"recovery_mode": recovery_declared,
|
||||
"shell_health": shell,
|
||||
"block": block,
|
||||
"allowed": not block,
|
||||
"reasons": reasons,
|
||||
"terminal_report": terminal_report,
|
||||
"safe_next_action": (
|
||||
"use the native MCP tool for this task"
|
||||
if mcp_available and mcp_tool_visible and block
|
||||
else (
|
||||
terminal_report or "proceed with native MCP"
|
||||
if block
|
||||
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,
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
"""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"
|
||||
),
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
"""PR-only queue cleanup mode gates and report verifier (#390).
|
||||
|
||||
Cleanup mode dispatches exactly one canonical review run per PR. It forbids
|
||||
author-side mutations (issue claiming, branch creation, implementation edits,
|
||||
issue filing) and enforces the terminal-mutation chain: stop after
|
||||
``REQUEST_CHANGES``; after ``APPROVED`` continue only to same-PR merge when
|
||||
merge is explicitly authorized for that PR and merge gates pass; stop after
|
||||
merge or a merge blocker. The next PR always requires a fresh run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CLEANUP_WORKFLOW_PATH = "workflows/pr-queue-cleanup.md"
|
||||
|
||||
# Author-side resolver tasks that must never run inside cleanup mode.
|
||||
CLEANUP_FORBIDDEN_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
# Run-state outcomes for one cleanup dispatch.
|
||||
STOP_AFTER_REQUEST_CHANGES = "STOP_AFTER_REQUEST_CHANGES"
|
||||
STOP_AFTER_DECISION = "STOP_AFTER_DECISION"
|
||||
CONTINUE_TO_SAME_PR_MERGE = "CONTINUE_TO_SAME_PR_MERGE"
|
||||
STOP_APPROVED_NO_MERGE_AUTH = "STOP_APPROVED_NO_MERGE_AUTH"
|
||||
STOP_APPROVED_MERGE_GATES_FAILED = "STOP_APPROVED_MERGE_GATES_FAILED"
|
||||
STOP_AFTER_MERGE = "STOP_AFTER_MERGE"
|
||||
STOP_AFTER_MERGE_BLOCKER = "STOP_AFTER_MERGE_BLOCKER"
|
||||
STOP_GATE_NOT_PROVEN = "STOP_GATE_NOT_PROVEN"
|
||||
|
||||
_TERMINAL_DECISIONS = frozenset({"approved", "request_changes", "comment", "skip"})
|
||||
|
||||
|
||||
def resolve_cleanup_run_state(
|
||||
decision: str | None,
|
||||
*,
|
||||
merge_authorized_for_pr: bool = False,
|
||||
merge_gates_passed: bool | None = None,
|
||||
merge_completed: bool = False,
|
||||
merge_blocker: bool = False,
|
||||
) -> dict:
|
||||
"""Resolve what one cleanup run may do after its terminal review decision.
|
||||
|
||||
Fail closed: unknown decisions stop the run with no further mutation.
|
||||
"""
|
||||
normalized = (decision or "").strip().lower()
|
||||
|
||||
if normalized not in _TERMINAL_DECISIONS:
|
||||
return {
|
||||
"outcome": STOP_GATE_NOT_PROVEN,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
f"unknown terminal decision {decision!r}; cleanup run stops "
|
||||
"(fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
if normalized == "request_changes":
|
||||
return {
|
||||
"outcome": STOP_AFTER_REQUEST_CHANGES,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["REQUEST_CHANGES is terminal in cleanup mode"],
|
||||
}
|
||||
|
||||
if normalized in {"comment", "skip"}:
|
||||
return {
|
||||
"outcome": STOP_AFTER_DECISION,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [f"{normalized} decision ends the cleanup run"],
|
||||
}
|
||||
|
||||
# normalized == "approved"
|
||||
if merge_completed:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge completed; cleanup run stops"],
|
||||
}
|
||||
if merge_blocker:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE_BLOCKER,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge blocker recorded; cleanup run stops"],
|
||||
}
|
||||
if not merge_authorized_for_pr:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_NO_MERGE_AUTH,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"APPROVED without explicit per-PR merge authorization; "
|
||||
"cleanup run stops"
|
||||
],
|
||||
}
|
||||
if merge_gates_passed is not True:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"merge gates not proven passed; cleanup run stops before merge"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"outcome": CONTINUE_TO_SAME_PR_MERGE,
|
||||
"further_mutation_allowed": True,
|
||||
"reasons": [],
|
||||
"allowed_mutation": "merge same PR only",
|
||||
}
|
||||
|
||||
|
||||
def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed on any author-side mutation task inside cleanup mode."""
|
||||
normalized = (task or "").strip().lower()
|
||||
if normalized in CLEANUP_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in PR-only cleanup mode: "
|
||||
"no issue claiming, branch creation, implementation edits, or "
|
||||
"issue filing"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
_SELECTED_PR_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_SELECTED_PR_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_EMPTY_QUEUE_RE = re.compile(
|
||||
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NEXT_SUGGESTED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_TERMINAL_MUTATION_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:review (?:decision|verdict)|terminal (?:review )?"
|
||||
r"(?:decision|mutation))\s*:\s*"
|
||||
r"(approved|request_changes|request changes|merged|comment)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_PAGINATION_HINT_RE = re.compile(
|
||||
r"inventory_complete|final page|has_more\s*[=:]\s*false|total[_ ]count",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*(?!none\b|not attempted\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_AUTHORIZED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge authorized(?: for pr)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*issue mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BRANCH_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*branch mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
"""Validate a PR-only cleanup run report (single dispatch, fail closed)."""
|
||||
reasons: list[str] = []
|
||||
text = report_text or ""
|
||||
|
||||
if CLEANUP_WORKFLOW_PATH not in text:
|
||||
reasons.append(
|
||||
f"report must cite the canonical cleanup workflow "
|
||||
f"({CLEANUP_WORKFLOW_PATH})"
|
||||
)
|
||||
|
||||
selected = _SELECTED_PR_RE.findall(text)
|
||||
if len(selected) == 0:
|
||||
if _SELECTED_PR_NONE_RE.search(text):
|
||||
if not _EMPTY_QUEUE_RE.search(text):
|
||||
reasons.append("Selected PR is 'none' but no valid empty-queue claim found")
|
||||
else:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
elif len(set(selected)) > 1:
|
||||
reasons.append(
|
||||
"cleanup run selected multiple PRs "
|
||||
f"({', '.join(sorted(set(selected)))}); one canonical review per "
|
||||
"PR per run"
|
||||
)
|
||||
|
||||
if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text):
|
||||
reasons.append("report contains both a selected PR and a claim of none selected")
|
||||
|
||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||
if len(terminal) > 1:
|
||||
reasons.append(
|
||||
"multiple terminal review mutations reported in one cleanup run"
|
||||
)
|
||||
|
||||
if not _PAGINATION_HINT_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing PR inventory pagination proof "
|
||||
"(inventory_complete / final page / total_count)"
|
||||
)
|
||||
|
||||
if not _NEXT_SUGGESTED_RE.search(text):
|
||||
reasons.append(
|
||||
"report must include 'Next suggested PR' (without continuing to it)"
|
||||
)
|
||||
|
||||
if _MERGE_RESULT_RE.search(text) and not _MERGE_AUTHORIZED_RE.search(text):
|
||||
reasons.append(
|
||||
"merge reported without explicit per-PR merge authorization "
|
||||
"('Merge authorized: true')"
|
||||
)
|
||||
|
||||
if _ISSUE_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("issue mutations are forbidden in PR-only cleanup mode")
|
||||
if _BRANCH_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("branch mutations are forbidden in PR-only cleanup mode")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed" if proven else
|
||||
"fix the cleanup report: one PR, one terminal mutation, pagination "
|
||||
"proof, next-suggested-PR field, and no issue/branch mutations"
|
||||
),
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
|
||||
|
||||
Structured PR/issue comments prove exclusive phases so author conflict-fix
|
||||
pushes cannot race reviewer validation/approval/merge on the same head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
})
|
||||
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
|
||||
text = body or ""
|
||||
if marker not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
return fields or None
|
||||
|
||||
|
||||
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"lease_kind": "reviewer",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
ff = (fields.get("fast_forward") or "").strip().lower()
|
||||
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
|
||||
return {
|
||||
"lease_kind": "conflict_fix",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"branch": fields.get("branch"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"head_before": _normalize_sha(fields.get("head_before")),
|
||||
"head_after": _normalize_sha(fields.get("head_after")),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"reviewer_active": reviewer_active in {"yes", "true", "1"},
|
||||
"fast_forward": ff in {"yes", "true", "1"},
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
|
||||
parsed = parser(body)
|
||||
if not parsed:
|
||||
continue
|
||||
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
return False
|
||||
return phase in active_phases or bool(phase and phase not in (
|
||||
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
|
||||
))
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def find_active_conflict_fix_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "conflict_fix"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def format_conflict_fix_lease_body(
|
||||
*,
|
||||
pr_number: int,
|
||||
branch: str,
|
||||
worktree: str,
|
||||
profile: str,
|
||||
head_before: str,
|
||||
phase: str = "claimed",
|
||||
session_id: str = "unknown",
|
||||
expires_at: datetime | None = None,
|
||||
reviewer_active: bool = False,
|
||||
) -> str:
|
||||
expires = expires_at or (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lines = [
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
f"pr: #{pr_number}",
|
||||
f"branch: {branch}",
|
||||
f"worktree: {worktree}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"phase: {phase}",
|
||||
f"head_before: {head_before}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_head_sha_equality(
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when reviewed and live PR heads differ."""
|
||||
reviewed = _normalize_sha(reviewed_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
if not reviewed or not live:
|
||||
reasons.append(
|
||||
"reviewed/live head SHA missing or not full 40-hex; fail closed"
|
||||
)
|
||||
elif reviewed != live:
|
||||
reasons.append(
|
||||
"PR head changed after validation; re-pin and re-validate before "
|
||||
"approval or merge"
|
||||
)
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha": live,
|
||||
"head_changed": bool(reviewed and live and reviewed != live),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_push(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
branch_head_before: str | None,
|
||||
branch_head_after: str | None,
|
||||
worktree_path: str | None,
|
||||
push_cwd: str | None,
|
||||
is_fast_forward: bool | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Author pre-push gate: block when a reviewer holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if reviewer_lease:
|
||||
reasons.append(
|
||||
f"active reviewer lease on PR #{pr_number} "
|
||||
f"(phase={reviewer_lease.get('phase')}); author push blocked"
|
||||
)
|
||||
|
||||
head_before = _normalize_sha(branch_head_before)
|
||||
head_after = _normalize_sha(branch_head_after)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push missing or invalid SHA")
|
||||
if head_after and head_before and head_before == head_after:
|
||||
reasons.append("branch head unchanged; no push to perform")
|
||||
|
||||
worktree = (worktree_path or "").strip()
|
||||
cwd = (push_cwd or "").strip()
|
||||
if not worktree:
|
||||
reasons.append("worktree path required for conflict-fix push proof")
|
||||
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
|
||||
if worktree not in cwd:
|
||||
reasons.append(
|
||||
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
|
||||
)
|
||||
|
||||
if is_fast_forward is False:
|
||||
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
|
||||
|
||||
if conflict_lease and conflict_lease.get("phase") == "pushing":
|
||||
owner = conflict_lease.get("worktree")
|
||||
if owner and worktree and owner != worktree:
|
||||
reasons.append(
|
||||
f"sibling conflict-fix lease active from worktree '{owner}'"
|
||||
)
|
||||
|
||||
push_allowed = not reasons
|
||||
return {
|
||||
"push_allowed": push_allowed,
|
||||
"block": not push_allowed,
|
||||
"reasons": reasons,
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
"reviewer_was_active": bool(reviewer_lease),
|
||||
"fast_forward": is_fast_forward,
|
||||
}
|
||||
|
||||
|
||||
def assess_reviewer_mutation_blocked(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
mutation: str,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer gate: block when conflict-fix lease active or head moved."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
|
||||
reasons.append(
|
||||
f"active conflict-fix lease on PR #{pr_number} "
|
||||
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
|
||||
)
|
||||
|
||||
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
|
||||
if head_check["block"]:
|
||||
reasons.extend(head_check["reasons"])
|
||||
|
||||
if not _normalize_sha(reviewed_head_sha):
|
||||
reasons.append(
|
||||
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
|
||||
)
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"head_check": head_check,
|
||||
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
|
||||
"live_head_sha": head_check.get("live_head_sha"),
|
||||
"push_during_validation": bool(
|
||||
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
|
||||
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
|
||||
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PUSH_DURING_VALIDATION_RE = re.compile(
|
||||
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_BEFORE_RE = re.compile(
|
||||
r"branch head before push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_AFTER_RE = re.compile(
|
||||
r"branch head after push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_LEASE_STATUS_RE = re.compile(
|
||||
r"active reviewer lease status\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FAST_FORWARD_RE = re.compile(
|
||||
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_ACTIVE_RE = re.compile(
|
||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||
live_approval = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
|
||||
else None
|
||||
)
|
||||
live_merge = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||
|
||||
if not reviewed:
|
||||
reasons.append("reviewed head SHA not stated in final report")
|
||||
if not live_approval:
|
||||
reasons.append("final live head SHA before approval not stated")
|
||||
if not live_merge:
|
||||
reasons.append("final live head SHA before merge not stated")
|
||||
if not push_during:
|
||||
reasons.append("whether push occurred during validation not stated")
|
||||
elif reviewed and live_approval and reviewed != live_approval:
|
||||
reasons.append("live head before approval differs from reviewed head SHA")
|
||||
elif reviewed and live_merge and reviewed != live_merge:
|
||||
reasons.append("live head before merge differs from reviewed head SHA")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha_before_approval": live_approval,
|
||||
"live_head_sha_before_merge": live_merge,
|
||||
"push_during_validation": (push_during.group(1).lower() if push_during else None),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
head_before = _normalize_sha(
|
||||
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_BEFORE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
head_after = _normalize_sha(
|
||||
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_AFTER_RE.search(text)
|
||||
else None
|
||||
)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push not stated")
|
||||
if not head_after:
|
||||
reasons.append("branch head after push not stated")
|
||||
if not _REVIEWER_LEASE_STATUS_RE.search(text):
|
||||
reasons.append("active reviewer lease status not stated")
|
||||
if not _FAST_FORWARD_RE.search(text):
|
||||
reasons.append("whether push was fast-forward not stated")
|
||||
if not _REVIEWER_ACTIVE_RE.search(text):
|
||||
reasons.append("whether any reviewer was active not stated")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"""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": "",
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Reconciler profile model for already-landed PR closure (#304).
|
||||
|
||||
Defines the narrowly scoped operation set for a dedicated reconciler profile
|
||||
such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the
|
||||
#310 stack; this module validates profile shape only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gitea_config
|
||||
|
||||
RECONCILER_REQUIRED_OPERATIONS = (
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
)
|
||||
|
||||
RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
)
|
||||
|
||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
)
|
||||
|
||||
|
||||
def _normalized_allowed(allowed: list[str]) -> set[str]:
|
||||
normalized: set[str] = set()
|
||||
for entry in allowed or []:
|
||||
try:
|
||||
normalized.add(gitea_config.normalize_operation(entry))
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]:
|
||||
"""Return forbidden ops that are explicitly listed in *allowed*."""
|
||||
allowed_n = _normalized_allowed(allowed)
|
||||
present: list[str] = []
|
||||
for op in ops:
|
||||
try:
|
||||
if gitea_config.normalize_operation(op) in allowed_n:
|
||||
present.append(op)
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return present
|
||||
|
||||
|
||||
def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool:
|
||||
"""Return True when *allowed*/*forbidden* describe a reconciler profile."""
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
if not can("gitea.pr.close"):
|
||||
return False
|
||||
if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict:
|
||||
"""Validate reconciler profile operations (read-only, fail closed)."""
|
||||
allowed = list(allowed or [])
|
||||
forbidden = list(forbidden or [])
|
||||
reasons: list[str] = []
|
||||
|
||||
for op in RECONCILER_REQUIRED_OPERATIONS:
|
||||
ok, _ = gitea_config.check_operation(op, allowed, forbidden)
|
||||
if not ok:
|
||||
reasons.append(f"missing required operation {op}")
|
||||
|
||||
for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
reasons.append(f"forbidden operation must not be allowed: {op}")
|
||||
|
||||
missing_recommended = [
|
||||
op for op in RECONCILER_RECOMMENDED_OPERATIONS
|
||||
if not gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
]
|
||||
|
||||
return {
|
||||
"is_reconciler_profile": is_reconciler_profile(allowed, forbidden),
|
||||
"valid": not reasons and is_reconciler_profile(allowed, forbidden),
|
||||
"reasons": reasons,
|
||||
"missing_recommended_operations": missing_recommended,
|
||||
"required_operations": list(RECONCILER_REQUIRED_OPERATIONS),
|
||||
"forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS),
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
"""Already-landed PR reconciliation workflow helpers (#301).
|
||||
|
||||
Read-only assessment and capability planning for reconciling open PRs whose
|
||||
heads are already ancestors of the target branch. Does not invoke review or
|
||||
merge paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import gitea_config
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED"
|
||||
OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP"
|
||||
OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY"
|
||||
OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION"
|
||||
OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN"
|
||||
|
||||
RECONCILE_WORKFLOW_MARKERS = (
|
||||
"workflows/reconcile-landed-pr.md",
|
||||
"reconcile-landed-pr.md",
|
||||
)
|
||||
|
||||
RECONCILE_TASK_MARKERS = (
|
||||
"reconcile-landed-pr",
|
||||
"reconcile already-landed",
|
||||
"reconcile_already_landed",
|
||||
)
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def profile_reconciliation_capabilities(
|
||||
allowed: list[str] | None,
|
||||
forbidden: list[str] | None,
|
||||
) -> dict[str, bool]:
|
||||
"""Map active profile operations to reconciliation capabilities."""
|
||||
allowed = allowed or []
|
||||
forbidden = forbidden or []
|
||||
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
return {
|
||||
"read": can("gitea.read"),
|
||||
"comment_pr": can("gitea.pr.comment"),
|
||||
"comment_issue": can("gitea.issue.comment"),
|
||||
"close_pr": can("gitea.pr.close"),
|
||||
"close_issue": can("gitea.issue.close"),
|
||||
"review_pr": can("gitea.pr.review") or can("gitea.pr.approve"),
|
||||
"merge_pr": can("gitea.pr.merge"),
|
||||
}
|
||||
|
||||
|
||||
def assess_open_pr_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether an open PR is eligible for already-landed reconciliation."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head = pr.get("head") or {}
|
||||
head_sha = head.get("sha") if isinstance(head, dict) else None
|
||||
head_ref = head.get("ref") if isinstance(head, dict) else None
|
||||
base = pr.get("base") or {}
|
||||
base_ref = base.get("ref") if isinstance(base, dict) else None
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(
|
||||
project_root, remote, target_branch
|
||||
)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
"review_merge_allowed": False,
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["reconciliation_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_reconciliation_plan(
|
||||
*,
|
||||
assessment: dict[str, Any],
|
||||
capabilities: dict[str, bool] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Map eligibility + profile capabilities to a reconciliation outcome."""
|
||||
caps = capabilities or {}
|
||||
reasons: list[str] = []
|
||||
|
||||
if not assessment.get("reconciliation_allowed"):
|
||||
outcome = OUTCOME_NOT_LANDED
|
||||
if assessment.get("eligibility_class") in {
|
||||
ELIGIBILITY_STALE_TARGET,
|
||||
ELIGIBILITY_PR_NOT_OPEN,
|
||||
}:
|
||||
outcome = OUTCOME_GATE_NOT_PROVEN
|
||||
reasons.extend(assessment.get("reasons") or [])
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": False,
|
||||
"comment_pr_allowed": False,
|
||||
"close_issue_allowed": False,
|
||||
"comment_issue_allowed": False,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": (
|
||||
"Do not reconcile via review/merge; repair missing proof first."
|
||||
),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
close_pr = bool(caps.get("close_pr"))
|
||||
comment_pr = bool(caps.get("comment_pr"))
|
||||
close_issue = bool(caps.get("close_issue"))
|
||||
comment_issue = bool(caps.get("comment_issue"))
|
||||
|
||||
if caps.get("review_pr") or caps.get("merge_pr"):
|
||||
reasons.append(
|
||||
"reconciliation path must not use review/merge capabilities"
|
||||
)
|
||||
|
||||
if close_pr:
|
||||
outcome = OUTCOME_FULL_RECONCILE
|
||||
safe_next_action = (
|
||||
"Run reconciliation close via exact gitea.pr.close after proof; "
|
||||
"do not approve or merge."
|
||||
)
|
||||
elif comment_pr:
|
||||
outcome = OUTCOME_PARTIAL_COMMENT
|
||||
safe_next_action = (
|
||||
"Post one reconciliation comment with ancestor proof, then stop "
|
||||
"for an authorized close profile."
|
||||
)
|
||||
else:
|
||||
outcome = OUTCOME_RECOVERY_HANDOFF
|
||||
safe_next_action = (
|
||||
"Produce a recovery handoff naming missing gitea.pr.close and/or "
|
||||
"gitea.pr.comment; do not loop through review/merge."
|
||||
)
|
||||
reasons.append("gitea.pr.close is not available in the active profile")
|
||||
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": close_pr,
|
||||
"comment_pr_allowed": comment_pr,
|
||||
"close_issue_allowed": close_issue,
|
||||
"comment_issue_allowed": comment_issue,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": safe_next_action,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]:
|
||||
missing = []
|
||||
if not caps.get("read"):
|
||||
missing.append("gitea.read")
|
||||
if not caps.get("close_pr"):
|
||||
missing.append("gitea.pr.close")
|
||||
if not caps.get("comment_pr"):
|
||||
missing.append("gitea.pr.comment")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]:
|
||||
"""Reconciliation reports must cite the canonical workflow file."""
|
||||
lower = (report_text or "").lower()
|
||||
reasons = []
|
||||
if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing workflow source "
|
||||
"(workflows/reconcile-landed-pr.md)"
|
||||
)
|
||||
if not any(marker in lower for marker in RECONCILE_TASK_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing task mode declaration "
|
||||
"(reconcile-landed-pr)"
|
||||
)
|
||||
return {
|
||||
"complete": not reasons,
|
||||
"downgraded": bool(reasons),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Remote/repo mismatch guard (#530).
|
||||
|
||||
Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES``
|
||||
table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo
|
||||
is ``Timesheet``, but the tools in this project operate on
|
||||
``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools
|
||||
worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``),
|
||||
the resolved target silently points at the wrong repository, producing false 404s
|
||||
and risking mutation of a different repo.
|
||||
|
||||
This module provides a pure assessment that compares the MCP-resolved ``org/repo``
|
||||
against the local git remote URL and fails closed on a genuine mismatch, unless the
|
||||
caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative)
|
||||
or the local remote URL is unavailable (best-effort corroboration only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
REMEDIATION = (
|
||||
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||
"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(
|
||||
*,
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
org_explicit: bool,
|
||||
repo_explicit: bool,
|
||||
) -> dict:
|
||||
"""Fail closed when the resolved org/repo disagrees with the local git remote.
|
||||
|
||||
The guard is intentionally conservative:
|
||||
|
||||
* When the caller passed both ``org`` and ``repo`` explicitly, their intent is
|
||||
authoritative and the guard never blocks.
|
||||
* When the local git remote URL is unavailable (``None``/empty), corroboration
|
||||
is impossible, so the guard does not block (best-effort only).
|
||||
* Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL
|
||||
(case-insensitive); if it does not, the guard blocks.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
if org_explicit and repo_explicit:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
url = (local_remote_url or "").strip()
|
||||
if not url:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
expected_slug = f"{resolved_org}/{resolved_repo}".lower()
|
||||
if expected_slug in url.lower():
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
reasons.append(
|
||||
f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote "
|
||||
f"'{remote}' does not match the local git remote URL '{url}'"
|
||||
)
|
||||
return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
|
||||
def format_remote_repo_guard_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for the MCP resolver gate."""
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["remote/repo resolution mismatch"]
|
||||
)
|
||||
resolved = (
|
||||
f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}"
|
||||
)
|
||||
local = assessment.get("local_remote_url") or "(unknown)"
|
||||
return (
|
||||
f"Remote/repo guard (#530): {reasons}. "
|
||||
f"Resolved target: {resolved}; local git remote: {local}. "
|
||||
f"{REMEDIATION}"
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"remote": remote,
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
"local_remote_url": local_remote_url,
|
||||
"remediation": REMEDIATION,
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Reviewer final-report schema verification before session output (#391).
|
||||
|
||||
Composes the composable ``assess_final_report_validator`` (#327) with
|
||||
review-specific schema gates required before a reviewer session completes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from final_report_validator import (
|
||||
assess_final_report_validator,
|
||||
validator_finding,
|
||||
_handoff_fields,
|
||||
)
|
||||
|
||||
_LEGACY_STALE_FIELDS = (
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
"workspace mutations",
|
||||
)
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_PASS_RE = re.compile(
|
||||
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGED_CLAIM_RE = re.compile(
|
||||
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
|
||||
_ANCESTRY_PROOF_RE = re.compile(
|
||||
r"(?:ancestor proof|target branch sha|merge commit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ISSUE_CLOSED_RE = re.compile(
|
||||
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_ISSUE_PROOF_RE = re.compile(
|
||||
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SUITE_PASS_RE = re.compile(
|
||||
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TESTS_IGNORED_RE = re.compile(
|
||||
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKED_HANDOFF_RE = re.compile(
|
||||
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPLAY_COMMAND_RE = re.compile(
|
||||
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
|
||||
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
|
||||
_APPROVED_RE = re.compile(
|
||||
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
|
||||
_FINDING_READY_RE = re.compile(
|
||||
r"(?:finding ready|ready to submit|not yet submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NARRATIVE_APPROVE_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_HANDOFF_DECISION_RE = re.compile(
|
||||
r"review decision\s*:\s*(\w+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
|
||||
fields = _handoff_fields(report_text)
|
||||
findings: list[dict[str, str]] = []
|
||||
for stale in _LEGACY_STALE_FIELDS:
|
||||
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.legacy_stale_field",
|
||||
"block",
|
||||
stale.title(),
|
||||
f"legacy or stale handoff field '{stale}' must not appear in "
|
||||
"canonical reviewer final reports",
|
||||
"remove stale fields; use Candidate head SHA and precise "
|
||||
"mutation categories instead",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _REVIEWED_HEAD_RE.search(text):
|
||||
return []
|
||||
if _VALIDATION_PASS_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.reviewed_head_without_validation",
|
||||
"block",
|
||||
"Pinned reviewed head",
|
||||
"reviewed/pinned head SHA reported without validation pass proof",
|
||||
"document validation command, cwd, and pass result before pinning head SHA",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _MERGED_CLAIM_RE.search(text):
|
||||
return []
|
||||
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.merged_without_proof",
|
||||
"block",
|
||||
"Merge result",
|
||||
"merged outcome claimed without merge result and target ancestry proof",
|
||||
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _ISSUE_CLOSED_RE.search(text):
|
||||
return []
|
||||
fields = _handoff_fields(text)
|
||||
status_value = fields.get("linked issue live status", "")
|
||||
readonly_value = fields.get("read-only diagnostics", "")
|
||||
proof_blob = f"{status_value} {readonly_value}".lower()
|
||||
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.issue_closed_without_live_proof",
|
||||
"block",
|
||||
"Linked issue",
|
||||
"issue closed claimed without live linked-issue verification proof",
|
||||
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
|
||||
return []
|
||||
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.full_suite_pass_ignored_tests",
|
||||
"block",
|
||||
"Validation",
|
||||
"full suite pass claimed while tests were ignored or skipped without "
|
||||
"explicit disclosure",
|
||||
"state which tests were ignored/skipped or downgrade validation verdict",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BLOCKED_HANDOFF_RE.search(text):
|
||||
return []
|
||||
if not _REPLAY_COMMAND_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.blocked_handoff_replay",
|
||||
"block",
|
||||
"Safe next action",
|
||||
"blocked recovery handoff includes direct approve or merge replay commands",
|
||||
"restart the full workflow after the blocker clears; do not replay mutations",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
|
||||
decision_match = _HANDOFF_DECISION_RE.search(text)
|
||||
if not narrative_approve or not decision_match:
|
||||
return []
|
||||
handoff_decision = decision_match.group(1).strip().lower()
|
||||
if handoff_decision in {"approve", "approved"}:
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.narrative_handoff_drift",
|
||||
"block",
|
||||
"Review decision",
|
||||
f"narrative approves PR but controller handoff says '{handoff_decision}'",
|
||||
"align narrative summary with controller handoff Review decision field",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
findings: list[dict[str, str]] = []
|
||||
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.pending_vs_approved",
|
||||
"block",
|
||||
"Review decision",
|
||||
"report conflates PENDING review state with APPROVED outcome",
|
||||
"distinguish official submitted review from pending or ready-to-submit state",
|
||||
)
|
||||
)
|
||||
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
|
||||
if "dry-run only" not in text.lower():
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.dry_run_vs_submitted",
|
||||
"block",
|
||||
"Review mutations",
|
||||
"dry-run language mixed with official approve/submitted claims",
|
||||
"mark dry-run explicitly or document the live review mutation proof",
|
||||
)
|
||||
)
|
||||
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.finding_ready_vs_submitted",
|
||||
"downgrade",
|
||||
"Review decision",
|
||||
"finding-ready wording combined with submitted-approve claims",
|
||||
"state whether review was submitted live or only prepared for submission",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
_SCHEMA_RULES = (
|
||||
_rule_legacy_stale_fields,
|
||||
_rule_reviewed_head_without_validation,
|
||||
_rule_merged_without_proof,
|
||||
_rule_issue_closed_without_live_proof,
|
||||
_rule_full_suite_pass_ignored_tests,
|
||||
_rule_blocked_handoff_replay,
|
||||
_rule_narrative_handoff_drift,
|
||||
_rule_review_state_ambiguous,
|
||||
)
|
||||
|
||||
|
||||
def _merge_validator_results(
|
||||
base: dict[str, Any],
|
||||
extra_findings: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
findings = list(base.get("findings") or []) + extra_findings
|
||||
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
|
||||
downgraded = base.get("downgraded") or any(
|
||||
f["severity"] == "downgrade" for f in extra_findings
|
||||
)
|
||||
grade = base.get("grade", "A")
|
||||
if blocked:
|
||||
grade = "blocked"
|
||||
elif downgraded and grade == "A":
|
||||
grade = "downgraded"
|
||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||
safe_next = base.get("safe_next_action") or "none"
|
||||
if extra_findings:
|
||||
safe_next = extra_findings[0].get("safe_next_action", safe_next)
|
||||
return {
|
||||
**base,
|
||||
"grade": grade,
|
||||
"blocked": blocked,
|
||||
"downgraded": downgraded,
|
||||
"findings": findings,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"complete": grade == "A",
|
||||
"schema_rules_applied": len(_SCHEMA_RULES),
|
||||
}
|
||||
|
||||
|
||||
def assess_review_final_report_schema(
|
||||
report_text: str,
|
||||
*,
|
||||
review_decision_lock: dict | None = None,
|
||||
linked_issue_lock: dict | None = None,
|
||||
validation_report: dict | None = None,
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate reviewer final report text before session completion (#391)."""
|
||||
base = assess_final_report_validator(
|
||||
report_text,
|
||||
"review_pr",
|
||||
review_decision_lock=review_decision_lock,
|
||||
linked_issue_lock=linked_issue_lock,
|
||||
validation_report=validation_report,
|
||||
action_log=action_log,
|
||||
mutations_observed=mutations_observed,
|
||||
local_edits=local_edits,
|
||||
validation_session=validation_session,
|
||||
)
|
||||
extra: list[dict[str, str]] = []
|
||||
for rule in _SCHEMA_RULES:
|
||||
extra.extend(rule(report_text))
|
||||
return _merge_validator_results(base, extra)
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Enforced PR review/merge workflow state machine (#290).
|
||||
|
||||
Review and merge must advance through explicit states. Any failed upstream
|
||||
gate forbids downstream approve/merge mutations until the full workflow is
|
||||
restarted after blockers clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
REVIEW_MERGE_STATES: tuple[str, ...] = (
|
||||
"PRECHECK",
|
||||
"INVENTORY",
|
||||
"SELECT_PR",
|
||||
"PIN_HEAD_SHA",
|
||||
"CREATE_BRANCHES_WORKTREE",
|
||||
"VALIDATE",
|
||||
"REVIEW_DECISION",
|
||||
"APPROVE_OR_REQUEST_CHANGES",
|
||||
"PRE_MERGE_RECHECK",
|
||||
"MERGE",
|
||||
"POST_MERGE_REPORT",
|
||||
)
|
||||
|
||||
TERMINAL_BLOCKED_HEADING = (
|
||||
"PR review/merge workflow blocked. Restart the full workflow after blockers clear."
|
||||
)
|
||||
|
||||
_FORBIDDEN_RECOVERY_REPLAY_RE = re.compile(
|
||||
r"\b(?:approve(?:\s+pr)?\s*#?\d+|merge(?:\s+pr)?\s*#?\d+|gitea_merge_pr|"
|
||||
r"gitea_submit_pr_review|submit\s+approve|run\s+merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESTART_WORKFLOW_RE = re.compile(
|
||||
r"restart(?:\s+the)?\s+full\s+workflow|rerun\s+the\s+full\s+workflow",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_READY_TO_MERGE_RE = re.compile(
|
||||
r"\bready\s+to\s+merge\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_VALIDATED_RE = re.compile(
|
||||
r"\b(?:reviewed|validated|approval\s+submitted)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PRE_MERGE_REQUIRED_GATES = (
|
||||
"whoami_verified",
|
||||
"profile_runtime_verified",
|
||||
"merge_capability_verified",
|
||||
"pr_refetched",
|
||||
"reviewed_head_sha_unchanged",
|
||||
"pr_mergeable",
|
||||
"checks_passed",
|
||||
"reviewer_not_author",
|
||||
"worktree_clean",
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def state_index(state: str) -> int:
|
||||
name = _clean(state).upper()
|
||||
try:
|
||||
return REVIEW_MERGE_STATES.index(name)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unknown review/merge state '{state}' (fail closed)") from exc
|
||||
|
||||
|
||||
def downstream_states(from_state: str) -> list[str]:
|
||||
idx = state_index(from_state)
|
||||
return list(REVIEW_MERGE_STATES[idx + 1 :])
|
||||
|
||||
|
||||
def _completed_through(state_completion: dict[str, bool], state: str) -> bool:
|
||||
return bool(state_completion.get(state))
|
||||
|
||||
|
||||
def _first_incomplete_state(state_completion: dict[str, bool]) -> str | None:
|
||||
for state in REVIEW_MERGE_STATES:
|
||||
if not _completed_through(state_completion, state):
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
def assess_workflow_blockers(
|
||||
*,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
mcp_reconnect_failed: bool = False,
|
||||
stale_capability_state: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
|
||||
reasons: list[str] = []
|
||||
if infra_stop:
|
||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||
if capability_blocked:
|
||||
reasons.append("gitea_resolve_task_capability returned blocked/stop_required")
|
||||
if mcp_reconnect_failed:
|
||||
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
||||
if stale_capability_state:
|
||||
reasons.append("stale MCP capability state detected after reconnect failure")
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"forbidden_states": list(REVIEW_MERGE_STATES) if reasons else [],
|
||||
"safe_next_action": (
|
||||
TERMINAL_BLOCKED_HEADING if reasons else "proceed with PRECHECK"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_state_advancement(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
target_state: str,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when *target_state* is requested before upstream gates pass."""
|
||||
completion = dict(state_completion or {})
|
||||
target = _clean(target_state).upper()
|
||||
blockers = assess_workflow_blockers(
|
||||
infra_stop=infra_stop,
|
||||
capability_blocked=capability_blocked,
|
||||
)
|
||||
reasons = list(blockers["reasons"])
|
||||
|
||||
try:
|
||||
target_idx = state_index(target)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": [str(exc)],
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": TERMINAL_BLOCKED_HEADING,
|
||||
}
|
||||
|
||||
if blockers["block"]:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": blockers["safe_next_action"],
|
||||
}
|
||||
|
||||
next_allowed = _first_incomplete_state(completion)
|
||||
if next_allowed is None:
|
||||
allowed = target_idx == len(REVIEW_MERGE_STATES) - 1
|
||||
if not allowed:
|
||||
reasons.append("workflow already completed through POST_MERGE_REPORT")
|
||||
else:
|
||||
allowed = state_index(next_allowed) >= target_idx
|
||||
if not allowed:
|
||||
reasons.append(
|
||||
f"state '{target}' is forbidden until '{next_allowed}' completes"
|
||||
)
|
||||
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": allowed and not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": next_allowed,
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"safe_next_action": (
|
||||
f"complete state '{next_allowed}' before advancing"
|
||||
if next_allowed and not allowed
|
||||
else (
|
||||
TERMINAL_BLOCKED_HEADING
|
||||
if reasons
|
||||
else f"advance to {target}"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def can_approve(state_completion: dict[str, bool] | None, **blocker_kwargs) -> dict[str, Any]:
|
||||
"""Approval requires all states through REVIEW_DECISION (#290 AC)."""
|
||||
required = REVIEW_MERGE_STATES[: REVIEW_MERGE_STATES.index("REVIEW_DECISION") + 1]
|
||||
completion = dict(state_completion or {})
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="APPROVE_OR_REQUEST_CHANGES",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
missing = [s for s in required if not completion.get(s)]
|
||||
reasons = list(advance["reasons"])
|
||||
if missing:
|
||||
reasons.append(
|
||||
"approval blocked: incomplete states: " + ", ".join(missing)
|
||||
)
|
||||
block = bool(reasons) or advance["block"]
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_states": missing,
|
||||
"safe_next_action": advance["safe_next_action"],
|
||||
}
|
||||
|
||||
|
||||
def can_merge(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
pre_merge_gates: dict[str, bool] | None = None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge requires approve path plus fresh PRE_MERGE_RECHECK gates (#290 AC6)."""
|
||||
completion = dict(state_completion or {})
|
||||
approve = can_approve(completion, **blocker_kwargs)
|
||||
reasons = list(approve["reasons"])
|
||||
|
||||
if not completion.get("APPROVE_OR_REQUEST_CHANGES"):
|
||||
reasons.append("merge blocked: APPROVE_OR_REQUEST_CHANGES not completed")
|
||||
|
||||
gate_map = dict(pre_merge_gates or {})
|
||||
missing_gates = [g for g in _PRE_MERGE_REQUIRED_GATES if not gate_map.get(g)]
|
||||
if missing_gates:
|
||||
reasons.append(
|
||||
"merge blocked: pre-merge gates incomplete: " + ", ".join(missing_gates)
|
||||
)
|
||||
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="MERGE",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
reasons.extend(advance["reasons"])
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_pre_merge_gates": missing_gates,
|
||||
"safe_next_action": (
|
||||
"complete PRE_MERGE_RECHECK with fresh whoami/capability/PR re-fetch "
|
||||
"before merge"
|
||||
if block
|
||||
else "merge allowed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_blocked_recovery_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""Blocked handoffs must not replay approve/merge commands (#290 AC4)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
if _FORBIDDEN_RECOVERY_REPLAY_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff contains direct approve/merge replay command"
|
||||
)
|
||||
if reasons and not _RESTART_WORKFLOW_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff must direct operator to restart full workflow"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove approve/merge replay commands and say to restart full workflow "
|
||||
"after blockers clear"
|
||||
if reasons
|
||||
else "recovery handoff wording acceptable"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_final_report_state_claims(
|
||||
report_text: str,
|
||||
*,
|
||||
state_completion: dict[str, bool] | None = None,
|
||||
approve_completed: bool = False,
|
||||
merge_completed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Reports must not claim reviewed/ready-to-merge without gate proof (#290 AC10)."""
|
||||
text = report_text or ""
|
||||
completion = dict(state_completion or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _READY_TO_MERGE_RE.search(text) and not (
|
||||
merge_completed or completion.get("PRE_MERGE_RECHECK")
|
||||
):
|
||||
reasons.append(
|
||||
"report claims ready-to-merge without PRE_MERGE_RECHECK completion"
|
||||
)
|
||||
|
||||
if _REVIEWED_VALIDATED_RE.search(text):
|
||||
if not (approve_completed or completion.get("VALIDATE")):
|
||||
reasons.append(
|
||||
"report claims reviewed/validated without VALIDATE/APPROVE proof"
|
||||
)
|
||||
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove stale reviewed/ready-to-merge claims unless gates passed"
|
||||
if reasons
|
||||
else "final report state claims consistent with workflow"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def workflow_status(
|
||||
state_completion: dict[str, bool] | None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize current state-machine position for MCP/runtime reporting."""
|
||||
completion = dict(state_completion or {})
|
||||
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||
next_state = _first_incomplete_state(completion)
|
||||
return {
|
||||
"states": list(REVIEW_MERGE_STATES),
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"next_required_state": next_state,
|
||||
"workflow_complete": next_state is None,
|
||||
"approve_allowed": can_approve(completion, **blocker_kwargs)["allowed"],
|
||||
"merge_allowed": can_merge(completion, **blocker_kwargs)["allowed"],
|
||||
"blockers": blockers,
|
||||
}
|
||||
+5
-4512
File diff suppressed because it is too large
Load Diff
@@ -1,259 +0,0 @@
|
||||
"""Reviewer session boundary tracking for workflow-load gate (#403).
|
||||
|
||||
Pre-review commands executed before ``gitea_load_review_workflow`` must be
|
||||
classified. Boundary violations block downstream reviewer mutations even when
|
||||
workflow hash proof is present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLASSIFICATION_READ_ONLY_INVENTORY = "read_only_inventory"
|
||||
CLASSIFICATION_DIAGNOSTIC = "diagnostic"
|
||||
CLASSIFICATION_BOUNDARY_VIOLATION = "boundary_violation"
|
||||
CLASSIFICATION_UNCLASSIFIED = "unclassified"
|
||||
|
||||
ALLOWED_CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_READ_ONLY_INVENTORY,
|
||||
CLASSIFICATION_DIAGNOSTIC,
|
||||
CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
CLASSIFICATION_UNCLASSIFIED,
|
||||
})
|
||||
|
||||
_PRE_REVIEW_COMMANDS: list[dict[str, Any]] = []
|
||||
|
||||
_READ_ONLY_INVENTORY_PATTERNS = (
|
||||
re.compile(
|
||||
r"\bgitea[_-](?:list|view|whoami|get[-_]|resolve[-_]task|check[-_]pr|route[-_]task)",
|
||||
re.I,
|
||||
),
|
||||
re.compile(r"\bgit\s+(?:fetch|remote\s+update|branch\s+-a|log|show|rev-parse)\b", re.I),
|
||||
re.compile(r"\bgit\s+status\b", re.I),
|
||||
re.compile(r"\bgit\s+worktree\s+list\b", re.I),
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_PATTERNS = (
|
||||
re.compile(r"\bgit\s+diff(?:\s+--stat)?\b", re.I),
|
||||
re.compile(r"\bwhich\s+pytest\b", re.I),
|
||||
re.compile(r"\bpytest\s+--version\b", re.I),
|
||||
)
|
||||
|
||||
_BOUNDARY_VIOLATION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\b(?:pytest|python\s+-m\s+pytest|python\s+-m\s+unittest)\b", re.I),
|
||||
"validation command before workflow load"),
|
||||
(re.compile(r"\bprofiles\.json\b", re.I), "local profile config inspection"),
|
||||
(re.compile(r"\bgitea-mcp(?:\.v2-contexts)?\.json\b", re.I),
|
||||
"local Gitea MCP config inspection"),
|
||||
(re.compile(r"\b\.env(?:\.|$|\b)", re.I), "credential file inspection"),
|
||||
(re.compile(r"\bkeychain\b", re.I), "credential store inspection"),
|
||||
(re.compile(r"\bpkill\b", re.I), "MCP repair activity"),
|
||||
(re.compile(r"\b(?:edit|write|modify).{0,40}\bmcp\b", re.I),
|
||||
"MCP config exploration"),
|
||||
(re.compile(r"\bgit\s+(?:add|commit|reset|clean|checkout|merge|rebase|push)\b", re.I),
|
||||
"git mutation before workflow load"),
|
||||
)
|
||||
|
||||
|
||||
def clear_pre_review_commands() -> None:
|
||||
"""Test helper and session reset."""
|
||||
global _PRE_REVIEW_COMMANDS
|
||||
_PRE_REVIEW_COMMANDS = []
|
||||
|
||||
|
||||
def pre_review_commands() -> list[dict[str, Any]]:
|
||||
"""Return a shallow copy of recorded pre-review commands."""
|
||||
return [dict(entry) for entry in _PRE_REVIEW_COMMANDS]
|
||||
|
||||
|
||||
def _normalize_path(path: str | None) -> str:
|
||||
return os.path.realpath(os.path.abspath((path or "").strip() or os.getcwd()))
|
||||
|
||||
|
||||
def is_main_checkout_path(cwd: str | None, project_root: str | None) -> bool:
|
||||
"""True when *cwd* is the stable control checkout (not under branches/)."""
|
||||
if not project_root:
|
||||
return False
|
||||
root = _normalize_path(project_root)
|
||||
path = _normalize_path(cwd)
|
||||
if path != root:
|
||||
return False
|
||||
marker = f"{os.sep}branches{os.sep}"
|
||||
return marker not in path
|
||||
|
||||
|
||||
def classify_pre_review_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a command executed before workflow load."""
|
||||
text = (command or "").strip()
|
||||
path = _normalize_path(cwd)
|
||||
root = _normalize_path(project_root) if project_root else None
|
||||
reasons: list[str] = []
|
||||
|
||||
for pattern, label in _BOUNDARY_VIOLATION_PATTERNS:
|
||||
if pattern.search(text):
|
||||
if label.startswith("validation") and root and not is_main_checkout_path(path, root):
|
||||
continue
|
||||
if label.startswith("git mutation") and root and not is_main_checkout_path(path, root):
|
||||
continue
|
||||
reasons.append(label)
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
for pattern in _READ_ONLY_INVENTORY_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_READ_ONLY_INVENTORY,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
for pattern in _DIAGNOSTIC_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_DIAGNOSTIC,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
if root and is_main_checkout_path(path, root):
|
||||
if re.search(r"\b(?:cat|head|less|read)\b", text, re.I):
|
||||
if re.search(r"workflow|skill|runbook", text, re.I):
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
"reasons": [
|
||||
"canonical workflow viewed as local file without "
|
||||
"gitea_load_review_workflow (narrative load is not proof)"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"command": text,
|
||||
"cwd": path,
|
||||
"classification": CLASSIFICATION_UNCLASSIFIED,
|
||||
"reasons": [
|
||||
"pre-review command not classified; record via "
|
||||
"gitea_record_pre_review_command before workflow load"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def record_pre_review_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
project_root: str | None = None,
|
||||
classification: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record and classify a pre-review command for the current session."""
|
||||
assessed = classify_pre_review_command(
|
||||
command, cwd=cwd, project_root=project_root)
|
||||
if classification:
|
||||
if classification not in ALLOWED_CLASSIFICATIONS:
|
||||
assessed["classification"] = CLASSIFICATION_UNCLASSIFIED
|
||||
assessed["reasons"] = [
|
||||
f"unknown classification '{classification}'; fail closed"
|
||||
]
|
||||
else:
|
||||
assessed["classification"] = classification
|
||||
assessed["reasons"] = []
|
||||
entry = {
|
||||
**assessed,
|
||||
"session_pid": os.getpid(),
|
||||
}
|
||||
_PRE_REVIEW_COMMANDS.append(entry)
|
||||
return dict(entry)
|
||||
|
||||
|
||||
def assess_boundary_status(project_root: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize pre-review boundary state for session proof and reports."""
|
||||
violations = [
|
||||
entry for entry in _PRE_REVIEW_COMMANDS
|
||||
if entry.get("classification") == CLASSIFICATION_BOUNDARY_VIOLATION
|
||||
]
|
||||
unclassified = [
|
||||
entry for entry in _PRE_REVIEW_COMMANDS
|
||||
if entry.get("classification") == CLASSIFICATION_UNCLASSIFIED
|
||||
]
|
||||
reasons: list[str] = []
|
||||
for entry in violations:
|
||||
reasons.extend(entry.get("reasons") or [
|
||||
f"boundary violation: {entry.get('command', '')[:80]}"
|
||||
])
|
||||
for entry in unclassified:
|
||||
reasons.extend(entry.get("reasons") or [
|
||||
"unclassified pre-review command blocks reviewer mutations"
|
||||
])
|
||||
|
||||
clean = not reasons
|
||||
return {
|
||||
"boundary_status": "clean" if clean else "violation",
|
||||
"boundary_clean": clean,
|
||||
"pre_review_command_count": len(_PRE_REVIEW_COMMANDS),
|
||||
"boundary_violation_count": len(violations),
|
||||
"unclassified_command_count": len(unclassified),
|
||||
"violations": [
|
||||
{
|
||||
"command": v.get("command"),
|
||||
"cwd": v.get("cwd"),
|
||||
"reasons": list(v.get("reasons") or []),
|
||||
}
|
||||
for v in violations
|
||||
],
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def boundary_blockers(project_root: str | None = None) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed due to boundary state."""
|
||||
status = assess_boundary_status(project_root)
|
||||
if status.get("boundary_clean"):
|
||||
return []
|
||||
return list(status.get("reasons") or [
|
||||
"reviewer session boundary violation before workflow load"
|
||||
])
|
||||
|
||||
|
||||
def workflow_load_helper_result(
|
||||
load: dict | None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured helper result for final reports (#403)."""
|
||||
boundary = assess_boundary_status(project_root)
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_hash": None,
|
||||
"boundary_status": boundary.get("boundary_status"),
|
||||
"boundary_clean": False,
|
||||
"reasons": [
|
||||
"gitea_load_review_workflow helper result missing from report"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"boundary_status": load.get("boundary_status", boundary.get("boundary_status")),
|
||||
"boundary_clean": bool(load.get("boundary_clean", boundary.get("boundary_clean"))),
|
||||
"pre_review_command_count": boundary.get("pre_review_command_count"),
|
||||
"reasons": [],
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import mcp_session_state
|
||||
import review_workflow_boundary as boundary
|
||||
|
||||
WORKFLOW_REL_PATH = (
|
||||
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
||||
)
|
||||
SCHEMA_REL_PATH = (
|
||||
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
||||
)
|
||||
TASK_MODE = "review-merge-pr"
|
||||
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
||||
|
||||
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
||||
|
||||
|
||||
def compute_content_hash(text: str) -> str:
|
||||
"""Short deterministic hash for workflow/schema version proof."""
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
||||
root = Path(project_root)
|
||||
workflow = root / WORKFLOW_REL_PATH
|
||||
schema = root / SCHEMA_REL_PATH
|
||||
if not workflow.is_file():
|
||||
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
||||
if not schema.is_file():
|
||||
raise FileNotFoundError(f"final report schema missing: {schema}")
|
||||
return workflow, schema
|
||||
|
||||
|
||||
def build_canonical_workflow_metadata(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Load workflow + schema from disk and compute proof metadata."""
|
||||
workflow_path, schema_path = _canonical_paths(project_root)
|
||||
workflow_text = _read_text(workflow_path)
|
||||
schema_text = _read_text(schema_path)
|
||||
workflow_hash = compute_content_hash(workflow_text)
|
||||
schema_hash = compute_content_hash(schema_text)
|
||||
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
||||
return {
|
||||
"workflow_source": WORKFLOW_REL_PATH,
|
||||
"workflow_path": str(workflow_path),
|
||||
"task_mode": TASK_MODE,
|
||||
"workflow_hash": workflow_hash,
|
||||
"workflow_version": workflow_hash,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"final_report_schema_hash": schema_hash,
|
||||
"prompt_conflicts_with_workflow": conflict,
|
||||
"prompt_conflict_reasons": conflict_reasons,
|
||||
"load_tool": LOAD_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
||||
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
||||
if not (prompt_text or "").strip():
|
||||
return False, []
|
||||
text = prompt_text.lower()
|
||||
reasons: list[str] = []
|
||||
conflicting = (
|
||||
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
||||
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
||||
(r"\bauthor/coder\b", "author/coder mode"),
|
||||
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
||||
)
|
||||
for pattern, label in conflicting:
|
||||
if re.search(pattern, text):
|
||||
reasons.append(
|
||||
f"active prompt appears to request {label} while loading "
|
||||
f"{TASK_MODE} workflow"
|
||||
)
|
||||
return bool(reasons), reasons
|
||||
|
||||
|
||||
def _session_binding_fields() -> dict:
|
||||
"""Capture profile identity used to share state across daemon processes."""
|
||||
env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
||||
remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None
|
||||
identity = mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=env_lock,
|
||||
)
|
||||
return {
|
||||
"session_profile": profile_name or identity,
|
||||
"session_profile_lock": env_lock or identity,
|
||||
"profile_identity": identity,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
|
||||
def _persist_workflow_load(record: dict | None) -> dict | None:
|
||||
"""Write durable workflow-load proof (or clear it)."""
|
||||
binding = _session_binding_fields()
|
||||
if record is None:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
return None
|
||||
payload = dict(record)
|
||||
payload.update({
|
||||
k: v for k, v in binding.items() if v is not None
|
||||
})
|
||||
return mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload=payload,
|
||||
remote=payload.get("remote"),
|
||||
org=payload.get("org"),
|
||||
repo=payload.get("repo"),
|
||||
profile_identity=payload.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _load_durable_workflow_load() -> dict | None:
|
||||
binding = _session_binding_fields()
|
||||
return mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _active_workflow_load() -> dict | None:
|
||||
"""Prefer in-process cache; fall back to durable shared state (#559)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
if _REVIEW_WORKFLOW_LOAD is not None:
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
durable = _load_durable_workflow_load()
|
||||
if durable is not None:
|
||||
_REVIEW_WORKFLOW_LOAD = dict(durable)
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
|
||||
|
||||
def record_review_workflow_load(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Record workflow load proof for the current MCP session (durable + memory)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
meta = build_canonical_workflow_metadata(
|
||||
project_root, prompt_text=prompt_text)
|
||||
boundary_state = boundary.assess_boundary_status(project_root)
|
||||
binding = _session_binding_fields()
|
||||
record = {
|
||||
**meta,
|
||||
"session_pid": os.getpid(),
|
||||
"loaded": True,
|
||||
"boundary_status": boundary_state.get("boundary_status"),
|
||||
"boundary_clean": boundary_state.get("boundary_clean"),
|
||||
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
||||
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
||||
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
||||
**{k: v for k, v in binding.items() if v is not None},
|
||||
}
|
||||
persisted = _persist_workflow_load(record)
|
||||
_REVIEW_WORKFLOW_LOAD = dict(persisted or record)
|
||||
return dict(_REVIEW_WORKFLOW_LOAD)
|
||||
|
||||
|
||||
def clear_review_workflow_load() -> None:
|
||||
"""Test helper and review_pr session reset."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
_REVIEW_WORKFLOW_LOAD = None
|
||||
_persist_workflow_load(None)
|
||||
boundary.clear_pre_review_commands()
|
||||
|
||||
|
||||
def workflow_load_status(project_root: str | None = None) -> dict:
|
||||
"""Non-throwing status for capability/runtime reports."""
|
||||
load = _active_workflow_load()
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_load_valid": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"reasons": [
|
||||
f"{LOAD_TOOL_NAME} has not been called in this session "
|
||||
"(fail closed for reviewer mutations)"
|
||||
],
|
||||
}
|
||||
reasons = _session_validation_reasons(load, project_root)
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
if boundary_reasons:
|
||||
reasons = list(reasons) + boundary_reasons
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_load_valid": not reasons,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"task_mode": load.get("task_mode"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"prompt_conflicts_with_workflow": load.get(
|
||||
"prompt_conflicts_with_workflow"),
|
||||
"session_pid": load.get("session_pid"),
|
||||
"writer_pid": load.get("writer_pid"),
|
||||
"profile_identity": load.get("profile_identity"),
|
||||
"boundary_status": load.get("boundary_status"),
|
||||
"boundary_clean": load.get("boundary_clean"),
|
||||
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
||||
load, project_root),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _session_validation_reasons(
|
||||
load: dict,
|
||||
project_root: str | None,
|
||||
) -> list[str]:
|
||||
"""Validate durable/in-memory load proof for this session identity (#559)."""
|
||||
reasons: list[str] = []
|
||||
# Cross-process daemon pools are allowed when profile identity matches.
|
||||
# Reject only when the stored profile identity conflicts with this process.
|
||||
binding = _session_binding_fields()
|
||||
stored_identity = (
|
||||
load.get("session_profile_lock")
|
||||
or load.get("profile_identity")
|
||||
or ""
|
||||
).strip()
|
||||
active_identity = (binding.get("profile_identity") or "").strip()
|
||||
if (
|
||||
stored_identity
|
||||
and active_identity
|
||||
and stored_identity != active_identity
|
||||
and active_identity != "unknown-profile"
|
||||
and stored_identity != "unknown-profile"
|
||||
):
|
||||
reasons.append(
|
||||
"workflow load proof profile identity mismatch "
|
||||
f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
# Expired durable records are treated as absent.
|
||||
identity_reasons = mcp_session_state.identity_match_reasons(
|
||||
load,
|
||||
remote=binding.get("remote") or load.get("remote"),
|
||||
org=load.get("org"),
|
||||
repo=load.get("repo"),
|
||||
profile_identity=active_identity or stored_identity,
|
||||
)
|
||||
# Filter out remote-mismatch noise when remote was not bound at load time.
|
||||
for reason in identity_reasons:
|
||||
if "missing recorded_at" in reason or "expired" in reason or "future" in reason:
|
||||
reasons.append(reason)
|
||||
elif "profile identity mismatch" in reason:
|
||||
reasons.append(reason)
|
||||
if reasons:
|
||||
return reasons
|
||||
|
||||
if load.get("prompt_conflicts_with_workflow"):
|
||||
reasons.extend(load.get("prompt_conflict_reasons") or [
|
||||
"active prompt conflicts with loaded review-merge workflow"
|
||||
])
|
||||
if project_root:
|
||||
try:
|
||||
current = build_canonical_workflow_metadata(project_root)
|
||||
except OSError as exc:
|
||||
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
||||
return reasons
|
||||
if current["workflow_hash"] != load.get("workflow_hash"):
|
||||
reasons.append(
|
||||
"stored workflow hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
if current["final_report_schema_hash"] != load.get(
|
||||
"final_report_schema_hash"):
|
||||
reasons.append(
|
||||
"stored final-report schema hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def review_workflow_load_blockers(
|
||||
project_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed."""
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
load = _active_workflow_load()
|
||||
if boundary_reasons and load is None:
|
||||
return boundary_reasons
|
||||
status = workflow_load_status(project_root)
|
||||
if not status.get("workflow_load_proof_present"):
|
||||
return list(status.get("reasons") or []) + boundary_reasons
|
||||
if not status.get("workflow_load_valid"):
|
||||
return list(status.get("reasons") or [])
|
||||
return []
|
||||
|
||||
|
||||
def recovery_handoff_without_replay() -> list[str]:
|
||||
"""Safe next-step lines that must not include approve/merge replay."""
|
||||
return [
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory.",
|
||||
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
||||
"or gitea_merge_pr until workflow-load proof is present.",
|
||||
"Do not include approve/merge replay commands in the recovery handoff.",
|
||||
]
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Already-landed PR classification verifier for reviewer reports (#295)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
ALREADY_LANDED_ELIGIBILITY_CLASS = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
|
||||
_LANDED_CONTEXT_RE = re.compile(
|
||||
r"already[- ]landed|ancestor proof\s*:\s*passed|"
|
||||
r"eligibility class\s*:\s*already_landed",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ELIGIBILITY_CLASS_LINE_RE = re.compile(
|
||||
r"eligibility class\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_INELIGIBLE_SELECTION_RE = re.compile(
|
||||
r"\b(?:next|oldest)\s+eligible\s+pr\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_ELIGIBLE_RE = re.compile(
|
||||
r"\beligible for (?:review|merge)\b|\bready for (?:review|merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PINNED_REVIEWED_HEAD_RE = re.compile(
|
||||
r"\bpinned reviewed head\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CANDIDATE_HEAD_RE = re.compile(
|
||||
r"\bcandidate head sha\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_HEAD_NONE_RE = re.compile(
|
||||
r"\breviewed head sha\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_PROOF_RE = re.compile(
|
||||
r"(?:validation passed|pytest.*passed|diff review passed|"
|
||||
r"validated on pinned head)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_QUEUE_BLOCKED_RE = re.compile(
|
||||
r"queue blocked by already[- ]landed|"
|
||||
r"reconciliation(?:-only)?\s+(?:next step|required)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _landed_context(report_text: str, eligibility_class: str | None) -> bool:
|
||||
if (eligibility_class or "").strip().upper() == ALREADY_LANDED_ELIGIBILITY_CLASS:
|
||||
return True
|
||||
return bool(_LANDED_CONTEXT_RE.search(report_text or ""))
|
||||
|
||||
|
||||
def assess_already_landed_classification_report(
|
||||
report_text: str,
|
||||
*,
|
||||
eligibility_class: str | None = None,
|
||||
selected_pr_already_landed: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
landed = _landed_context(text, eligibility_class)
|
||||
if selected_pr_already_landed is True:
|
||||
landed = True
|
||||
if not landed:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"landed_context": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _INELIGIBLE_SELECTION_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed PR must not be described as oldest/next eligible PR; "
|
||||
"use 'Oldest open PR requiring action' and "
|
||||
f"'Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}'"
|
||||
)
|
||||
|
||||
if _REVIEW_ELIGIBLE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed PR must not be described as eligible for review or merge"
|
||||
)
|
||||
|
||||
class_match = _ELIGIBILITY_CLASS_LINE_RE.search(text)
|
||||
if class_match:
|
||||
declared = class_match.group(1).strip().upper()
|
||||
if declared != ALREADY_LANDED_ELIGIBILITY_CLASS:
|
||||
reasons.append(
|
||||
"already-landed PR eligibility class must be "
|
||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
||||
)
|
||||
elif selected_pr_already_landed is True:
|
||||
reasons.append(
|
||||
f"already-landed selected PR must declare Eligibility class: "
|
||||
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
|
||||
)
|
||||
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text):
|
||||
if not _VALIDATION_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed pre-review report must use Candidate head SHA, "
|
||||
"not Pinned reviewed head, unless validation and diff review passed"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _CANDIDATE_HEAD_RE.search(text):
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text) or "head sha" in text.lower():
|
||||
reasons.append(
|
||||
"already-landed ancestry check must report Candidate head SHA"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _REVIEWED_HEAD_NONE_RE.search(text):
|
||||
if _PINNED_REVIEWED_HEAD_RE.search(text) and not _VALIDATION_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed report must state 'Reviewed head SHA: none' "
|
||||
"when validation did not run"
|
||||
)
|
||||
|
||||
if selected_pr_already_landed is True and not _QUEUE_BLOCKED_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed queue blocker must state reconciliation requirement "
|
||||
"or queue blocked wording"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"landed_context": True,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"classify as ALREADY_LANDED_RECONCILE_REQUIRED, use Candidate head SHA, "
|
||||
"and stop normal review"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""Already-landed controller handoff consistency verifier (#299)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from review_proofs import git_ref_mutating_commands
|
||||
|
||||
ALREADY_LANDED_STATE = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
_STALE_HANDOFF_FIELDS = (
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
)
|
||||
|
||||
_LEGACY_MUTATIONS_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
_LEGACY_WORKSPACE_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*workspace\s+mutations\s*:\s*none\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
_FORBIDDEN_REVIEW_STATES_RE = re.compile(
|
||||
r"review decision\s*:\s*approved?\b|"
|
||||
r"merge result\s*:\s*merged\b|"
|
||||
r"\bready[_ ]to[_ ]merge\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_NARRATIVE_ELIGIBILITY_RE = re.compile(
|
||||
r"eligibility class\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
_NARRATIVE_REVIEWED_SHA_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
_NARRATIVE_WORKTREE_RE = re.compile(
|
||||
r"review worktree used\s*:\s*([^\n]+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].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 _narrative_before_handoff(report_text: str) -> str:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return text
|
||||
return text[: match.start()]
|
||||
|
||||
|
||||
def _is_truthy(value: str) -> bool:
|
||||
lowered = (value or "").strip().lower()
|
||||
return lowered not in {"", "none", "n/a", "not applicable", "false", "no", "—", "-"}
|
||||
|
||||
|
||||
def _gate_active(text: str, fields: dict[str, str], session: dict) -> bool:
|
||||
if session.get("gate_fired") or session.get("already_landed_gate_fired"):
|
||||
return True
|
||||
eligibility = (fields.get("eligibility class") or "").upper()
|
||||
if ALREADY_LANDED_STATE in eligibility:
|
||||
return True
|
||||
return ALREADY_LANDED_STATE.lower() in text.lower()
|
||||
|
||||
|
||||
def assess_already_landed_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
handoff_session: dict | None = None,
|
||||
command_log: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject stale handoff fields and narrative/handoff drift after the gate (#299)."""
|
||||
text = report_text or ""
|
||||
session = dict(handoff_session or {})
|
||||
fields = _handoff_field_map(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not _gate_active(text, fields, session):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"gate_active": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
for stale_field in _STALE_HANDOFF_FIELDS:
|
||||
if stale_field in fields:
|
||||
reasons.append(
|
||||
f"already-landed handoff must not include stale field "
|
||||
f"'{stale_field.title()}' (#299)"
|
||||
)
|
||||
|
||||
if _LEGACY_WORKSPACE_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not include legacy "
|
||||
"'Workspace mutations: None' (#299)"
|
||||
)
|
||||
|
||||
ref_commands = git_ref_mutating_commands(command_log or session.get("command_log"))
|
||||
mutations_observed = bool(
|
||||
ref_commands
|
||||
or session.get("mutations_observed")
|
||||
or session.get("mcp_mutations")
|
||||
or session.get("review_mutations")
|
||||
)
|
||||
if mutations_observed and _LEGACY_MUTATIONS_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not claim 'Mutations: None' when "
|
||||
"git ref, MCP, review, merge, or cleanup mutations occurred (#299)"
|
||||
)
|
||||
|
||||
git_ref_value = fields.get("git ref mutations", "").strip().lower()
|
||||
if ref_commands and (not git_ref_value or git_ref_value == "none"):
|
||||
reasons.append(
|
||||
"git fetch/ref updates must be reported under Git ref mutations (#299)"
|
||||
)
|
||||
|
||||
reviewed_sha = fields.get("reviewed head sha", "")
|
||||
if reviewed_sha and _is_truthy(reviewed_sha):
|
||||
reasons.append(
|
||||
"already-landed handoff must use 'Reviewed head SHA: none' (#299)"
|
||||
)
|
||||
|
||||
worktree_used = fields.get("review worktree used", "")
|
||||
if worktree_used and _is_truthy(worktree_used):
|
||||
reasons.append(
|
||||
"already-landed handoff must set Review worktree used: false (#299)"
|
||||
)
|
||||
|
||||
candidate_sha = fields.get("candidate head sha", "")
|
||||
if not candidate_sha or candidate_sha.lower() in {"none", "n/a", "unknown"}:
|
||||
reasons.append(
|
||||
"already-landed handoff must include Candidate head SHA (#299)"
|
||||
)
|
||||
|
||||
if _FORBIDDEN_REVIEW_STATES_RE.search(text):
|
||||
reasons.append(
|
||||
"already-landed handoff must not claim approved/merged/ready-to-merge (#299)"
|
||||
)
|
||||
|
||||
narrative = _narrative_before_handoff(text)
|
||||
handoff_eligibility = (fields.get("eligibility class") or "").strip()
|
||||
narrative_eligibility = (
|
||||
_NARRATIVE_ELIGIBILITY_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_ELIGIBILITY_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if handoff_eligibility and narrative_eligibility:
|
||||
if handoff_eligibility.upper() != narrative_eligibility.upper():
|
||||
reasons.append(
|
||||
"narrative Eligibility class disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
narrative_reviewed = (
|
||||
_NARRATIVE_REVIEWED_SHA_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_REVIEWED_SHA_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if narrative_reviewed and reviewed_sha:
|
||||
if narrative_reviewed.lower() != reviewed_sha.lower():
|
||||
reasons.append(
|
||||
"narrative Reviewed head SHA disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
narrative_worktree = (
|
||||
_NARRATIVE_WORKTREE_RE.search(narrative).group(1).strip()
|
||||
if _NARRATIVE_WORKTREE_RE.search(narrative)
|
||||
else ""
|
||||
)
|
||||
if narrative_worktree and worktree_used:
|
||||
if narrative_worktree.lower() != worktree_used.lower():
|
||||
reasons.append(
|
||||
"narrative Review worktree used disagrees with controller handoff (#299)"
|
||||
)
|
||||
|
||||
git_ref_narrative = "git ref mutations" in narrative.lower()
|
||||
if git_ref_narrative and git_ref_value:
|
||||
if "none" in git_ref_value and ref_commands:
|
||||
reasons.append(
|
||||
"narrative and handoff disagree on git ref mutation reporting (#299)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"gate_active": True,
|
||||
"safe_next_action": (
|
||||
"emit canonical ALREADY_LANDED_RECONCILE_REQUIRED handoff without "
|
||||
"stale review/merge fields; align narrative and controller handoff"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Prior-blocker skip proof verifier for reviewer queue reports (#318)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
|
||||
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
|
||||
_BLOCKING_DECISION_RE = re.compile(
|
||||
r"(?:blocking review decision|review decision|blocking decision)\s*:\s*request[_ ]changes|"
|
||||
r"request[_ ]changes",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKING_HEAD_RE = re.compile(
|
||||
r"(?:blocking review head(?:\s+sha)?|blocker head(?:\s+sha)?|review head at blocker)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HEAD_CHANGED_RE = re.compile(
|
||||
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
|
||||
r"head changed (?:after|since) (?:the )?blocker|head unchanged since blocker)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKER_REASON_RE = re.compile(
|
||||
r"(?:reason (?:it )?remains blocked|remains blocked because|blocking category)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_BLOCKER_PROOF_RE = re.compile(
|
||||
r"(?:blocker revalidated live|live proof|gitea_get_pr_review_feedback|"
|
||||
r"gitea_view_pr|review feedback fetched|current review state)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKER_UNVERIFIED_RE = re.compile(r"\bBLOCKER_STATUS_UNVERIFIED\b")
|
||||
|
||||
|
||||
def _pr_section(text: str, pr_number: int) -> str:
|
||||
lines = (text or "").splitlines()
|
||||
chunks: list[str] = []
|
||||
capture = False
|
||||
token = f"#{pr_number}"
|
||||
for line in lines:
|
||||
lower = line.lower()
|
||||
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
|
||||
capture = True
|
||||
chunks.append(line)
|
||||
continue
|
||||
if capture:
|
||||
if _PR_NUMBER_RE.search(line) and token not in line.lower():
|
||||
break
|
||||
if line.strip() == "" and len(chunks) > 3:
|
||||
break
|
||||
chunks.append(line)
|
||||
if chunks:
|
||||
return "\n".join(chunks)
|
||||
return text or ""
|
||||
|
||||
|
||||
def _has_head_sha(text: str, head_sha: str | None) -> bool:
|
||||
if not head_sha:
|
||||
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
|
||||
head = head_sha.strip().lower()
|
||||
if head in text.lower():
|
||||
return True
|
||||
if len(head) >= 7 and head[:7] in text.lower():
|
||||
return True
|
||||
return bool(_FULL_SHA.search(text))
|
||||
|
||||
|
||||
def assess_prior_blocker_skip_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
skipped_prs: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate live blocker proof for skipped earlier open PRs (#318)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
assessments: list[dict[str, Any]] = []
|
||||
|
||||
for entry in skipped_prs or []:
|
||||
pr_number = entry.get("pr_number")
|
||||
if pr_number is None:
|
||||
reasons.append("skipped PR entry missing pr_number")
|
||||
continue
|
||||
pr_number = int(pr_number)
|
||||
section = _pr_section(text, pr_number)
|
||||
verified = entry.get("blocker_verified")
|
||||
head_changed = entry.get("head_changed_since_blocker")
|
||||
blocking_head = (entry.get("blocking_review_head_sha") or "").strip() or None
|
||||
current_head = (entry.get("head_sha") or "").strip() or None
|
||||
skip_reason = (entry.get("skip_reason") or entry.get("blocking_decision") or "").lower()
|
||||
|
||||
item_reasons: list[str] = []
|
||||
|
||||
if head_changed is True:
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} head changed after blocker; it cannot be skipped "
|
||||
"based on stale REQUEST_CHANGES"
|
||||
)
|
||||
|
||||
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
|
||||
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
|
||||
|
||||
if "request_changes" in skip_reason or entry.get("blocking_decision") == "request_changes":
|
||||
if verified is False:
|
||||
if not _BLOCKER_UNVERIFIED_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} blocker proof unavailable; report must classify "
|
||||
"BLOCKER_STATUS_UNVERIFIED"
|
||||
)
|
||||
else:
|
||||
if not _BLOCKING_DECISION_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing blocking review decision proof"
|
||||
)
|
||||
if not _has_head_sha(section, current_head):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing current head SHA"
|
||||
)
|
||||
if blocking_head and not _BLOCKING_HEAD_RE.search(section):
|
||||
if blocking_head.lower() not in section.lower():
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing blocking review head SHA"
|
||||
)
|
||||
if head_changed is False and not _HEAD_CHANGED_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
|
||||
)
|
||||
if not _BLOCKER_REASON_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing reason-it-remains-blocked"
|
||||
)
|
||||
if not _LIVE_BLOCKER_PROOF_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing live blocker proof for this session"
|
||||
)
|
||||
|
||||
proven = not item_reasons
|
||||
assessments.append({
|
||||
"pr_number": pr_number,
|
||||
"proven": proven,
|
||||
"classification": (
|
||||
"BLOCKER_STATUS_UNVERIFIED"
|
||||
if verified is False
|
||||
else "BLOCKED_SKIPPED"
|
||||
),
|
||||
"reasons": item_reasons,
|
||||
})
|
||||
reasons.extend(item_reasons)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"downgraded": False,
|
||||
"assessments": assessments,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"fetch live review feedback and document blocker proof for each skipped PR, "
|
||||
"or classify BLOCKER_STATUS_UNVERIFIED"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Reviewer local-fallback detection for normal PR review workflows (#324).
|
||||
|
||||
Normal reviewer runs must use MCP tools. Reading profile secret files or
|
||||
running local Gitea helper scripts while MCP is available is a fail-closed
|
||||
violation. Explicit recovery mode may use local fallback only with full proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
LOCAL_GITEA_SCRIPT_NAMES = (
|
||||
"list_prs.py",
|
||||
"view_pr.py",
|
||||
"create_pr.py",
|
||||
"merge_pr.py",
|
||||
"edit_pr.py",
|
||||
"list_issues.py",
|
||||
"delete_branch.py",
|
||||
"create_issue.py",
|
||||
"close_issue.py",
|
||||
"review_pr.py",
|
||||
"mark_issue.py",
|
||||
"mirror_refs.sh",
|
||||
)
|
||||
|
||||
PROFILE_SECRET_MARKERS = (
|
||||
"profiles.json",
|
||||
".config/gitea-tools/profiles",
|
||||
"gitea_auth.py",
|
||||
"gitea_config.py",
|
||||
"keychain",
|
||||
"credential fill",
|
||||
"token store",
|
||||
)
|
||||
|
||||
_RECOVERY_MODE_RE = re.compile(
|
||||
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
|
||||
r"mcp tools unavailable|no mcp path)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FALLBACK_CLASSIFICATION_RE = re.compile(
|
||||
r"\b(?:local fallback|fallback mode|used local gitea|ran local script)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MCP_TOOL_USE_RE = re.compile(
|
||||
r"\b(?:gitea_list_prs|gitea_view_pr|gitea_review_pr|gitea_merge_pr|"
|
||||
r"gitea_resolve_task_capability|gitea_whoami|mcp tool)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:" + "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES) + r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PROFILE_ACCESS_RE = re.compile(
|
||||
r"(?:read|open|inspect|cat|view|access(?:ed)?|loaded?)\s+(?:file\s+)?[`'\"]?"
|
||||
r"[^`'\"]*profiles\.json|profiles\.json[`'\"]?\s+(?:read|opened|inspected|accessed)|"
|
||||
r"~/?\.config/gitea-tools/profiles",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_ENV_SCRIPT_RE = re.compile(
|
||||
r"GITEA_MCP_(?:CONFIG|PROFILE)=.*\b(?:python\s+)?(?:list_prs|view_pr|create_pr|merge_pr)\.py",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RECOVERY_PROOF_FIELDS = (
|
||||
("identity proof", ("identity proof", "exact identity proof")),
|
||||
("profile proof", ("profile proof", "exact profile proof")),
|
||||
("repo proof", ("repo proof", "exact repo proof")),
|
||||
("capability proof", ("capability proof", "exact capability proof")),
|
||||
("mcp unavailable reason", (
|
||||
"why mcp was unavailable",
|
||||
"mcp unavailable",
|
||||
"mcp unavailability",
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _collect_observed_text(
|
||||
report_text: str,
|
||||
action_log: list[dict] | None,
|
||||
) -> str:
|
||||
chunks = [report_text or ""]
|
||||
for entry in action_log or []:
|
||||
for key in ("command", "action", "detail", "path", "script"):
|
||||
value = entry.get(key)
|
||||
if value:
|
||||
chunks.append(str(value))
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
def _detect_profile_secret_access(text: str) -> list[str]:
|
||||
reasons = []
|
||||
lower = text.lower()
|
||||
if _PROFILE_ACCESS_RE.search(text):
|
||||
reasons.append("report or action log shows profiles.json/profile secret access")
|
||||
for marker in PROFILE_SECRET_MARKERS:
|
||||
if marker == "profiles.json":
|
||||
continue
|
||||
if marker in lower and "do not" not in lower and "must not" not in lower:
|
||||
if any(verb in lower for verb in ("read ", "open ", "inspect ", "cat ", "loaded ")):
|
||||
reasons.append(f"profile secret surface '{marker}' accessed during review")
|
||||
return reasons
|
||||
|
||||
|
||||
def _detect_local_script_use(text: str) -> list[str]:
|
||||
reasons = []
|
||||
match = _LOCAL_SCRIPT_RE.search(text)
|
||||
if match:
|
||||
reasons.append(
|
||||
f"local Gitea helper script invoked ({match.group(0).strip()})"
|
||||
)
|
||||
if _LOCAL_ENV_SCRIPT_RE.search(text):
|
||||
reasons.append(
|
||||
"local Gitea script run with GITEA_MCP_CONFIG/GITEA_MCP_PROFILE env overrides"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def _recovery_proof_missing(text: str) -> list[str]:
|
||||
lower = text.lower()
|
||||
missing = []
|
||||
for label, aliases in _RECOVERY_PROOF_FIELDS:
|
||||
if not any(alias in lower for alias in aliases):
|
||||
missing.append(label)
|
||||
if not _FALLBACK_CLASSIFICATION_RE.search(text):
|
||||
missing.append("fallback classification")
|
||||
if not _RECOVERY_MODE_RE.search(text):
|
||||
missing.append("recovery mode declaration")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reviewer_fallback_report(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list[dict] | None = None,
|
||||
recovery_mode: bool = False,
|
||||
mcp_available: bool = True,
|
||||
mcp_tools_used: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when normal reviewer workflows use local Gitea fallbacks (#324)."""
|
||||
text = _collect_observed_text(report_text, action_log)
|
||||
lower = (report_text or "").lower()
|
||||
|
||||
profile_violations = _detect_profile_secret_access(text)
|
||||
script_violations = _detect_local_script_use(text)
|
||||
violations = profile_violations + script_violations
|
||||
|
||||
if mcp_tools_used is None:
|
||||
mcp_tools_used = bool(_MCP_TOOL_USE_RE.search(report_text or ""))
|
||||
elif mcp_tools_used is False and _MCP_TOOL_USE_RE.search(report_text or ""):
|
||||
mcp_tools_used = True
|
||||
|
||||
reasons: list[str] = []
|
||||
recovery_declared = recovery_mode or bool(_RECOVERY_MODE_RE.search(report_text or ""))
|
||||
|
||||
if violations and mcp_available and not recovery_declared:
|
||||
reasons.extend(violations)
|
||||
if mcp_tools_used:
|
||||
reasons.append(
|
||||
"MCP tools were available and used; local fallback/profile access is forbidden"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"MCP path is available; normal review must not use local Gitea fallbacks"
|
||||
)
|
||||
|
||||
if violations and recovery_declared:
|
||||
missing = _recovery_proof_missing(report_text or "")
|
||||
if missing:
|
||||
reasons.append(
|
||||
"recovery-mode fallback missing proof fields: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
if (
|
||||
not violations
|
||||
and recovery_declared
|
||||
and _FALLBACK_CLASSIFICATION_RE.search(report_text or "")
|
||||
and not recovery_mode
|
||||
):
|
||||
missing = _recovery_proof_missing(report_text or "")
|
||||
if missing:
|
||||
reasons.append(
|
||||
"report claims local fallback but recovery proof is incomplete: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
blocked = bool(reasons) and not recovery_declared or any(
|
||||
"recovery-mode fallback missing" in r or "recovery proof is incomplete" in r
|
||||
for r in reasons
|
||||
)
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": blocked or (bool(reasons) and mcp_available and not recovery_declared),
|
||||
"downgraded": bool(reasons) and not blocked,
|
||||
"recovery_mode": recovery_declared,
|
||||
"mcp_available": mcp_available,
|
||||
"mcp_tools_used": mcp_tools_used,
|
||||
"profile_violations": profile_violations,
|
||||
"script_violations": script_violations,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use MCP reviewer tools only; do not read profiles.json or run local Gitea scripts"
|
||||
if reasons and not recovery_declared
|
||||
else (
|
||||
"complete recovery-mode fallback proof before claiming local fallback"
|
||||
if reasons
|
||||
else "proceed with MCP tools"
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Infra-stop repair handoff verifier for blocked reviewer workflows (#289)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from capability_stop_terminal import (
|
||||
TERMINAL_REPORT_HEADING,
|
||||
assess_capability_stop_report,
|
||||
)
|
||||
|
||||
_REPAIR_HANDOFF_RE = re.compile(
|
||||
r"repair handoff|control-checkout repair mode",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTROL_REPAIR_RE = re.compile(r"control-checkout repair mode", re.IGNORECASE)
|
||||
_PR_QUEUE_ADVANCE_RE = re.compile(
|
||||
r"(?:selected pr|pr #\d+ (?:to review|selected)|eligible pr|"
|
||||
r"next (?:eligible )?pr(?: to review)?|oldest eligible pr|pinned (?:review )?head)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_MUTATION_RE = re.compile(
|
||||
r"(?:gitea_review_pr|gitea_merge_pr|request_changes|submitted\s+approve|"
|
||||
r"merge result|review decision\s*:\s*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BACKGROUND_TOOL_RE = re.compile(r"\b(?:schedule|manage_task)\b", re.IGNORECASE)
|
||||
_PR_REVIEW_REPORT_RE = re.compile(
|
||||
r"(?:review summary|merge recommendation|validation result\s*:\s*pass|"
|
||||
r"queue status report with selected pr)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_DIAGNOSTIC_FIELDS = {
|
||||
"mcp process root": re.compile(r"mcp process root\s*:", re.IGNORECASE),
|
||||
"inspected git root": re.compile(r"inspected git root\s*:", re.IGNORECASE),
|
||||
"conflict marker path": re.compile(
|
||||
r"(?:conflict marker path|exact conflict marker path)\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"merge/rebase control path": re.compile(
|
||||
r"(?:merge/rebase control path|exact merge/rebase control path)\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"safe next repair action": re.compile(
|
||||
r"safe next (?:repair )?action\s*:",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_infra_stop_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
stop_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate repair handoff purity when infra_stop blocks reviewer capability (#289)."""
|
||||
text = report_text or ""
|
||||
session = dict(stop_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
infra_stop = bool(
|
||||
session.get("infra_stop")
|
||||
or session.get("infra_stop_blocked")
|
||||
or session.get("route_result") == "infra_stop"
|
||||
)
|
||||
if not infra_stop:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"infra_stop": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
lower = text.lower()
|
||||
repair_handoff = bool(
|
||||
_REPAIR_HANDOFF_RE.search(text)
|
||||
or TERMINAL_REPORT_HEADING.lower() in lower
|
||||
)
|
||||
if not repair_handoff:
|
||||
reasons.append(
|
||||
"infra_stop requires a repair handoff, not a PR review report"
|
||||
)
|
||||
|
||||
if _PR_REVIEW_REPORT_RE.search(text) and not _REPAIR_HANDOFF_RE.search(text):
|
||||
reasons.append(
|
||||
"final output must be a repair handoff instead of stale PR review state"
|
||||
)
|
||||
|
||||
if _PR_QUEUE_ADVANCE_RE.search(text):
|
||||
reasons.append(
|
||||
"infra_stop blocks PR queue advancement; do not select or advance next PR"
|
||||
)
|
||||
|
||||
if _REVIEW_MUTATION_RE.search(text):
|
||||
reasons.append(
|
||||
"review, approval, request-changes, merge, or comment mutations "
|
||||
"forbidden while infra_stop is active"
|
||||
)
|
||||
|
||||
if session.get("pinned_head_sha") or re.search(
|
||||
r"pinned (?:review )?head sha\s*:", text, re.IGNORECASE
|
||||
):
|
||||
if session.get("capability_cleared") is not True:
|
||||
reasons.append(
|
||||
"cannot pin PR head SHA while review capability never cleared"
|
||||
)
|
||||
|
||||
if session.get("main_checkout_diagnostics") and not _CONTROL_REPAIR_RE.search(text):
|
||||
reasons.append(
|
||||
"main-checkout diagnostics must be labeled CONTROL-CHECKOUT REPAIR MODE"
|
||||
)
|
||||
|
||||
if _BACKGROUND_TOOL_RE.search(text):
|
||||
reasons.append(
|
||||
"background schedule/manage_task tools must not be used during "
|
||||
"blocked infra_stop recovery"
|
||||
)
|
||||
|
||||
assessment = session.get("infra_stop_assessment") or {}
|
||||
if assessment.get("infra_stop") or infra_stop:
|
||||
missing = [
|
||||
label
|
||||
for label, pattern in _DIAGNOSTIC_FIELDS.items()
|
||||
if not pattern.search(text)
|
||||
]
|
||||
if missing and session.get("require_infra_diagnostics", True):
|
||||
reasons.append(
|
||||
"infra_stop repair handoff missing diagnostic fields: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
conflict_file = assessment.get("conflict_file")
|
||||
if conflict_file and conflict_file.lower() not in lower:
|
||||
reasons.append(
|
||||
f"infra_stop assessment conflict file {conflict_file!r} "
|
||||
"not reflected in repair handoff"
|
||||
)
|
||||
|
||||
capability = assess_capability_stop_report(
|
||||
text,
|
||||
trust_gate_status=session.get("trust_gate_status"),
|
||||
capability_denied=True,
|
||||
)
|
||||
if not capability.get("pure"):
|
||||
reasons.extend(capability.get("reasons") or [])
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"infra_stop": True,
|
||||
"repair_handoff": repair_handoff,
|
||||
"safe_next_action": (
|
||||
"stop PR queue work; emit CONTROL-CHECKOUT REPAIR MODE handoff "
|
||||
"with infra diagnostics and safe next repair action"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
"""Reviewer inventory completeness and worktree state verifier (#293)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_PAGINATION_FINALITY_EVIDENCE = re.compile(
|
||||
r"pagination_complete\s*:\s*true|inventory_complete\s*:\s*true|"
|
||||
r"is_final_page\s*:\s*true|pages_fetched|has_more\s*:\s*false|"
|
||||
r"no next page|final[- ]page|pr_inventory_trust_gate\.status|"
|
||||
r"total_count\s*:|pagination.*(?:final|complete)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_INCOMPLETE_PAGINATION_PROOF = re.compile(
|
||||
r"first page only|partial page|page 1 only|truncated|"
|
||||
r"returned \d+ open prs?(?:\s*$|\s*[,;])",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
|
||||
r"default (?:gitea )?page[- ]?size|under (?:the )?50[- ]?(?:item )?limit|"
|
||||
r"(?:complete|exhaustive).*(?:default )?page[- ]?size|"
|
||||
r"page[- ]?size assumption|assumed complete",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_ELIGIBILITY_CLAIM_RE = re.compile(
|
||||
r"\b(?:oldest eligible pr|next eligible pr|next pr to review|"
|
||||
r"inventory (?:is )?complete|inventory exhaustive|exhaustive inventory)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_EXACT_PAGE_LIMIT_RE = re.compile(
|
||||
r"(?:returned|listed|fetched)\s+(\d+)\s+open prs?|"
|
||||
r"open pr count\s*:\s*(\d+)|"
|
||||
r"page[- ]?size\s*(?:=|:)\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_LEGACY_SCRATCH_FALSE_RE = re.compile(
|
||||
r"scratch worktree used\s*:\s*false",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)
|
||||
|
||||
_CONTRADICTORY_HEAD_RE = re.compile(
|
||||
r"detached head\s*/\s*branch\s+master|"
|
||||
r"branch\s+master\s*/\s*detached head|"
|
||||
r"detached head.*branch\s+(?:master|main|dev)\b.*detached|"
|
||||
r"checkout\s*:\s*detached head\s*/\s*branch",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_MAIN_CHECKOUT_BRANCH_RE = re.compile(
|
||||
r"main checkout branch\s*:\s*(\S+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_REVIEW_HEAD_STATE_RE = re.compile(
|
||||
r"review worktree head state\s*:\s*(\S+)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(
|
||||
r"^##\s*Controller Handoff\s*$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
"""Parse ``- Field: value`` lines from the Controller Handoff section."""
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
section = text[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 _truthy_field(value: str) -> bool:
|
||||
lowered = (value or "").strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
if lowered in {"false", "no", "none", "not applicable", "n/a", "—", "-"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _field_proves_pagination(value: str) -> bool:
|
||||
proof = (value or "").strip()
|
||||
if not proof or proof.lower() in {"none", "n/a", "not applicable", "unknown"}:
|
||||
return False
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(proof):
|
||||
return False
|
||||
if _INCOMPLETE_PAGINATION_PROOF.search(proof):
|
||||
return False
|
||||
return bool(_PAGINATION_FINALITY_EVIDENCE.search(proof))
|
||||
|
||||
|
||||
def _pagination_proven(text: str, session: dict, *, pagination_field: str = "") -> bool:
|
||||
if session.get("pagination_complete") or session.get("inventory_complete"):
|
||||
return True
|
||||
session_proof = session.get("inventory_pagination_proof") or ""
|
||||
if _field_proves_pagination(str(session_proof)):
|
||||
return True
|
||||
if _field_proves_pagination(pagination_field):
|
||||
return True
|
||||
if _PAGINATION_FINALITY_EVIDENCE.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _exact_page_limit_unproven(
|
||||
text: str, session: dict, *, pagination_proven: bool = False
|
||||
) -> bool:
|
||||
"""True when a full page was returned without final-page proof."""
|
||||
if pagination_proven:
|
||||
return False
|
||||
requested = session.get("requested_page_size")
|
||||
returned = session.get("returned_page_size")
|
||||
if isinstance(requested, int) and isinstance(returned, int):
|
||||
if returned >= requested > 0:
|
||||
return True
|
||||
for match in _EXACT_PAGE_LIMIT_RE.finditer(text):
|
||||
count = next((g for g in match.groups() if g), None)
|
||||
if not count:
|
||||
continue
|
||||
try:
|
||||
n = int(count)
|
||||
except ValueError:
|
||||
continue
|
||||
if n in {10, 20, 50}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_inventory_worktree_report(
|
||||
report_text: str,
|
||||
*,
|
||||
inventory_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate PR inventory pagination proof and worktree field consistency (#293)."""
|
||||
text = report_text or ""
|
||||
session = dict(inventory_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
fields = _handoff_field_map(text)
|
||||
pagination_proof_field = fields.get("inventory pagination proof", "")
|
||||
|
||||
pagination_proven = _pagination_proven(
|
||||
text, session, pagination_field=pagination_proof_field
|
||||
)
|
||||
|
||||
if _ELIGIBILITY_CLAIM_RE.search(text):
|
||||
if not pagination_proven:
|
||||
reasons.append(
|
||||
"oldest/next eligible PR or inventory-complete claim requires "
|
||||
"final-page/no-next-page/pagination_complete proof"
|
||||
)
|
||||
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text):
|
||||
if not pagination_proven:
|
||||
reasons.append(
|
||||
"inventory pagination assumed from default page size without "
|
||||
"final-page/no-next-page/total-count/traversal proof"
|
||||
)
|
||||
|
||||
if pagination_proof_field:
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(pagination_proof_field):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field relies on page-size assumption"
|
||||
)
|
||||
elif _INCOMPLETE_PAGINATION_PROOF.search(pagination_proof_field):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field does not prove final page"
|
||||
)
|
||||
elif not _field_proves_pagination(pagination_proof_field):
|
||||
if _ELIGIBILITY_CLAIM_RE.search(text) or _EXACT_PAGE_LIMIT_RE.search(text):
|
||||
reasons.append(
|
||||
"Inventory pagination proof field missing final-page metadata"
|
||||
)
|
||||
|
||||
if _exact_page_limit_unproven(text, session, pagination_proven=pagination_proven):
|
||||
reasons.append(
|
||||
"exactly-full first page returned without final-page or "
|
||||
"no-next-page proof"
|
||||
)
|
||||
|
||||
review_worktree_used = fields.get("review worktree used", "")
|
||||
review_worktree_path = fields.get("review worktree path", "")
|
||||
scratch_used = fields.get("scratch worktree used", "")
|
||||
inside_branches = fields.get("review worktree inside branches:", "") or fields.get(
|
||||
"review worktree inside branches", ""
|
||||
)
|
||||
|
||||
branches_path_in_text = bool(
|
||||
_BRANCHES_WORKTREE_RE.search(review_worktree_path)
|
||||
or _BRANCHES_WORKTREE_RE.search(text)
|
||||
)
|
||||
|
||||
if branches_path_in_text:
|
||||
if review_worktree_used and not _truthy_field(review_worktree_used):
|
||||
reasons.append(
|
||||
"reports using a branches/ review worktree must set "
|
||||
"Review worktree used: true"
|
||||
)
|
||||
if scratch_used and re.search(r"\bfalse\b", scratch_used, re.I):
|
||||
reasons.append(
|
||||
"Scratch worktree used: false rejected when a branches/ "
|
||||
"review worktree was created or used"
|
||||
)
|
||||
if _LEGACY_SCRATCH_FALSE_RE.search(text) and not _truthy_field(
|
||||
review_worktree_used
|
||||
):
|
||||
reasons.append(
|
||||
"legacy Scratch worktree used: false contradicts branches/ "
|
||||
"review worktree usage"
|
||||
)
|
||||
|
||||
if review_worktree_path and _truthy_field(review_worktree_used):
|
||||
if "branches/" not in review_worktree_path.replace("\\", "/").lower():
|
||||
reasons.append(
|
||||
"Review worktree path must be under branches/ when "
|
||||
"Review worktree used is true"
|
||||
)
|
||||
if inside_branches and re.search(r"\bfalse\b", inside_branches, re.I):
|
||||
reasons.append(
|
||||
"Review worktree inside branches must be true when path is "
|
||||
"under branches/"
|
||||
)
|
||||
|
||||
main_branch = (
|
||||
fields.get("main checkout branch", "")
|
||||
or _MAIN_CHECKOUT_BRANCH_RE.search(text).group(1)
|
||||
if _MAIN_CHECKOUT_BRANCH_RE.search(text)
|
||||
else ""
|
||||
)
|
||||
head_state = (
|
||||
fields.get("review worktree head state", "")
|
||||
or (
|
||||
_REVIEW_HEAD_STATE_RE.search(text).group(1)
|
||||
if _REVIEW_HEAD_STATE_RE.search(text)
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if main_branch and head_state:
|
||||
main_norm = main_branch.strip().lower()
|
||||
head_norm = head_state.strip().lower()
|
||||
if head_norm in {"branch", "on branch"} and main_norm in {
|
||||
"master",
|
||||
"main",
|
||||
"dev",
|
||||
}:
|
||||
if "detached" in text.lower() and "review worktree" in text.lower():
|
||||
reasons.append(
|
||||
"review worktree HEAD state must not reuse main checkout "
|
||||
"branch wording"
|
||||
)
|
||||
|
||||
if _CONTRADICTORY_HEAD_RE.search(text):
|
||||
reasons.append(
|
||||
"contradictory checkout wording such as 'Detached HEAD / Branch master'"
|
||||
)
|
||||
|
||||
if review_worktree_used and _truthy_field(review_worktree_used):
|
||||
if not review_worktree_path or review_worktree_path.lower() in {
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
}:
|
||||
reasons.append(
|
||||
"Review worktree used: true requires Review worktree path"
|
||||
)
|
||||
if not head_state or head_state.lower() in {
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
"unknown",
|
||||
}:
|
||||
reasons.append(
|
||||
"Review worktree used: true requires Review worktree HEAD state"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"pagination_proven": pagination_proven,
|
||||
"branches_worktree_used": branches_path_in_text,
|
||||
"safe_next_action": (
|
||||
"prove inventory pagination with final-page metadata; align "
|
||||
"Review worktree used/path/HEAD state with branches/ usage"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Merge-simulation worktree mutation verifier for reviewer reports (#317)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_WORKTREE_INDEX_FIELD_RE = re.compile(
|
||||
r"^\s*worktree/index mutations\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_READONLY_DIAG_RE = re.compile(
|
||||
r"read[- ]only diagnostics\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:worktree path|diagnostic worktree|simulation worktree)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PRE_CLEAN_RE = re.compile(
|
||||
r"(?:pre[- ]simulation|before simulation|clean before).*(?:clean|status)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"(?:merge result|simulation result|conflict status|merge conflict)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ABORT_RE = re.compile(
|
||||
r"(?:merge --abort|abort/reset command|abort command|git merge --abort)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_POST_CLEAN_RE = re.compile(
|
||||
r"(?:post[- ]abort|after abort|clean after).*(?:clean|status)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _command_text(entry: Any) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("command") or "").strip()
|
||||
return str(entry or "").strip()
|
||||
|
||||
|
||||
def _git_subcommand_line(command: str) -> str | None:
|
||||
tokens = command.split()
|
||||
if not tokens or tokens[0] != "git":
|
||||
return None
|
||||
return " ".join(tokens[1:])
|
||||
|
||||
|
||||
def merge_simulation_commands(command_log: list | None) -> list[str]:
|
||||
"""Return logged commands that perform local merge simulation (#317)."""
|
||||
out: list[str] = []
|
||||
for entry in command_log or []:
|
||||
command = _command_text(entry)
|
||||
rest = _git_subcommand_line(command)
|
||||
if rest is None:
|
||||
continue
|
||||
lower = command.lower()
|
||||
if rest.startswith("merge"):
|
||||
if "--no-commit" in lower or (
|
||||
"--no-ff" in lower and "merge" in lower and "--commit" not in lower
|
||||
):
|
||||
out.append(command)
|
||||
elif "--abort" in lower:
|
||||
out.append(command)
|
||||
return out
|
||||
|
||||
|
||||
def assess_merge_simulation_report(
|
||||
report_text: str,
|
||||
*,
|
||||
command_log: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject merge simulation classified as read-only; require worktree proof (#317)."""
|
||||
text = report_text or ""
|
||||
lower = text.lower()
|
||||
reasons: list[str] = []
|
||||
simulation_commands = merge_simulation_commands(command_log)
|
||||
has_abort = any("--abort" in c.lower() for c in simulation_commands)
|
||||
has_simulation = any(
|
||||
"--no-commit" in c.lower() or (
|
||||
"--no-ff" in c.lower() and "--abort" not in c.lower()
|
||||
)
|
||||
for c in simulation_commands
|
||||
)
|
||||
|
||||
if not simulation_commands:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"simulation_commands": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
field = _WORKTREE_INDEX_FIELD_RE.search(text)
|
||||
value = (field.group(1).strip().lower() if field else "") or ""
|
||||
if not value or value in {"none", "n/a", "no"}:
|
||||
reasons.append(
|
||||
"merge simulation commands ran but report lacks "
|
||||
"'Worktree/index mutations' entry"
|
||||
)
|
||||
elif "merge" not in value and "simulation" not in value:
|
||||
if not any(cmd.lower() in lower for cmd in simulation_commands):
|
||||
reasons.append(
|
||||
"Worktree/index mutations must document merge simulation commands"
|
||||
)
|
||||
|
||||
readonly = _READONLY_DIAG_RE.search(text)
|
||||
if readonly:
|
||||
readonly_value = readonly.group(1)
|
||||
for command in simulation_commands:
|
||||
if command.lower() in readonly_value.lower():
|
||||
reasons.append(
|
||||
"merge simulation may not be listed under read-only diagnostics"
|
||||
)
|
||||
if has_simulation and "merge simulation" in readonly_value.lower():
|
||||
reasons.append(
|
||||
"merge simulation may not be classified as read-only diagnostics"
|
||||
)
|
||||
|
||||
if has_simulation:
|
||||
if not _WORKTREE_PATH_RE.search(text):
|
||||
reasons.append("merge simulation report missing worktree path")
|
||||
if not _PRE_CLEAN_RE.search(text):
|
||||
reasons.append("merge simulation report missing pre-simulation clean status")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
reasons.append("merge simulation report missing merge result/conflict status")
|
||||
if has_abort or has_simulation:
|
||||
if has_abort and not _ABORT_RE.search(text):
|
||||
reasons.append("merge simulation report missing abort/reset command proof")
|
||||
if has_abort and not _POST_CLEAN_RE.search(text):
|
||||
reasons.append("merge simulation report missing post-abort clean status")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"simulation_commands": simulation_commands,
|
||||
"safe_next_action": (
|
||||
"classify merge simulation under Worktree/index mutations with full "
|
||||
"pre/merge/abort/post-clean proof; never list as read-only diagnostics"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Non-mergeable PR skip conflict-proof verifier for reviewer reports (#322)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
|
||||
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
|
||||
_MERGEABILITY_FALSE_RE = re.compile(
|
||||
r"(?:mergeable\s*:\s*false|not mergeable|non[- ]mergeable|mergeability\s*:\s*false|"
|
||||
r"mergeability result\s*:\s*false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_PROOF_RE = re.compile(
|
||||
r"(?:merge-tree|git merge-tree|gitea_view_pr|gitea_check_pr_eligibility|"
|
||||
r"conflict proof|mergeability tool|merge simulation|conflicting files?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICTING_FILES_RE = re.compile(
|
||||
r"(?:conflicting files?|conflict files?)\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_HEAD_CHANGED_RE = re.compile(
|
||||
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
|
||||
r"head changed since|head unchanged since)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGEABILITY_UNVERIFIED_RE = re.compile(
|
||||
r"\bMERGEABILITY_UNVERIFIED\b",
|
||||
)
|
||||
|
||||
|
||||
def _pr_section(text: str, pr_number: int) -> str:
|
||||
"""Return report lines likely describing one skipped PR."""
|
||||
lines = (text or "").splitlines()
|
||||
chunks: list[str] = []
|
||||
capture = False
|
||||
token = f"#{pr_number}"
|
||||
for line in lines:
|
||||
lower = line.lower()
|
||||
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
|
||||
capture = True
|
||||
chunks.append(line)
|
||||
continue
|
||||
if capture:
|
||||
if _PR_NUMBER_RE.search(line) and token not in line.lower():
|
||||
break
|
||||
if line.strip() == "" and len(chunks) > 3:
|
||||
break
|
||||
chunks.append(line)
|
||||
if chunks:
|
||||
return "\n".join(chunks)
|
||||
return text or ""
|
||||
|
||||
|
||||
def _has_head_sha(text: str, head_sha: str | None) -> bool:
|
||||
if not head_sha:
|
||||
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
|
||||
head = head_sha.strip().lower()
|
||||
if head in text.lower():
|
||||
return True
|
||||
if len(head) >= 7 and head[:7] in text.lower():
|
||||
return True
|
||||
return bool(_FULL_SHA.search(text))
|
||||
|
||||
|
||||
def assess_non_mergeable_skip_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
skipped_prs: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate skipped non-mergeable PR documentation in reviewer reports (#322)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
assessments: list[dict[str, Any]] = []
|
||||
|
||||
for entry in skipped_prs or []:
|
||||
pr_number = entry.get("pr_number")
|
||||
if pr_number is None:
|
||||
reasons.append("skipped PR entry missing pr_number")
|
||||
continue
|
||||
pr_number = int(pr_number)
|
||||
section = _pr_section(text, pr_number)
|
||||
mergeable = entry.get("mergeable")
|
||||
verified = entry.get("mergeability_verified")
|
||||
classification = (entry.get("classification") or "").strip().upper()
|
||||
head_sha = (entry.get("head_sha") or "").strip() or None
|
||||
|
||||
item_reasons: list[str] = []
|
||||
|
||||
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
|
||||
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
|
||||
|
||||
if mergeable is False or verified is False:
|
||||
if not _MERGEABILITY_UNVERIFIED_RE.search(section) and verified is False:
|
||||
if "MERGEABILITY_UNVERIFIED" not in classification:
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} conflict proof unavailable; report must classify "
|
||||
"MERGEABILITY_UNVERIFIED"
|
||||
)
|
||||
elif verified is not False:
|
||||
if not _MERGEABILITY_FALSE_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing mergeability:false proof"
|
||||
)
|
||||
if not _has_head_sha(section, head_sha):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing current head SHA"
|
||||
)
|
||||
if not _CONFLICT_PROOF_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing conflict proof command/tool"
|
||||
)
|
||||
if entry.get("head_changed_since_prior_blocker") is not None:
|
||||
if not _HEAD_CHANGED_RE.search(section):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
|
||||
)
|
||||
|
||||
if (
|
||||
entry.get("conflicting_files")
|
||||
and not _CONFLICTING_FILES_RE.search(section)
|
||||
and not any(
|
||||
path.lower() in section.lower()
|
||||
for path in (entry.get("conflicting_files") or [])
|
||||
)
|
||||
):
|
||||
item_reasons.append(
|
||||
f"PR #{pr_number} has conflicting files in session proof but "
|
||||
"report omits file-level conflict proof"
|
||||
)
|
||||
|
||||
proven = not item_reasons
|
||||
assessments.append({
|
||||
"pr_number": pr_number,
|
||||
"proven": proven,
|
||||
"classification": classification or (
|
||||
"MERGEABILITY_UNVERIFIED" if verified is False else "NON_MERGEABLE_SKIPPED"
|
||||
),
|
||||
"reasons": item_reasons,
|
||||
})
|
||||
reasons.extend(item_reasons)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"downgraded": False,
|
||||
"assessments": assessments,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"document each skipped non-mergeable PR with head SHA, mergeability result, "
|
||||
"conflict proof command, conflicting files, and head-change status"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Exact per-mutation capability proof verifier for reviewer reports (#405).
|
||||
|
||||
A reviewer final report may prove ``review_pr`` capability and then also merge
|
||||
a PR or delete a remote branch. Merge and branch deletion are separate
|
||||
mutations that require their own exact capability proof — a nearby capability
|
||||
must never authorize a different operation. This verifier requires a
|
||||
mutation-capability table pairing every performed mutation with the exact
|
||||
task/permission resolved *before* that mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Mutations this verifier tracks, with the exact capability tokens that
|
||||
# authorize each. A row for the mutation must cite one of its own tokens;
|
||||
# tokens from a different mutation (a "nearby capability") never count.
|
||||
_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve",
|
||||
"gitea.pr.request_changes", "request_changes_pr", "approve_pr")
|
||||
_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge")
|
||||
_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete")
|
||||
|
||||
# Detect that a mutation was actually performed (not merely mentioned as a
|
||||
# non-goal or skipped).
|
||||
_MERGE_PERFORMED = re.compile(
|
||||
r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*merged\b|"
|
||||
r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_PERFORMED = re.compile(
|
||||
r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|"
|
||||
r"\bdeleted (?:the )?(?:remote )?branch\b|"
|
||||
r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_REVIEW_PERFORMED = re.compile(
|
||||
r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|"
|
||||
r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*"
|
||||
r"(?:approved|request[_ ]changes)\b|\breview submitted\b)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Post-hoc proof: capability resolved *after* the mutation is never valid.
|
||||
_POST_HOC = re.compile(
|
||||
r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*"
|
||||
r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# The report must carry an explicit mutation-capability table.
|
||||
_TABLE_MARKER = re.compile(
|
||||
r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool:
|
||||
low = text.lower()
|
||||
return any(tok.lower() in low for tok in tokens)
|
||||
|
||||
|
||||
def assess_mutation_capability_proof(report_text: str) -> dict:
|
||||
"""Validate exact per-mutation capability proof in a reviewer report.
|
||||
|
||||
Returns ``{proven, block, reasons, safe_next_action}``. A report that
|
||||
performs no mutation beyond an ordinary review passes only when its
|
||||
review capability is cited; merge/delete each demand their own exact
|
||||
capability row. Fail closed on nearby-capability substitution, a
|
||||
missing table, missing rows, or post-hoc proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
merged = bool(_MERGE_PERFORMED.search(text))
|
||||
deleted = bool(_DELETE_PERFORMED.search(text))
|
||||
reviewed = bool(_REVIEW_PERFORMED.search(text))
|
||||
|
||||
extra_mutation = merged or deleted
|
||||
|
||||
if _POST_HOC.search(text):
|
||||
reasons.append(
|
||||
"capability proof recorded after the mutation; exact capability "
|
||||
"must be resolved before each mutation"
|
||||
)
|
||||
|
||||
# A review-only report needs its review capability cited; no table required.
|
||||
if reviewed and not _tokens_present(text, _REVIEW_TOKENS):
|
||||
reasons.append(
|
||||
"review mutation performed without exact review capability proof "
|
||||
"(review_pr / gitea.pr.review)"
|
||||
)
|
||||
|
||||
if extra_mutation and not _TABLE_MARKER.search(text):
|
||||
reasons.append(
|
||||
"mutation beyond review performed without a mutation-capability "
|
||||
"table (mutation, exact task/capability, result, order-before)"
|
||||
)
|
||||
|
||||
if merged:
|
||||
if not _tokens_present(text, _MERGE_TOKENS):
|
||||
reasons.append(
|
||||
"merge performed without exact merge capability proof "
|
||||
"(merge_pr / gitea.pr.merge); nearby review_pr does not "
|
||||
"authorize merge"
|
||||
)
|
||||
|
||||
if deleted:
|
||||
if not _tokens_present(text, _DELETE_TOKENS):
|
||||
reasons.append(
|
||||
"branch deletion performed without exact delete capability "
|
||||
"proof (delete_branch / gitea.branch.delete); nearby "
|
||||
"merge_pr does not authorize branch deletion"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "add a mutation-capability table with the exact resolved "
|
||||
"task/permission and pre-mutation order for every mutation; "
|
||||
"skip any mutation whose exact capability is unproven"
|
||||
),
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Precise mutation category verifier for reviewer controller handoffs (#319)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_CONTROLLER_HANDOFF_RE = re.compile(r"controller handoff", re.IGNORECASE)
|
||||
_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*workspace\s+mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_VAGUE_MUTATIONS_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_REQUIRED_CATEGORIES = (
|
||||
"file edits by reviewer",
|
||||
"worktree/index mutations",
|
||||
"git ref mutations",
|
||||
)
|
||||
_WORKTREE_FIELD_ALIASES = (
|
||||
"worktree/index mutations",
|
||||
"worktree mutations",
|
||||
)
|
||||
|
||||
|
||||
def _has_category(text: str, category: str) -> bool:
|
||||
pattern = re.compile(
|
||||
rf"^\s*[-*]?\s*{re.escape(category)}\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
return bool(pattern.search(text))
|
||||
|
||||
|
||||
def _has_worktree_category(text: str) -> bool:
|
||||
return any(_has_category(text, alias) for alias in _WORKTREE_FIELD_ALIASES)
|
||||
|
||||
|
||||
def _normalize_for_consistency_check(text: str) -> str:
|
||||
"""Map #319 precise labels to #313 field names for consistency delegation."""
|
||||
normalized = text
|
||||
if _has_category(text, "worktree/index mutations") and not _has_category(
|
||||
text, "worktree mutations"
|
||||
):
|
||||
normalized = re.sub(
|
||||
r"(worktree/index mutations\s*:)",
|
||||
"Worktree mutations:",
|
||||
normalized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def assess_mutation_categories_report(
|
||||
report_text: str,
|
||||
*,
|
||||
handoff_session: dict | None = None,
|
||||
observed_commands: list | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require precise mutation categories in reviewer controller handoffs (#319)."""
|
||||
text = report_text or ""
|
||||
session = dict(handoff_session or {})
|
||||
reasons: list[str] = []
|
||||
lower = text.lower()
|
||||
|
||||
is_controller = bool(
|
||||
session.get("controller_handoff")
|
||||
or _CONTROLLER_HANDOFF_RE.search(text)
|
||||
)
|
||||
if not is_controller and not session.get("require_categories"):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"controller_handoff": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _WORKSPACE_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"controller handoffs must not use legacy 'Workspace mutations'; "
|
||||
"use precise mutation category fields"
|
||||
)
|
||||
|
||||
if _VAGUE_MUTATIONS_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"controller handoffs must not use vague 'Mutations: none'; "
|
||||
"report each precise category separately"
|
||||
)
|
||||
|
||||
missing = [
|
||||
category
|
||||
for category in _REQUIRED_CATEGORIES
|
||||
if category != "worktree/index mutations" and not _has_category(text, category)
|
||||
]
|
||||
if not _has_worktree_category(text):
|
||||
missing.append("worktree/index mutations")
|
||||
|
||||
if missing:
|
||||
reasons.append(
|
||||
"controller handoff missing precise mutation categories: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
commands = observed_commands or session.get("observed_commands")
|
||||
if commands:
|
||||
from review_proofs import assess_workspace_mutation_consistency
|
||||
|
||||
consistency = assess_workspace_mutation_consistency(
|
||||
_normalize_for_consistency_check(text),
|
||||
commands,
|
||||
)
|
||||
if not consistency.get("complete"):
|
||||
reasons.extend(consistency.get("reasons") or [])
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"controller_handoff": True,
|
||||
"safe_next_action": (
|
||||
"replace Workspace mutations with file edits, worktree/index, git ref, "
|
||||
"and other precise mutation category fields"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,762 +0,0 @@
|
||||
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
"adopted",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
STALE_WARNING_MINUTES = 30
|
||||
RECLAIMABLE_MINUTES = 60
|
||||
|
||||
_SESSION_LEASE: dict[str, Any] | None = None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
return text if text and _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def format_lease_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
worktree: str,
|
||||
phase: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
now = last_activity or datetime.now(timezone.utc)
|
||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
issue_text = f"#{issue_number}" if issue_number else "none"
|
||||
lines = [
|
||||
MARKER,
|
||||
f"repo: {repo}",
|
||||
f"pr: #{pr_number}",
|
||||
f"issue: {issue_text}",
|
||||
f"reviewer_identity: {reviewer_identity}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"worktree: {worktree}",
|
||||
f"phase: {phase}",
|
||||
f"candidate_head: {candidate_head or 'none'}",
|
||||
f"target_branch: {target_branch}",
|
||||
f"target_branch_sha: {target_branch_sha or 'none'}",
|
||||
f"last_activity: {last_text}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"blocker: {blocker}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
text = body or ""
|
||||
if MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"repo": fields.get("repo"),
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_lease_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||
last = _parse_timestamp(lease.get("last_activity"))
|
||||
if not last:
|
||||
return None
|
||||
return (now - last).total_seconds() / 60.0
|
||||
|
||||
|
||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
return "terminal"
|
||||
if _lease_expired(lease, now=now):
|
||||
return "expired"
|
||||
minutes = _minutes_since_activity(lease, now=now)
|
||||
if minutes is None:
|
||||
return "active"
|
||||
if minutes >= RECLAIMABLE_MINUTES:
|
||||
return "reclaimable"
|
||||
if minutes >= STALE_WARNING_MINUTES:
|
||||
return "stale_warning"
|
||||
return "active"
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""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)
|
||||
entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
|
||||
if not entries:
|
||||
return None
|
||||
newest = entries[0]
|
||||
phase = (newest.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
return None
|
||||
if _lease_expired(newest, now=now):
|
||||
return None
|
||||
if phase in _ACTIVE_PHASES or phase:
|
||||
lease = dict(newest)
|
||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def assess_acquire_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
pr_merged_or_closed: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when another session holds an active lease.
|
||||
|
||||
When *pr_merged_or_closed* is true the PR has already merged/closed, so any
|
||||
reviewer-lease acquisition or adoption for merge work is moot: fail closed
|
||||
with a ``post_merge_moot`` reason and never mint a lease body (#515).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
post_merge_moot = bool(pr_merged_or_closed)
|
||||
if post_merge_moot:
|
||||
reasons.append(
|
||||
f"post_merge_moot: PR #{pr_number} is already merged/closed; reviewer "
|
||||
"lease adoption for merge is moot (fail closed)"
|
||||
)
|
||||
if existing:
|
||||
owner_session = (existing.get("session_id") or "").strip()
|
||||
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
|
||||
if owner_session and owner_session != session_id and freshness in {
|
||||
"active", "stale_warning"
|
||||
}:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} already has active reviewer lease "
|
||||
f"(session_id={owner_session}, phase={existing.get('phase')})"
|
||||
)
|
||||
elif owner_session and owner_session != session_id and freshness == "reclaimable":
|
||||
reasons.append(
|
||||
f"PR #{pr_number} lease is reclaimable but still held by "
|
||||
f"session_id={owner_session}; explicit reclaim not implemented "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
if not (reviewer_identity or "").strip():
|
||||
reasons.append("reviewer identity required for lease acquisition")
|
||||
if not (session_id or "").strip():
|
||||
reasons.append("session_id required for lease acquisition")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("worktree path required for lease acquisition")
|
||||
|
||||
allowed = not reasons
|
||||
body = None
|
||||
if allowed:
|
||||
body = format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=reviewer_identity,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
worktree=worktree,
|
||||
phase="claimed",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=now,
|
||||
)
|
||||
return {
|
||||
"acquire_allowed": allowed,
|
||||
"reasons": reasons,
|
||||
"existing_lease": existing,
|
||||
"lease_body": body,
|
||||
"session_id": session_id,
|
||||
"post_merge_moot": post_merge_moot,
|
||||
}
|
||||
|
||||
|
||||
def assess_post_merge_moot_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
pr_merged: bool = False,
|
||||
pr_state: str | None = None,
|
||||
merge_commit_sha: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess a reviewer lease left lingering on an already-merged/closed PR (#515).
|
||||
|
||||
Read-first and fail-safe:
|
||||
|
||||
- Only treats a lease as moot when the live PR state is merged/closed.
|
||||
- Never proposes touching an *active* lease while the PR is still open
|
||||
(``cleanup_allowed`` stays false and a refusal reason is returned).
|
||||
- When the PR is merged/closed and a lease is still active, ``cleanup_allowed``
|
||||
is true and a terminal ``phase: released`` lease body (``blocker:
|
||||
post-merge-moot``) is provided so the moot lease can be neutralised by an
|
||||
append-only comment — never by deleting a foreign session's comment, and
|
||||
never by adopting or merging.
|
||||
|
||||
Posting the released body makes that lease terminal, so a subsequent call
|
||||
finds no active lease and reports nothing left to clean (idempotent).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
merged_or_closed = bool(pr_merged) or (
|
||||
str(pr_state or "").strip().lower() == "closed"
|
||||
)
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
# The newest lease comment is authoritative: once a terminal marker
|
||||
# (released/done/blocked) is the latest entry, the lease is resolved even if
|
||||
# an earlier non-terminal comment from the same session still lingers. This
|
||||
# keeps post-merge cleanup idempotent.
|
||||
entries = _lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
newest_terminal = bool(newest) and (
|
||||
(newest.get("phase") or "").strip().lower() in _TERMINAL_PHASES
|
||||
)
|
||||
reasons: list[str] = []
|
||||
cleanup_allowed = False
|
||||
release_body: str | None = None
|
||||
is_moot = bool(active) and merged_or_closed and not newest_terminal
|
||||
|
||||
if not merged_or_closed:
|
||||
if active:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; refusing to touch active reviewer "
|
||||
"lease (fail closed)"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; no post-merge lease cleanup applicable"
|
||||
)
|
||||
elif newest_terminal:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} reviewer lease already released/terminal; nothing to clean"
|
||||
)
|
||||
elif active:
|
||||
cleanup_allowed = True
|
||||
release_body = format_lease_body(
|
||||
repo=active.get("repo") or "",
|
||||
pr_number=pr_number,
|
||||
issue_number=active.get("issue_number"),
|
||||
reviewer_identity=active.get("reviewer_identity") or "",
|
||||
profile=active.get("profile") or "unknown",
|
||||
session_id=active.get("session_id") or "",
|
||||
worktree=active.get("worktree") or "",
|
||||
phase="released",
|
||||
candidate_head=active.get("candidate_head"),
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
last_activity=now,
|
||||
blocker="post-merge-moot",
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is merged/closed but no active reviewer lease remains; "
|
||||
"nothing to clean"
|
||||
)
|
||||
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"pr_merged_or_closed": merged_or_closed,
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
"active_lease": active,
|
||||
"is_moot": is_moot,
|
||||
"cleanup_allowed": cleanup_allowed,
|
||||
"release_body": release_body,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(
|
||||
lease: dict[str, Any],
|
||||
*,
|
||||
lease_provenance: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record the in-session lease mirror for mutation gates.
|
||||
|
||||
*lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare
|
||||
manual seeding without provenance cannot satisfy merger/reviewer mutation
|
||||
gates.
|
||||
"""
|
||||
global _SESSION_LEASE
|
||||
stored = dict(lease)
|
||||
if lease_provenance:
|
||||
stored["lease_provenance"] = dict(lease_provenance)
|
||||
_SESSION_LEASE = stored
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
def clear_session_lease() -> None:
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = None
|
||||
|
||||
|
||||
def get_session_lease() -> dict[str, Any] | None:
|
||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||
|
||||
|
||||
def assess_mutation_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewer_identity: str,
|
||||
session_id: str | None,
|
||||
mutation: str,
|
||||
live_head_sha: str | None,
|
||||
pinned_head_sha: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer mutations require an owned, current PR lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
session = get_session_lease()
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if not session:
|
||||
reasons.append(
|
||||
f"no in-session reviewer lease recorded; acquire via "
|
||||
f"gitea_acquire_reviewer_pr_lease or adopt via "
|
||||
f"gitea_adopt_merger_pr_lease before {mutation}"
|
||||
)
|
||||
else:
|
||||
import merger_lease_adoption as mla
|
||||
|
||||
if not mla.is_sanctioned_session_lease(session):
|
||||
reasons.append(
|
||||
"in-session lease lacks sanctioned provenance; manual "
|
||||
"_SESSION_LEASE seeding is not canonical proof — use "
|
||||
"gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease"
|
||||
)
|
||||
if session and session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
||||
)
|
||||
elif session and (session.get("session_id") or "") != (
|
||||
session_id or session.get("session_id")
|
||||
):
|
||||
reasons.append("session lease session_id mismatch (fail closed)")
|
||||
|
||||
if active:
|
||||
owner = (active.get("session_id") or "").strip()
|
||||
if owner and session_id and owner != session_id:
|
||||
reasons.append(
|
||||
f"active PR lease owned by session_id={owner}; current session "
|
||||
f"cannot {mutation}"
|
||||
)
|
||||
pinned = _normalize_sha(pinned_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
lease_head = active.get("candidate_head")
|
||||
if pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"PR head changed during lease; stop and re-validate before "
|
||||
f"reviewer {mutation}"
|
||||
)
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"live PR head differs from lease candidate_head; refresh lease "
|
||||
f"before {mutation}"
|
||||
)
|
||||
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
|
||||
if freshness in {"expired", "reclaimable"}:
|
||||
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
|
||||
else:
|
||||
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"session_lease": session,
|
||||
}
|
||||
|
||||
|
||||
def assess_lease_inventory(
|
||||
comments_by_pr: dict[int, list[dict]],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize lease states across PR comment threads."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
active: list[dict] = []
|
||||
stale: list[dict] = []
|
||||
reclaimable: list[dict] = []
|
||||
for pr_number, comments in (comments_by_pr or {}).items():
|
||||
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
if not lease:
|
||||
continue
|
||||
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
|
||||
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
|
||||
if freshness == "stale_warning":
|
||||
stale.append(entry)
|
||||
elif freshness == "reclaimable":
|
||||
reclaimable.append(entry)
|
||||
else:
|
||||
active.append(entry)
|
||||
return {
|
||||
"active_review_leases": active,
|
||||
"stale_review_leases": stale,
|
||||
"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",
|
||||
],
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Proof-backed reviewer handoff claim verifier (#395)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_PAGINATION_FINALITY = re.compile(
|
||||
r"has_more\s*:\s*false|is_final_page\s*:\s*true|"
|
||||
r"inventory_complete\s*:\s*true|pages_fetched|total_count\s*:",
|
||||
re.I,
|
||||
)
|
||||
_PAGE_SIZE_ONLY = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?page[- ]?size|"
|
||||
r"under (?:the )?50[- ]?(?:item )?limit|"
|
||||
r"returned \d+ (?:open )?prs?.*less than 50",
|
||||
re.I,
|
||||
)
|
||||
_INVENTORY_COMPLETE_CLAIM = re.compile(
|
||||
r"\b(?:inventory (?:is )?complete|inventory exhaustive|"
|
||||
r"complete (?:pr )?inventory)\b",
|
||||
re.I,
|
||||
)
|
||||
_SKIP_CLAIM = re.compile(
|
||||
r"\b(?:earlier prs? skipped|skipped (?:earlier )?pr|"
|
||||
r"skip(?:ped)? pr #?\d+|non-mergeable|prior request.changes)\b",
|
||||
re.I,
|
||||
)
|
||||
_CONFLICT_PROOF = re.compile(
|
||||
r"merge simulation|git merge --no-commit|conflicting files|"
|
||||
r"conflict proof|merge_exit|non-mergeable",
|
||||
re.I,
|
||||
)
|
||||
_BASELINE_CLAIM = re.compile(
|
||||
r"\b(?:baseline (?:validation|worktree|comparison)|"
|
||||
r"same as master|pre-existing (?:on )?master|"
|
||||
r"failure signatures match)\b",
|
||||
re.I,
|
||||
)
|
||||
_BASELINE_PATH = re.compile(r"baseline worktree path\s*:\s*(\S+)", re.I)
|
||||
_BASELINE_SHA = re.compile(r"baseline (?:target )?sha\s*:\s*([0-9a-f]{7,40})", re.I)
|
||||
_BASELINE_DIRTY_BEFORE = re.compile(
|
||||
r"baseline.*dirty before|dirty before.*baseline", re.I
|
||||
)
|
||||
_BASELINE_DIRTY_AFTER = re.compile(
|
||||
r"baseline.*dirty after|dirty after.*baseline", re.I
|
||||
)
|
||||
_BASELINE_COMMAND = re.compile(
|
||||
r"baseline.*(?:validation )?command|pytest.*baseline", re.I
|
||||
)
|
||||
_BASELINE_RESULT = re.compile(
|
||||
r"baseline.*(?:validation )?result|baseline_exit|baseline failures", re.I
|
||||
)
|
||||
_MASTER_INTEGRATION = re.compile(
|
||||
r"\b(?:merged master into|merge(?:d)? (?:remote[- ]tracking )?branch.*master|"
|
||||
r"master integration|integrated master|rebase.*master)\b",
|
||||
re.I,
|
||||
)
|
||||
_CLEANUP_CLAIM = re.compile(
|
||||
r"\b(?:worktree(?:s)? (?:were )?cleaned|cleanup (?:result|mutations)|"
|
||||
r"removed (?:session[- ]owned )?worktree|git worktree remove)\b",
|
||||
re.I,
|
||||
)
|
||||
_WORKTREE_LIST = re.compile(r"git worktree list|worktree list proof", re.I)
|
||||
_PROOF_SOURCE = re.compile(
|
||||
r"proof source\s*:\s*(command|mcp metadata|prior blocker|not checked)",
|
||||
re.I,
|
||||
)
|
||||
_HANDOFF_SECTION = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
|
||||
def _handoff_fields(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].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 _command_text(entry: Any) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("command") or entry.get("tool") or "").strip()
|
||||
return str(entry or "").strip()
|
||||
|
||||
|
||||
def _action_log_has(action_log: list | None, pattern: re.Pattern[str]) -> bool:
|
||||
for entry in action_log or []:
|
||||
blob = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
_command_text(entry),
|
||||
str(entry.get("result") or "") if isinstance(entry, dict) else "",
|
||||
str(entry.get("reason") or "") if isinstance(entry, dict) else "",
|
||||
],
|
||||
)
|
||||
)
|
||||
if pattern.search(blob):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_proof_backed_handoff_report(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list | None = None,
|
||||
inventory_session: dict | None = None,
|
||||
baseline_session: dict | None = None,
|
||||
skip_session: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require explicit command/tool evidence for proof-sensitive review claims (#395)."""
|
||||
text = report_text or ""
|
||||
fields = _handoff_fields(text)
|
||||
reasons: list[str] = []
|
||||
inventory = dict(inventory_session or {})
|
||||
baseline = dict(baseline_session or {})
|
||||
skips = list(skip_session or [])
|
||||
|
||||
pagination_field = fields.get("inventory pagination proof", "")
|
||||
if _INVENTORY_COMPLETE_CLAIM.search(text) or _PAGE_SIZE_ONLY.search(text):
|
||||
has_meta = bool(
|
||||
inventory.get("inventory_complete")
|
||||
or inventory.get("pagination_complete")
|
||||
or _PAGINATION_FINALITY.search(pagination_field)
|
||||
or _PAGINATION_FINALITY.search(text)
|
||||
or _action_log_has(action_log, re.compile(r"gitea_list_prs", re.I))
|
||||
)
|
||||
if _PAGE_SIZE_ONLY.search(text) and not has_meta:
|
||||
reasons.append(
|
||||
"inventory completeness claimed from page-size assumption only; "
|
||||
"require has_more=false, is_final_page=true, or inventory_complete=true"
|
||||
)
|
||||
elif _INVENTORY_COMPLETE_CLAIM.search(text) and not has_meta:
|
||||
reasons.append(
|
||||
"inventory complete claim lacks explicit pagination metadata proof"
|
||||
)
|
||||
|
||||
if _SKIP_CLAIM.search(text) or fields.get("earlier prs skipped", "").lower() not in {
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
}:
|
||||
skip_proof = bool(
|
||||
skips
|
||||
or _CONFLICT_PROOF.search(text)
|
||||
or _action_log_has(
|
||||
action_log, re.compile(r"git merge --no-commit|gitea_get_pr_review_feedback", re.I)
|
||||
)
|
||||
)
|
||||
if not skip_proof:
|
||||
reasons.append(
|
||||
"earlier PR skip claim lacks command/tool conflict or blocker proof"
|
||||
)
|
||||
|
||||
if _BASELINE_CLAIM.search(text) or fields.get("baseline worktree used", "").lower() == "true":
|
||||
baseline_ok = bool(
|
||||
baseline.get("complete")
|
||||
or (
|
||||
_BASELINE_PATH.search(text)
|
||||
and _BASELINE_SHA.search(text)
|
||||
and (_BASELINE_DIRTY_BEFORE.search(text) or baseline.get("clean_before"))
|
||||
and (_BASELINE_COMMAND.search(text) or baseline.get("command"))
|
||||
and (_BASELINE_RESULT.search(text) or baseline.get("result"))
|
||||
and (_BASELINE_DIRTY_AFTER.search(text) or baseline.get("clean_after"))
|
||||
)
|
||||
)
|
||||
if not baseline_ok:
|
||||
reasons.append(
|
||||
"baseline validation claim missing worktree path, target SHA, "
|
||||
"dirty-before/after status, command, or result proof"
|
||||
)
|
||||
|
||||
if _MASTER_INTEGRATION.search(text):
|
||||
if not _action_log_has(
|
||||
action_log,
|
||||
re.compile(r"git merge.*master|git rebase.*master", re.I),
|
||||
) and not re.search(r"merge_exit\s*=\s*\d+|merge simulation", text, re.I):
|
||||
reasons.append(
|
||||
"master integration claim lacks exact merge/rebase command and result"
|
||||
)
|
||||
|
||||
if _CLEANUP_CLAIM.search(text) or fields.get("cleanup mutations", "").lower() not in {
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
}:
|
||||
if not (_WORKTREE_LIST.search(text) or _action_log_has(action_log, _WORKTREE_LIST)):
|
||||
reasons.append(
|
||||
"cleanup claim lacks final git worktree list or equivalent proof"
|
||||
)
|
||||
|
||||
if re.search(r"\blive proof\b", text, re.I) and not _PROOF_SOURCE.search(text):
|
||||
if not action_log:
|
||||
reasons.append(
|
||||
"live proof wording requires proof source classification "
|
||||
"(command, MCP metadata, prior blocker, or not checked)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"cite explicit command/tool evidence or structured MCP pagination metadata "
|
||||
"for each proof-sensitive claim"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Reconciliation PR inventory pagination proof verifier (#308)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_COMPLETE_SCAN_CLAIM_RE = re.compile(
|
||||
r"\b(?:all already[- ]landed(?:\s+prs?)?(?:\s+were)?\s+found|"
|
||||
r"complete queue scan|all open prs? (?:were )?(?:found|checked|inventoried|listed)|"
|
||||
r"inventory (?:is )?complete|exhaustive (?:pr )?inventory|"
|
||||
r"no additional already[- ]landed|scanned (?:the )?(?:full )?open pr queue)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_FINALITY_RE = re.compile(
|
||||
r"is_final_page\s*:\s*true|has_more\s*:\s*false|no next page|final[- ]page|"
|
||||
r"pagination_complete\s*:\s*true|inventory pagination proof\s*:\s*(?!assumed|none\b).+",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_REQUESTED_PAGE_SIZE_RE = re.compile(
|
||||
r"requested page size\s*:\s*(\d+)|page[- ]?size\s*(?:=|:)\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
_PAGES_FETCHED_RE = re.compile(
|
||||
r"pages? fetched\s*:\s*(\d+)|page count\s*:\s*(\d+)",
|
||||
re.I,
|
||||
)
|
||||
_RETURNED_PER_PAGE_RE = re.compile(
|
||||
r"returned pr count(?: per page)?\s*:|open pr count per page|"
|
||||
r"returned \d+ open prs? per page|per[- ]page counts?\s*:",
|
||||
re.I,
|
||||
)
|
||||
_EXACT_LIMIT_RETURN_RE = re.compile(
|
||||
r"returned\s+(\d+)\s+open prs?|listed\s+(\d+)\s+open prs?",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
|
||||
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
|
||||
r"default (?:gitea )?page[- ]?size|assumed complete",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _int_from_groups(match: re.Match[str] | None) -> int | None:
|
||||
if not match:
|
||||
return None
|
||||
for group in match.groups():
|
||||
if group:
|
||||
return int(group)
|
||||
return None
|
||||
|
||||
|
||||
def _pagination_proven(text: str, session: dict) -> bool:
|
||||
if session.get("pagination_complete") or session.get("inventory_complete"):
|
||||
return True
|
||||
if _FINALITY_RE.search(text):
|
||||
return True
|
||||
pages = session.get("pages_fetched")
|
||||
if isinstance(pages, int) and pages >= 1 and session.get("is_final_page"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _metadata_present(text: str, session: dict) -> list[str]:
|
||||
missing: list[str] = []
|
||||
has_page_size = bool(
|
||||
_REQUESTED_PAGE_SIZE_RE.search(text)
|
||||
or session.get("requested_page_size")
|
||||
)
|
||||
has_pages_fetched = bool(
|
||||
_PAGES_FETCHED_RE.search(text) or session.get("pages_fetched")
|
||||
)
|
||||
has_returned_counts = bool(
|
||||
_RETURNED_PER_PAGE_RE.search(text) or session.get("returned_per_page")
|
||||
)
|
||||
if not has_page_size:
|
||||
missing.append("requested page size")
|
||||
if not has_pages_fetched:
|
||||
missing.append("page count fetched")
|
||||
if not has_returned_counts:
|
||||
missing.append("returned PR count per page")
|
||||
if not _pagination_proven(text, session):
|
||||
missing.append("final-page/no-next-page proof")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reconcile_inventory_report(
|
||||
report_text: str,
|
||||
*,
|
||||
inventory_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate PR inventory pagination proof in reconciliation reports (#308)."""
|
||||
text = report_text or ""
|
||||
session = dict(inventory_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
claims_scan = bool(_COMPLETE_SCAN_CLAIM_RE.search(text))
|
||||
lists_open_prs = bool(
|
||||
re.search(r"open prs? listed|listed open prs?|pr inventory", text, re.I)
|
||||
)
|
||||
|
||||
if not claims_scan and not lists_open_prs and not session.get("inventory_required"):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"inventory_claimed": False,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text) and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"reconciliation inventory assumed complete from page-size guess "
|
||||
"without final-page proof (#308)"
|
||||
)
|
||||
|
||||
if claims_scan and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"complete queue scan or all already-landed claim requires "
|
||||
"pagination finality proof (#308)"
|
||||
)
|
||||
|
||||
missing = _metadata_present(text, session)
|
||||
if (claims_scan or lists_open_prs) and missing:
|
||||
reasons.append(
|
||||
"reconciliation inventory missing pagination metadata: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
requested = session.get("requested_page_size")
|
||||
returned = session.get("returned_page_size")
|
||||
if isinstance(requested, int) and isinstance(returned, int):
|
||||
if returned >= requested > 0 and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"exactly-full first reconciliation page without final-page proof (#308)"
|
||||
)
|
||||
else:
|
||||
for match in _EXACT_LIMIT_RETURN_RE.finditer(text):
|
||||
count = _int_from_groups(match)
|
||||
if count in {10, 20, 50} and not _pagination_proven(text, session):
|
||||
reasons.append(
|
||||
"exactly-full first reconciliation page without final-page proof (#308)"
|
||||
)
|
||||
break
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"inventory_claimed": claims_scan or lists_open_prs,
|
||||
"pagination_proven": _pagination_proven(text, session),
|
||||
"safe_next_action": (
|
||||
"include requested page size, pages fetched, per-page counts, and "
|
||||
"final-page/no-next-page proof before claiming complete scan"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Reconciliation linked-issue live proof verifier (#300)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
|
||||
|
||||
_LINKED_ISSUE_FIELD_RE = re.compile(
|
||||
r"linked issue\s*:\s*(.+)$",
|
||||
re.I | re.M,
|
||||
)
|
||||
_LINKED_ISSUE_STATUS_RE = re.compile(
|
||||
r"linked issue(?:\s+live)?\s+status\s*:\s*(.+)$",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
_STATUS_CLAIM_RE = re.compile(
|
||||
r"\b(?:issue\s+#?\d+\s*\()?(open|closed|resolved)\b|"
|
||||
r"issue\s+(?:is\s+)?(open|closed|resolved)\b|"
|
||||
r"linked issue.*\b(open|closed|resolved)\b",
|
||||
re.I,
|
||||
)
|
||||
_NOT_VERIFIED_RE = re.compile(r"not verified in this session", re.I)
|
||||
|
||||
_LIVE_FETCH_PROOF_RE = re.compile(
|
||||
r"gitea_view_issue|issue fetched live|live issue fetch|"
|
||||
r"fetched linked issue.*(?:current|this) session|"
|
||||
r"linked issue (?:fetch|proof).*(?:current|this) session|"
|
||||
r"live linked issue proof",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_ISSUE_ACTION_RECOMMEND_RE = re.compile(
|
||||
r"(?:recommend(?:ed)?|should|must)\s+(?:close|reopen|comment on)\s+"
|
||||
r"(?:the\s+)?(?:linked\s+)?issue|"
|
||||
r"(?:close|reopen|comment on)\s+linked issue\s+#?\d+",
|
||||
re.I,
|
||||
)
|
||||
_CAPABILITY_PROOF_RE = re.compile(
|
||||
r"capability proof|exact capability|gitea_close_issue|"
|
||||
r"gitea_mark_issue|gitea_create_issue_comment|gitea_edit_issue",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_NO_LINKED_ISSUE_VALUES = frozenset({
|
||||
"",
|
||||
"none",
|
||||
"n/a",
|
||||
"not applicable",
|
||||
"no linked issue",
|
||||
"unknown",
|
||||
})
|
||||
|
||||
|
||||
def _handoff_field_map(report_text: str) -> dict[str, str]:
|
||||
text = report_text or ""
|
||||
match = _HANDOFF_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return {}
|
||||
fields: dict[str, str] = {}
|
||||
for line in text[match.end() :].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 _extract_linked_issue_number(text: str, fields: dict[str, str]) -> int | None:
|
||||
linked = fields.get("linked issue", "")
|
||||
if linked.lower() in _NO_LINKED_ISSUE_VALUES:
|
||||
return None
|
||||
match = re.search(r"#?(\d+)", linked)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
field_match = _LINKED_ISSUE_FIELD_RE.search(text)
|
||||
if field_match:
|
||||
num = re.search(r"#?(\d+)", field_match.group(1))
|
||||
if num:
|
||||
return int(num.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _status_value(text: str, fields: dict[str, str]) -> str:
|
||||
for key in ("linked issue live status", "linked issue status"):
|
||||
if fields.get(key):
|
||||
return fields[key]
|
||||
match = _LINKED_ISSUE_STATUS_RE.search(text)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
def _live_proof_present(text: str, session: dict, issue_number: int | None) -> bool:
|
||||
if session.get("live_fetched") or session.get("verified_live"):
|
||||
return True
|
||||
if session.get("issue_fetch_proof"):
|
||||
return True
|
||||
if _LIVE_FETCH_PROOF_RE.search(text):
|
||||
return True
|
||||
if issue_number is not None:
|
||||
pattern = re.compile(
|
||||
rf"gitea_view_issue.*#?{issue_number}|"
|
||||
rf"issue\s+#?{issue_number}.*(?:fetched|viewed).*(?:current|this) session",
|
||||
re.I,
|
||||
)
|
||||
if pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_reconcile_linked_issue_report(
|
||||
report_text: str,
|
||||
*,
|
||||
reconcile_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate linked issue status claims in reconciliation handoffs (#300)."""
|
||||
text = report_text or ""
|
||||
session = dict(reconcile_session or {})
|
||||
fields = _handoff_field_map(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
issue_number = session.get("linked_issue_number")
|
||||
if issue_number is None:
|
||||
issue_number = _extract_linked_issue_number(text, fields)
|
||||
|
||||
if issue_number is None:
|
||||
status = _status_value(text, fields)
|
||||
if status and status.lower() not in _NO_LINKED_ISSUE_VALUES:
|
||||
if _STATUS_CLAIM_RE.search(status):
|
||||
reasons.append(
|
||||
"linked issue status reported without identifying the linked issue (#300)"
|
||||
)
|
||||
if not reasons:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"linked_issue_number": None,
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
status = _status_value(text, fields)
|
||||
status_lower = status.lower()
|
||||
|
||||
if session.get("issue_missing"):
|
||||
if status_lower and "not verified" not in status_lower:
|
||||
reasons.append(
|
||||
"missing linked issue must be reported as "
|
||||
"'not verified in this session' (#300)"
|
||||
)
|
||||
elif status:
|
||||
if _NOT_VERIFIED_RE.search(status):
|
||||
pass
|
||||
elif _STATUS_CLAIM_RE.search(status):
|
||||
if not _live_proof_present(text, session, issue_number):
|
||||
reasons.append(
|
||||
"linked issue open/closed/resolved status requires live "
|
||||
"gitea_view_issue proof in the current session (#300)"
|
||||
)
|
||||
elif status_lower not in _NO_LINKED_ISSUE_VALUES:
|
||||
if not _live_proof_present(text, session, issue_number):
|
||||
reasons.append(
|
||||
"linked issue status claim requires live fetch proof or "
|
||||
"'not verified in this session' (#300)"
|
||||
)
|
||||
|
||||
if _ISSUE_ACTION_RECOMMEND_RE.search(text):
|
||||
if not _CAPABILITY_PROOF_RE.search(text) and not session.get(
|
||||
"issue_action_capability_proven"
|
||||
):
|
||||
reasons.append(
|
||||
"recommended linked issue close/reopen/comment requires "
|
||||
"exact capability proof (#300)"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
"linked_issue_number": issue_number,
|
||||
"live_proof_present": _live_proof_present(text, session, issue_number),
|
||||
"safe_next_action": (
|
||||
"fetch linked issue with gitea_view_issue in this session or "
|
||||
"report 'Linked issue status: not verified in this session'"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Explicit worktree and cwd proof for PR review validation (#398)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_PWD_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HEAD_RE = re.compile(
|
||||
r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_EXPECTED_HEAD_RE = re.compile(
|
||||
r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STATUS_RE = re.compile(
|
||||
r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_CMD_RE = re.compile(
|
||||
r"validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE)
|
||||
_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE)
|
||||
_BASELINE_CWD_RE = re.compile(
|
||||
r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_SHA_RE = re.compile(
|
||||
r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_CMD_RE = re.compile(
|
||||
r"baseline\s+validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def _path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(project_root)
|
||||
if normalized.startswith(f"{root}/"):
|
||||
rel = normalized[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def _expand_sha(sha: str) -> str:
|
||||
return (sha or "").strip().lower()
|
||||
|
||||
|
||||
def _sha_matches(expected: str, observed: str) -> bool:
|
||||
exp = _expand_sha(expected)
|
||||
obs = _expand_sha(observed)
|
||||
if not exp or not obs:
|
||||
return False
|
||||
if len(exp) == 40 and len(obs) == 40:
|
||||
return exp == obs
|
||||
return obs.startswith(exp) or exp.startswith(obs)
|
||||
|
||||
|
||||
def _command_has_explicit_cwd(command: str, cwd: str) -> bool:
|
||||
text = (command or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if _GIT_C_CMD_RE.search(text):
|
||||
return True
|
||||
if _CD_CMD_RE.search(text):
|
||||
return True
|
||||
if cwd and cwd in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_validation_cwd_proof_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require cwd/HEAD proof before reviewer validation claims (#398)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
violations: list[str] = []
|
||||
|
||||
claims_validation = bool(
|
||||
session.get("validation_ran")
|
||||
or _VALIDATION_CMD_RE.search(text)
|
||||
or session.get("command")
|
||||
)
|
||||
if not claims_validation:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"claims_validation": False,
|
||||
"reasons": [],
|
||||
"violations": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
expected_head = (
|
||||
session.get("expected_head_sha")
|
||||
or session.get("candidate_head_sha")
|
||||
or ""
|
||||
).strip()
|
||||
if not expected_head:
|
||||
match = _EXPECTED_HEAD_RE.search(text)
|
||||
expected_head = (match.group(1) if match else "").strip()
|
||||
|
||||
observed_head = (session.get("observed_head_sha") or "").strip()
|
||||
if not observed_head:
|
||||
match = _HEAD_RE.search(text)
|
||||
observed_head = (match.group(1) if match else "").strip()
|
||||
|
||||
cwd = (
|
||||
session.get("working_directory")
|
||||
or session.get("cwd")
|
||||
or session.get("pwd")
|
||||
or ""
|
||||
).strip()
|
||||
if not cwd:
|
||||
match = _PWD_RE.search(text)
|
||||
cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
|
||||
command = (session.get("command") or "").strip()
|
||||
if not command:
|
||||
match = _VALIDATION_CMD_RE.search(text)
|
||||
command = (match.group(1) if match else "").strip().rstrip(".;")
|
||||
|
||||
if not cwd:
|
||||
reasons.append(
|
||||
"validation claimed without pwd/working-directory proof (#398)"
|
||||
)
|
||||
elif not _path_under_branches(cwd, project_root):
|
||||
violations.append(
|
||||
f"validation cwd {cwd!r} is not under branches/ (#398)"
|
||||
)
|
||||
reasons.append(
|
||||
"reviewer validation must run from a branches/ worktree, "
|
||||
"not the main checkout (#398)"
|
||||
)
|
||||
|
||||
if not observed_head:
|
||||
reasons.append(
|
||||
"validation claimed without git rev-parse HEAD / observed HEAD SHA "
|
||||
"proof (#398)"
|
||||
)
|
||||
elif expected_head and not _sha_matches(expected_head, observed_head):
|
||||
violations.append(
|
||||
f"observed HEAD {observed_head} does not match expected "
|
||||
f"PR head {expected_head} (#398)"
|
||||
)
|
||||
reasons.append("validation HEAD SHA must match pinned PR head (#398)")
|
||||
|
||||
if not _STATUS_RE.search(text) and session.get("git_status") is None:
|
||||
reasons.append(
|
||||
"validation claimed without git status --short --branch proof (#398)"
|
||||
)
|
||||
|
||||
if command and cwd and not _command_has_explicit_cwd(command, cwd):
|
||||
if session.get("tool_working_directory") is not True:
|
||||
reasons.append(
|
||||
"validation command must use git -C <worktree>, "
|
||||
"cd <worktree> && ..., or tool-provided cwd metadata (#398)"
|
||||
)
|
||||
|
||||
baseline_ran = bool(
|
||||
session.get("baseline_validation_ran")
|
||||
or _BASELINE_CMD_RE.search(text)
|
||||
)
|
||||
if baseline_ran:
|
||||
baseline_cwd = (session.get("baseline_worktree_path") or "").strip()
|
||||
if not baseline_cwd:
|
||||
match = _BASELINE_CWD_RE.search(text)
|
||||
baseline_cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
if not baseline_cwd or not _path_under_branches(baseline_cwd, project_root):
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline worktree cwd "
|
||||
"under branches/ (#398)"
|
||||
)
|
||||
baseline_sha = (session.get("baseline_target_sha") or "").strip()
|
||||
if not baseline_sha:
|
||||
match = _BASELINE_SHA_RE.search(text)
|
||||
baseline_sha = (match.group(1) if match else "").strip()
|
||||
if not baseline_sha:
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline target SHA (#398)"
|
||||
)
|
||||
baseline_cmd = (session.get("baseline_command") or "").strip()
|
||||
if not baseline_cmd:
|
||||
match = _BASELINE_CMD_RE.search(text)
|
||||
baseline_cmd = (match.group(1) if match else "").strip()
|
||||
if not baseline_cmd:
|
||||
reasons.append(
|
||||
"baseline validation claimed without exact baseline command (#398)"
|
||||
)
|
||||
|
||||
proven = not reasons and not violations
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": bool(violations) or not proven,
|
||||
"claims_validation": True,
|
||||
"expected_head_sha": expected_head or None,
|
||||
"observed_head_sha": observed_head or None,
|
||||
"working_directory": cwd or None,
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"safe_next_action": (
|
||||
"before validation record pwd, git rev-parse HEAD, git status, "
|
||||
"expected PR head SHA; run commands with git -C or cd in the same line"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user