Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67b2f54eb | ||
|
|
a2cabb64b1 | ||
|
|
69e9e25fcf |
@@ -46,12 +46,3 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
|||||||
# profile's values. Leave unset for pure env-based configuration.
|
# profile's values. Leave unset for pure env-based configuration.
|
||||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||||
GITEA_MCP_PROFILE=prgs
|
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
|
|
||||||
|
|||||||
@@ -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, []
|
|
||||||
@@ -7,13 +7,8 @@ project's ``branches/`` directory, never from the stable control checkout.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
|
|
||||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
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:
|
def _normalize_path(path: str) -> str:
|
||||||
@@ -53,130 +48,6 @@ def resolve_mutation_workspace(
|
|||||||
return os.path.realpath(project_root)
|
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(
|
def assess_author_mutation_worktree(
|
||||||
*,
|
*,
|
||||||
workspace_path: str,
|
workspace_path: str,
|
||||||
|
|||||||
@@ -286,28 +286,6 @@ heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
|||||||
work. An expired lease still blocks takeover until a recovery review records why
|
work. An expired lease still blocks takeover until a recovery review records why
|
||||||
the prior work is abandoned, completed, or unsafe to continue.
|
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
|
**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
|
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
|
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||||
@@ -325,22 +303,33 @@ shared state and manual writes can clobber another session's live lease. Use
|
|||||||
3. Operator override only when explicitly authorized — record
|
3. Operator override only when explicitly authorized — record
|
||||||
`External-state mutations` and `operator override proof` in the final report.
|
`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`
|
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||||
approval in one run. See also #438 (global lock redesign).
|
approval in one run. See also #438 (global lock redesign).
|
||||||
|
|
||||||
|
### Non-destructive lock recovery after push (#440)
|
||||||
|
|
||||||
|
When work is pushed but the in-memory MCP session lock is lost (server restart,
|
||||||
|
crashed process, or stale session pointer):
|
||||||
|
|
||||||
|
1. **Do not delete the remote branch.** Branch deletion is not a normal recovery
|
||||||
|
step and can destroy the only copy of unmerged work.
|
||||||
|
2. **Re-run `gitea_lock_issue`** with the same `issue_number`, exact
|
||||||
|
`branch_name`, and active `branches/` worktree. Own-branch adoption
|
||||||
|
reacquires the lease when the remote branch is the caller's exact branch and
|
||||||
|
no open PR or competing same-issue branch exists.
|
||||||
|
3. **Call `gitea_create_pr`** with the same `head` branch. The server resolves
|
||||||
|
the durable keyed lock file even when the session pointer was cleared at
|
||||||
|
restart.
|
||||||
|
4. **Stop fail-closed** when another actor owns a competing branch, an open PR
|
||||||
|
already exists, or a live foreign lease blocks takeover — never adopt their
|
||||||
|
branch.
|
||||||
|
|
||||||
|
Branch ownership uses structured evidence
|
||||||
|
`(fix|feat|docs|chore)/issue-<n>-<desc>` — not broad substring matches on
|
||||||
|
`issue-<n>`.
|
||||||
|
|
||||||
Remote branches matching the issue number are also treated as active work unless
|
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
|
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
|
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
||||||
@@ -823,45 +812,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
|
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
|
## Safety notes
|
||||||
|
|
||||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import re
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
|
||||||
from review_proofs import (
|
from review_proofs import (
|
||||||
HANDOFF_HEADING,
|
HANDOFF_HEADING,
|
||||||
assess_controller_handoff,
|
assess_controller_handoff,
|
||||||
@@ -22,7 +21,6 @@ from review_proofs import (
|
|||||||
assess_review_mutation_final_report,
|
assess_review_mutation_final_report,
|
||||||
assess_validation_report,
|
assess_validation_report,
|
||||||
)
|
)
|
||||||
from validation_status_vocabulary import assess_validation_status_vocabulary
|
|
||||||
|
|
||||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
@@ -118,22 +116,6 @@ _TARGET_BRANCH_SHA_RE = re.compile(
|
|||||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
|
||||||
r"workflow[- ]load helper result\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_WORKFLOW_LOAD_HASH_RE = re.compile(
|
|
||||||
r"workflow[- ]load helper result[\s\S]{0,400}?workflow[_ ]hash\s*:\s*[0-9a-f]{12}",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
|
||||||
r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
|
||||||
r"(?:read|viewed|loaded)\s+(?:the\s+)?(?:canonical\s+)?(?:workflow|review-merge-pr\.md)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||||
_RECONCILE_STALE_FIELDS = (
|
_RECONCILE_STALE_FIELDS = (
|
||||||
"pr number opened",
|
"pr number opened",
|
||||||
@@ -509,81 +491,6 @@ def _rule_reviewer_validation_failure_history(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_cwd_proof(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
validation_session: dict | None = None,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report
|
|
||||||
|
|
||||||
session = validation_session or {}
|
|
||||||
claims = (
|
|
||||||
session.get("validation_ran")
|
|
||||||
or session.get("command")
|
|
||||||
or session.get("baseline_validation_ran")
|
|
||||||
)
|
|
||||||
if not claims and "validation command:" not in (report_text or "").lower():
|
|
||||||
return []
|
|
||||||
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
report_text,
|
|
||||||
validation_session=session,
|
|
||||||
)
|
|
||||||
if result.get("proven") or not result.get("claims_validation"):
|
|
||||||
return []
|
|
||||||
severity = "block" if result.get("violations") else "downgrade"
|
|
||||||
return [
|
|
||||||
validator_finding(
|
|
||||||
"reviewer.validation_cwd_proof",
|
|
||||||
severity,
|
|
||||||
"Validation cwd/HEAD proof",
|
|
||||||
reason,
|
|
||||||
result.get("safe_next_action")
|
|
||||||
or "document pwd, HEAD SHA, and explicit cwd before validation",
|
|
||||||
)
|
|
||||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
|
||||||
from pr_work_lease import assess_reviewer_stale_head_final_report
|
|
||||||
|
|
||||||
result = assess_reviewer_stale_head_final_report(report_text)
|
|
||||||
if result.get("proven"):
|
|
||||||
return []
|
|
||||||
return _findings_from_reasons(
|
|
||||||
"reviewer.stale_head_proof",
|
|
||||||
result.get("reasons") or [],
|
|
||||||
field="Stale-head proof",
|
|
||||||
severity="block",
|
|
||||||
safe_next_action=(
|
|
||||||
"state reviewed head SHA, live head before approval/merge, and "
|
|
||||||
"whether any push occurred during validation"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
|
|
||||||
from pr_work_lease import assess_conflict_fix_final_report
|
|
||||||
|
|
||||||
text = report_text or ""
|
|
||||||
if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower():
|
|
||||||
return []
|
|
||||||
result = assess_conflict_fix_final_report(text)
|
|
||||||
if result.get("proven"):
|
|
||||||
return []
|
|
||||||
return _findings_from_reasons(
|
|
||||||
"author.conflict_fix_push_proof",
|
|
||||||
result.get("reasons") or [],
|
|
||||||
field="Conflict-fix push proof",
|
|
||||||
severity="block",
|
|
||||||
safe_next_action=(
|
|
||||||
"state branch head before/after push, reviewer lease status, "
|
|
||||||
"fast-forward status, and whether any reviewer was active"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
if not _BARE_PYTEST_RE.search(text):
|
if not _BARE_PYTEST_RE.search(text):
|
||||||
@@ -692,34 +599,6 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_status_vocabulary(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
action_log: list[dict] | None = None,
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
text = report_text or ""
|
|
||||||
if not re.search(
|
|
||||||
r"validation status|pr-head validation status|official validation status",
|
|
||||||
text,
|
|
||||||
re.IGNORECASE,
|
|
||||||
):
|
|
||||||
return []
|
|
||||||
result = assess_validation_status_vocabulary(
|
|
||||||
text,
|
|
||||||
command_log=action_log,
|
|
||||||
)
|
|
||||||
if not result.get("block"):
|
|
||||||
return []
|
|
||||||
return _findings_from_reasons(
|
|
||||||
"reviewer.validation_status_vocabulary",
|
|
||||||
result.get("reasons") or [],
|
|
||||||
field="Validation status",
|
|
||||||
severity="block",
|
|
||||||
safe_next_action=result.get("safe_next_action")
|
|
||||||
or "use a validation status that matches the proof path executed",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
if "baseline worktree path" not in text.lower():
|
if "baseline worktree path" not in text.lower():
|
||||||
@@ -1040,70 +919,6 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
|
||||||
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
|
||||||
if not report_text.strip():
|
|
||||||
return []
|
|
||||||
findings: list[dict[str, str]] = []
|
|
||||||
has_helper = bool(_WORKFLOW_LOAD_HELPER_RE.search(report_text))
|
|
||||||
has_hash = bool(_WORKFLOW_LOAD_HASH_RE.search(report_text))
|
|
||||||
has_boundary = bool(_WORKFLOW_LOAD_BOUNDARY_RE.search(report_text))
|
|
||||||
has_narrative_only = bool(_WORKFLOW_FILE_VIEW_NARRATIVE_RE.search(report_text))
|
|
||||||
|
|
||||||
if has_narrative_only and not has_helper:
|
|
||||||
findings.append(validator_finding(
|
|
||||||
"reviewer.workflow_load_boundary",
|
|
||||||
"block",
|
|
||||||
"Workflow-load helper result",
|
|
||||||
(
|
|
||||||
"canonical workflow file-view narrative without structured "
|
|
||||||
"gitea_load_review_workflow helper result"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"include Workflow-load helper result with workflow_hash and "
|
|
||||||
"boundary_status from gitea_load_review_workflow"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
return findings
|
|
||||||
|
|
||||||
if has_helper and (not has_hash or not has_boundary):
|
|
||||||
missing = []
|
|
||||||
if not has_hash:
|
|
||||||
missing.append("workflow_hash")
|
|
||||||
if not has_boundary:
|
|
||||||
missing.append("boundary_status")
|
|
||||||
findings.append(validator_finding(
|
|
||||||
"reviewer.workflow_load_boundary",
|
|
||||||
"block",
|
|
||||||
"Workflow-load helper result",
|
|
||||||
(
|
|
||||||
"workflow-load helper result incomplete; missing "
|
|
||||||
+ ", ".join(missing)
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"copy workflow_load_helper_result fields from "
|
|
||||||
"gitea_load_review_workflow into the final report"
|
|
||||||
),
|
|
||||||
))
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
|
|
||||||
from audit_reconciliation_mode import assess_audit_reconciliation_report
|
|
||||||
|
|
||||||
result = assess_audit_reconciliation_report(report_text)
|
|
||||||
if result.get("proven"):
|
|
||||||
return []
|
|
||||||
return _findings_from_reasons(
|
|
||||||
"reconcile.audit_cleanup_boundary",
|
|
||||||
result.get("reasons") or [],
|
|
||||||
field="Audit/cleanup phase",
|
|
||||||
severity="block",
|
|
||||||
safe_next_action=result.get("safe_next_action")
|
|
||||||
or "separate audit from authorized cleanup and classify mutations",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_review_mutation(
|
def _rule_reviewer_review_mutation(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -1123,20 +938,6 @@ def _rule_reviewer_review_mutation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
|
||||||
result = assess_post_merge_cleanup_proof(report_text)
|
|
||||||
if not result.get("block"):
|
|
||||||
return []
|
|
||||||
return _findings_from_reasons(
|
|
||||||
"reviewer.post_merge_cleanup_proof",
|
|
||||||
result.get("reasons") or [],
|
|
||||||
field="Cleanup status",
|
|
||||||
severity="block",
|
|
||||||
safe_next_action=result.get("safe_next_action")
|
|
||||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_SHARED_ISSUE_LOCK_RULES = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
@@ -1154,21 +955,16 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_git_fetch_readonly,
|
_rule_reviewer_git_fetch_readonly,
|
||||||
_rule_reviewer_validation_command,
|
_rule_reviewer_validation_command,
|
||||||
_rule_reviewer_validation_failure_history,
|
_rule_reviewer_validation_failure_history,
|
||||||
_rule_reviewer_validation_cwd_proof,
|
|
||||||
_rule_reviewer_validation_structured,
|
_rule_reviewer_validation_structured,
|
||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
_rule_reviewer_validation_status_vocabulary,
|
|
||||||
_rule_reviewer_main_checkout_baseline,
|
_rule_reviewer_main_checkout_baseline,
|
||||||
_rule_reviewer_main_checkout_path,
|
_rule_reviewer_main_checkout_path,
|
||||||
_rule_reviewer_already_landed_eligible,
|
_rule_reviewer_already_landed_eligible,
|
||||||
_rule_reviewer_already_landed_state,
|
_rule_reviewer_already_landed_state,
|
||||||
_rule_reviewer_target_branch_freshness,
|
_rule_reviewer_target_branch_freshness,
|
||||||
_rule_reviewer_workflow_load_boundary,
|
|
||||||
_rule_reviewer_mutation_ledger,
|
_rule_reviewer_mutation_ledger,
|
||||||
_rule_reviewer_review_mutation,
|
_rule_reviewer_review_mutation,
|
||||||
_rule_reviewer_post_merge_cleanup_proof,
|
|
||||||
_rule_reviewer_stale_head_proof,
|
|
||||||
],
|
],
|
||||||
"reconcile_already_landed": [
|
"reconcile_already_landed": [
|
||||||
_rule_reconcile_controller_handoff,
|
_rule_reconcile_controller_handoff,
|
||||||
@@ -1181,7 +977,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_git_fetch_readonly,
|
_rule_reviewer_git_fetch_readonly,
|
||||||
_rule_reviewer_legacy_workspace_mutations,
|
_rule_reviewer_legacy_workspace_mutations,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
_rule_audit_reconciliation_boundary,
|
|
||||||
],
|
],
|
||||||
"author_issue": [
|
"author_issue": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
@@ -1194,7 +989,6 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_shared_email_disclosure,
|
_rule_shared_email_disclosure,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
_rule_reviewer_vague_mutations_none,
|
_rule_reviewer_vague_mutations_none,
|
||||||
_rule_conflict_fix_push_proof,
|
|
||||||
],
|
],
|
||||||
"issue_filing": [
|
"issue_filing": [
|
||||||
_rule_shared_controller_handoff,
|
_rule_shared_controller_handoff,
|
||||||
|
|||||||
+108
-1269
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
"""Structured issue/branch ownership evidence (#440).
|
||||||
|
|
||||||
|
Author branches must follow ``(fix|feat|docs|chore)/issue-<n>-<desc>``. Duplicate-work
|
||||||
|
and lock-recovery gates use this parser instead of broad substring matching on
|
||||||
|
``issue-<n>`` so unrelated branch names cannot false-positive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ISSUE_BRANCH_RE = re.compile(
|
||||||
|
r"^(?P<prefix>fix|feat|docs|chore)/issue-(?P<num>\d+)(?:-|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_issue_branch(branch_name: str) -> int | None:
|
||||||
|
"""Return the issue number encoded in a canonical author branch, else None."""
|
||||||
|
match = ISSUE_BRANCH_RE.match((branch_name or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return int(match.group("num"))
|
||||||
|
|
||||||
|
|
||||||
|
def branch_belongs_to_issue(branch_name: str, issue_number: int) -> bool:
|
||||||
|
"""True when ``branch_name`` is a canonical branch for ``issue_number``."""
|
||||||
|
parsed = parse_issue_branch(branch_name)
|
||||||
|
return parsed is not None and parsed == issue_number
|
||||||
+5
-160
@@ -1,4 +1,4 @@
|
|||||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#440 / #442 / #443).
|
||||||
|
|
||||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||||
@@ -14,49 +14,12 @@ this module additionally records whether they passed for proof purposes.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import issue_branch_ownership
|
||||||
|
|
||||||
ADOPT = "adopt_existing_branch"
|
ADOPT = "adopt_existing_branch"
|
||||||
BLOCK_COMPETING = "block_competing_branch"
|
BLOCK_COMPETING = "block_competing_branch"
|
||||||
NO_MATCH = "no_matching_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:
|
def _branch_name(entry) -> str:
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
@@ -72,58 +35,24 @@ def _branch_sha(entry) -> str | None:
|
|||||||
return None
|
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(
|
def assess_own_branch_adoption(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
requested_branch: str,
|
requested_branch: str,
|
||||||
existing_branches,
|
existing_branches,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Decide whether an existing matching branch is adoptable.
|
"""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()
|
requested = (requested_branch or "").strip()
|
||||||
|
|
||||||
matches: list[tuple[str, str | None]] = []
|
matches: list[tuple[str, str | None]] = []
|
||||||
for entry in existing_branches or []:
|
for entry in existing_branches or []:
|
||||||
name = _branch_name(entry).strip()
|
name = _branch_name(entry).strip()
|
||||||
if _branch_carries_issue_marker(name, issue_number):
|
if issue_branch_ownership.branch_belongs_to_issue(name, issue_number):
|
||||||
matches.append((name, _branch_sha(entry)))
|
matches.append((name, _branch_sha(entry)))
|
||||||
|
|
||||||
competing = sorted({name for name, _ in matches if name != requested})
|
competing = sorted({name for name, _ in matches if name != requested})
|
||||||
exact = [(name, sha) for name, sha 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:
|
if competing:
|
||||||
return {
|
return {
|
||||||
"outcome": BLOCK_COMPETING,
|
"outcome": BLOCK_COMPETING,
|
||||||
@@ -165,42 +94,6 @@ def assess_own_branch_adoption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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(
|
def build_adoption_proof(
|
||||||
*,
|
*,
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
@@ -211,23 +104,7 @@ def build_adoption_proof(
|
|||||||
lock_file_path: str,
|
lock_file_path: str,
|
||||||
lock_file_status: str,
|
lock_file_status: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
|
"""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 {
|
return {
|
||||||
"issue_number": issue_number,
|
"issue_number": issue_number,
|
||||||
"branch_name": branch_name,
|
"branch_name": branch_name,
|
||||||
@@ -237,36 +114,4 @@ def build_adoption_proof(
|
|||||||
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
||||||
"lock_file_path": lock_file_path,
|
"lock_file_path": lock_file_path,
|
||||||
"lock_file_status": lock_file_status,
|
"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),
|
|
||||||
}
|
}
|
||||||
+14
-241
@@ -1,21 +1,18 @@
|
|||||||
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
|
"""Keyed, persistent issue-lock storage (#443).
|
||||||
|
|
||||||
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
||||||
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
||||||
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
|
``~/.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
|
via a per-process pointer file so concurrent repos/issues never clobber each
|
||||||
other. Acquisition is serialized per issue with ``fcntl.flock``.
|
other.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import errno
|
|
||||||
import fcntl
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -27,10 +24,6 @@ AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
|||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_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:
|
def default_lock_dir() -> str:
|
||||||
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
||||||
return raw or DEFAULT_LOCK_DIR
|
return raw or DEFAULT_LOCK_DIR
|
||||||
@@ -79,41 +72,6 @@ def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
|||||||
return root
|
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:
|
def read_lock_file(path: str) -> dict[str, Any] | None:
|
||||||
lock_path = (path or "").strip()
|
lock_path = (path or "").strip()
|
||||||
if not lock_path or not os.path.exists(lock_path):
|
if not lock_path or not os.path.exists(lock_path):
|
||||||
@@ -168,7 +126,7 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
|||||||
record = dict(lock_data)
|
record = dict(lock_data)
|
||||||
record["lock_file_path"] = path
|
record["lock_file_path"] = path
|
||||||
record["session_pid"] = os.getpid()
|
record["session_pid"] = os.getpid()
|
||||||
record.setdefault("pid", os.getpid())
|
save_lock_file(path, record)
|
||||||
|
|
||||||
pointer = {
|
pointer = {
|
||||||
"pid": os.getpid(),
|
"pid": os.getpid(),
|
||||||
@@ -179,32 +137,7 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
|
|||||||
"org": org,
|
"org": org,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
}
|
}
|
||||||
sentinel = flock_path(path)
|
save_lock_file(session_pointer_path(root), pointer)
|
||||||
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
|
return path
|
||||||
|
|
||||||
|
|
||||||
@@ -308,62 +241,15 @@ def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None
|
|||||||
|
|
||||||
|
|
||||||
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||||
return assess_lock_freshness(lock, now=now)["live"]
|
if not lock:
|
||||||
|
return False
|
||||||
|
lease = lock.get("work_lease")
|
||||||
def assess_lock_freshness(
|
if not isinstance(lease, dict):
|
||||||
lock_data: dict[str, Any] | None,
|
return True
|
||||||
*,
|
expires = _parse_lease_timestamp(lease.get("expires_at"))
|
||||||
now: datetime | None = None,
|
if expires is None:
|
||||||
) -> dict[str, Any]:
|
return True
|
||||||
"""Classify a lock as live, expired, stale, or absent."""
|
return expires > _lease_now(now)
|
||||||
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:
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||||
@@ -496,117 +382,4 @@ def has_active_issue_lock(
|
|||||||
continue
|
continue
|
||||||
if is_lease_live(lock):
|
if is_lease_live(lock):
|
||||||
return True
|
return True
|
||||||
return False
|
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)
|
|
||||||
+5
-26
@@ -30,16 +30,8 @@ def resolve_author_worktree_path(
|
|||||||
return os.path.realpath(os.path.abspath(path))
|
return os.path.realpath(os.path.abspath(path))
|
||||||
|
|
||||||
|
|
||||||
def read_worktree_git_state(
|
def read_worktree_git_state(worktree_path: str) -> dict:
|
||||||
worktree_path: str,
|
"""Read branch name and porcelain status from a git worktree."""
|
||||||
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()
|
path = (worktree_path or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
return {"current_branch": None, "porcelain_status": ""}
|
return {"current_branch": None, "porcelain_status": ""}
|
||||||
@@ -71,7 +63,7 @@ def read_worktree_git_state(
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
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)
|
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
|
||||||
return {
|
return {
|
||||||
"current_branch": current_branch,
|
"current_branch": current_branch,
|
||||||
"porcelain_status": status_res.stdout or "",
|
"porcelain_status": status_res.stdout or "",
|
||||||
@@ -211,26 +203,13 @@ def _assessment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _find_matching_base_ref(
|
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
|
||||||
path: str,
|
"""Return the stable branch ref whose commit matches HEAD, if any."""
|
||||||
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:
|
if not head_sha:
|
||||||
return None, None
|
return None, None
|
||||||
candidates: list[str] = []
|
candidates: list[str] = []
|
||||||
for branch in sorted(BASE_BRANCHES):
|
for branch in sorted(BASE_BRANCHES):
|
||||||
candidates.extend((f"origin/{branch}", branch))
|
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:
|
for ref in candidates:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||||
|
|||||||
@@ -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,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,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,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,
|
|
||||||
}
|
|
||||||
@@ -5115,15 +5115,6 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
|
|||||||
return _assess(report_text or "")
|
return _assess(report_text or "")
|
||||||
|
|
||||||
|
|
||||||
def assess_audit_reconciliation_report(report_text: str | None) -> dict:
|
|
||||||
"""#419: validate audit vs cleanup reconciliation report boundaries."""
|
|
||||||
from audit_reconciliation_mode import (
|
|
||||||
assess_audit_reconciliation_report as _assess,
|
|
||||||
)
|
|
||||||
|
|
||||||
return _assess(report_text or "")
|
|
||||||
|
|
||||||
|
|
||||||
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
|
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
|
||||||
|
|
||||||
_NOT_APPLICABLE_VALUE = re.compile(
|
_NOT_APPLICABLE_VALUE = re.compile(
|
||||||
@@ -5524,13 +5515,6 @@ def assess_validation_failure_history_report(report_text, **kwargs):
|
|||||||
return _assess(report_text, **kwargs)
|
return _assess(report_text, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def assess_validation_cwd_proof_report(report_text, **kwargs):
|
|
||||||
"""#398: validation commands require explicit worktree cwd and HEAD proof."""
|
|
||||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report as _assess
|
|
||||||
|
|
||||||
return _assess(report_text, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
def assess_already_landed_classification_report(report_text, **kwargs):
|
||||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||||
from reviewer_already_landed_classification import (
|
from reviewer_already_landed_classification import (
|
||||||
|
|||||||
@@ -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,217 +0,0 @@
|
|||||||
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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 record_review_workflow_load(
|
|
||||||
project_root: str,
|
|
||||||
*,
|
|
||||||
prompt_text: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Record in-process workflow load proof for the current MCP session."""
|
|
||||||
global _REVIEW_WORKFLOW_LOAD
|
|
||||||
meta = build_canonical_workflow_metadata(
|
|
||||||
project_root, prompt_text=prompt_text)
|
|
||||||
boundary_state = boundary.assess_boundary_status(project_root)
|
|
||||||
_REVIEW_WORKFLOW_LOAD = {
|
|
||||||
**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 []),
|
|
||||||
}
|
|
||||||
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
|
|
||||||
boundary.clear_pre_review_commands()
|
|
||||||
|
|
||||||
|
|
||||||
def workflow_load_status(project_root: str | None = None) -> dict:
|
|
||||||
"""Non-throwing status for capability/runtime reports."""
|
|
||||||
load = _REVIEW_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"),
|
|
||||||
"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]:
|
|
||||||
reasons: list[str] = []
|
|
||||||
if load.get("session_pid") != os.getpid():
|
|
||||||
reasons.append(
|
|
||||||
"workflow load proof was recorded in a different process "
|
|
||||||
"(fail closed)"
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
if boundary_reasons and _REVIEW_WORKFLOW_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,489 +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",
|
|
||||||
})
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
|
||||||
now = now or datetime.now(timezone.utc)
|
|
||||||
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
|
||||||
if phase in _TERMINAL_PHASES:
|
|
||||||
continue
|
|
||||||
if _lease_expired(lease, now=now):
|
|
||||||
continue
|
|
||||||
if phase in _ACTIVE_PHASES or phase:
|
|
||||||
lease = dict(lease)
|
|
||||||
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]) -> dict[str, Any]:
|
|
||||||
global _SESSION_LEASE
|
|
||||||
_SESSION_LEASE = dict(lease)
|
|
||||||
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 before {mutation}"
|
|
||||||
)
|
|
||||||
elif session.get("pr_number") != pr_number:
|
|
||||||
reasons.append(
|
|
||||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
|
||||||
)
|
|
||||||
elif (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,
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -63,14 +63,8 @@ Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
|
|||||||
- Current status:
|
- Current status:
|
||||||
- Safe next action:
|
- Safe next action:
|
||||||
- Safety statement:
|
- Safety statement:
|
||||||
- Workflow-load helper result:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The **Workflow-load helper result** field must carry structured output from
|
|
||||||
`gitea_load_review_workflow` (workflow_hash, final_report_schema_hash,
|
|
||||||
boundary_status). Narrative claims that workflow files were viewed locally are
|
|
||||||
not sufficient (#403).
|
|
||||||
|
|
||||||
### Already-landed handoff overrides
|
### Already-landed handoff overrides
|
||||||
|
|
||||||
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
|
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
|
||||||
|
|||||||
@@ -304,40 +304,6 @@ If any required mutation capability is missing:
|
|||||||
* include safe next action (profile switch, human close, or dedicated reconciler
|
* include safe next action (profile switch, human close, or dedicated reconciler
|
||||||
profile)
|
profile)
|
||||||
|
|
||||||
## 15A. Audit vs cleanup phase (#419)
|
|
||||||
|
|
||||||
Reconciliation audits are **read-only** unless a separate cleanup phase is
|
|
||||||
explicitly authorized.
|
|
||||||
|
|
||||||
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
|
|
||||||
fails closed):
|
|
||||||
|
|
||||||
* ``gitea_delete_branch``
|
|
||||||
* ``git branch -D``
|
|
||||||
* ``git worktree remove``
|
|
||||||
* pushes
|
|
||||||
* issue/PR mutations
|
|
||||||
* file edits
|
|
||||||
|
|
||||||
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
|
|
||||||
``dry_run=True``) stays in audit phase. Execution requires:
|
|
||||||
|
|
||||||
1. Operator approval or workflow authorization
|
|
||||||
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
|
|
||||||
3. Proof branch/worktree is safe to remove
|
|
||||||
4. Before/after state snapshot
|
|
||||||
|
|
||||||
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
|
|
||||||
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
|
|
||||||
Classify cleanup mutations as:
|
|
||||||
|
|
||||||
* remote branch deletion → **External-state mutations**
|
|
||||||
* local branch deletion → **Git ref mutations**
|
|
||||||
* worktree removal → **Cleanup mutations**
|
|
||||||
|
|
||||||
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
|
|
||||||
these boundaries in final reports.
|
|
||||||
|
|
||||||
## 16. Mutation classification
|
## 16. Mutation classification
|
||||||
|
|
||||||
Use precise mutation categories in the final report:
|
Use precise mutation categories in the final report:
|
||||||
|
|||||||
@@ -36,44 +36,6 @@ If available, load it first and report:
|
|||||||
|
|
||||||
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
|
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
|
||||||
|
|
||||||
## 0A. Workflow-load and session boundary anchor (#403)
|
|
||||||
|
|
||||||
The MCP gate is the authority — not local file viewing.
|
|
||||||
|
|
||||||
Before any reviewer mutation:
|
|
||||||
|
|
||||||
1. Record pre-review commands with `gitea_record_pre_review_command` when they
|
|
||||||
are not automatically classified (inventory/diagnostic commands may be
|
|
||||||
recorded explicitly for proof).
|
|
||||||
2. Call `gitea_load_review_workflow` to establish workflow hash proof **and**
|
|
||||||
session boundary state in the same in-process session proof.
|
|
||||||
3. Do not claim the workflow was loaded by reading
|
|
||||||
`skills/llm-project-workflow/workflows/review-merge-pr.md` as a local file;
|
|
||||||
that narrative does not satisfy the validator.
|
|
||||||
|
|
||||||
Allowed before workflow load (classify as `read_only_inventory` or
|
|
||||||
`diagnostic`):
|
|
||||||
|
|
||||||
* `gitea_whoami`, `gitea_resolve_task_capability`, `gitea_list_prs`,
|
|
||||||
`gitea_view_pr`, `gitea_get_runtime_context`
|
|
||||||
* `git fetch` / `git remote update` for inventory
|
|
||||||
* `git status`, `git worktree list` (read-only)
|
|
||||||
|
|
||||||
Boundary violations (block downstream reviewer mutations even after load):
|
|
||||||
|
|
||||||
* validation commands (`pytest`, `python -m unittest`) in the main checkout
|
|
||||||
* local profile/credential/config inspection (`profiles.json`, `gitea-mcp.json`,
|
|
||||||
`.env`, keychain dumps)
|
|
||||||
* MCP repair (`pkill`, MCP config edits)
|
|
||||||
* git mutations before workflow load
|
|
||||||
|
|
||||||
Final reports must include a structured **Workflow-load helper result** block
|
|
||||||
copied from `gitea_load_review_workflow`, including at minimum:
|
|
||||||
|
|
||||||
* `workflow_hash`
|
|
||||||
* `final_report_schema_hash`
|
|
||||||
* `boundary_status` (`clean` or `violation`)
|
|
||||||
|
|
||||||
## 1. Start with live identity, profile, runtime, and capability checks
|
## 1. Start with live identity, profile, runtime, and capability checks
|
||||||
|
|
||||||
Prove:
|
Prove:
|
||||||
@@ -606,28 +568,6 @@ If the cause is unknown, do not erase the earlier failure with plain
|
|||||||
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
||||||
validation failures when `validation_session.observed_failures` is supplied.
|
validation failures when `validation_session.observed_failures` is supplied.
|
||||||
|
|
||||||
## 21B. Validation status taxonomy (#406)
|
|
||||||
|
|
||||||
When the final report summarizes how validation concluded, use one of these
|
|
||||||
**validation status** labels (distinct from per-command pass/fail entries):
|
|
||||||
|
|
||||||
* `passed` — raw PR-head validation passed on the unmodified head.
|
|
||||||
* `failed` — raw PR-head validation failed and no allowed resolution path
|
|
||||||
was proven.
|
|
||||||
* `baseline-equivalent failure accepted` — only when a clean baseline
|
|
||||||
worktree under `branches/` proves matching failure signatures on the target
|
|
||||||
branch (baseline path, target SHA, exact commands, failure lists, and
|
|
||||||
`failure signatures match: true`).
|
|
||||||
* `raw-head failure resolved by merge simulation` — raw PR-head validation
|
|
||||||
failed, but merge simulation into the current target passed cleanly; report
|
|
||||||
merge simulation under `Worktree/index mutations` with full #317 proof.
|
|
||||||
* `passed after transient failure investigation` — a later run passed after an
|
|
||||||
earlier failure in the same session; document the failure history (#396).
|
|
||||||
|
|
||||||
Do not use `baseline-equivalent failure accepted` when only merge simulation
|
|
||||||
resolved the failure. Do not use bare `passed` when raw PR-head validation
|
|
||||||
failed unless one of the resolution statuses above applies.
|
|
||||||
|
|
||||||
## 22. Baseline validation rule
|
## 22. Baseline validation rule
|
||||||
|
|
||||||
Do not run tests in the main checkout.
|
Do not run tests in the main checkout.
|
||||||
@@ -664,28 +604,6 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
|
|||||||
|
|
||||||
## 23. Validation command proof rule
|
## 23. Validation command proof rule
|
||||||
|
|
||||||
Before any diff, test, or compile validation, record in the same command
|
|
||||||
transcript or final report:
|
|
||||||
|
|
||||||
* `pwd` or explicit working directory
|
|
||||||
* `git rev-parse HEAD`
|
|
||||||
* `git status --short --branch`
|
|
||||||
* expected PR head SHA (candidate head SHA)
|
|
||||||
|
|
||||||
Validation commands must use one of:
|
|
||||||
|
|
||||||
* `git -C <review_worktree> ...`
|
|
||||||
* `cd <review_worktree> && ...` in the same command
|
|
||||||
* tool-provided explicit working-directory metadata
|
|
||||||
|
|
||||||
Do not rely on inferred shell cwd from a prior command in a different block.
|
|
||||||
|
|
||||||
`gitea_validate_review_final_report` rejects validation claims without
|
|
||||||
cwd/HEAD proof when `validation_session` is supplied.
|
|
||||||
|
|
||||||
Baseline validation must document baseline worktree path, baseline target SHA,
|
|
||||||
cwd proof, exact baseline command, and baseline result using the same rules.
|
|
||||||
|
|
||||||
Report the exact validation command as executed.
|
Report the exact validation command as executed.
|
||||||
|
|
||||||
Report the working directory where validation ran.
|
Report the working directory where validation ran.
|
||||||
@@ -814,44 +732,6 @@ The final report must identify:
|
|||||||
* whether same-PR merge continuation was allowed
|
* whether same-PR merge continuation was allowed
|
||||||
* whether the run stopped as required
|
* whether the run stopped as required
|
||||||
|
|
||||||
## 26B. Per-PR reviewer lease (#407)
|
|
||||||
|
|
||||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
|
||||||
live PR lease.
|
|
||||||
|
|
||||||
Before validation or review mutation on a selected PR:
|
|
||||||
|
|
||||||
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
|
|
||||||
and target branch SHA.
|
|
||||||
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
|
|
||||||
after validation, before review mutation, and before merge.
|
|
||||||
3. Do not approve, request changes, or merge unless the in-session lease
|
|
||||||
matches the selected PR.
|
|
||||||
|
|
||||||
If PR head or target branch advances during the lease, stop and refresh
|
|
||||||
inventory before continuing.
|
|
||||||
|
|
||||||
Final reports must include lease session id, acquisition proof, heartbeat
|
|
||||||
status, and release/blocked status.
|
|
||||||
|
|
||||||
## 26C. Conflict-fix lease and stale-head protection (#399)
|
|
||||||
|
|
||||||
Before validating, approving, or merging a PR:
|
|
||||||
|
|
||||||
1. Check for an active conflict-fix lease on the PR; stop if one is active.
|
|
||||||
2. Pin `expected_head_sha` before validation and pass it to
|
|
||||||
`gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and
|
|
||||||
`gitea_merge_pr`.
|
|
||||||
3. Re-fetch live PR head immediately before approval and merge; refuse when
|
|
||||||
live head differs from the reviewed SHA.
|
|
||||||
|
|
||||||
Final reports must state:
|
|
||||||
|
|
||||||
* reviewed head SHA
|
|
||||||
* final live head SHA before approval
|
|
||||||
* final live head SHA before merge
|
|
||||||
* whether any push occurred during validation
|
|
||||||
|
|
||||||
## 27. Merge rules
|
## 27. Merge rules
|
||||||
|
|
||||||
Before merge, rerun fresh live checks:
|
Before merge, rerun fresh live checks:
|
||||||
@@ -862,7 +742,6 @@ Before merge, rerun fresh live checks:
|
|||||||
* author safety
|
* author safety
|
||||||
* PR re-fetch
|
* PR re-fetch
|
||||||
* reviewed head SHA unchanged
|
* reviewed head SHA unchanged
|
||||||
* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471)
|
|
||||||
* target branch freshly fetched
|
* target branch freshly fetched
|
||||||
* PR still open
|
* PR still open
|
||||||
* PR still mergeable
|
* PR still mergeable
|
||||||
@@ -879,7 +758,6 @@ Do not merge if:
|
|||||||
* capability state is stale
|
* capability state is stale
|
||||||
* worktree is dirty
|
* worktree is dirty
|
||||||
* PR head changed
|
* PR head changed
|
||||||
* approval is stale (approved SHA ≠ current live head SHA)
|
|
||||||
* validation failed
|
* validation failed
|
||||||
* inventory was incomplete
|
* inventory was incomplete
|
||||||
* PR is already landed
|
* PR is already landed
|
||||||
@@ -919,40 +797,6 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
|||||||
|
|
||||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||||
|
|
||||||
## 28A. Post-merge cleanup proof checklist (#402)
|
|
||||||
|
|
||||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
|
||||||
|
|
||||||
### Remote branch deletion checklist
|
|
||||||
|
|
||||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
|
||||||
|
|
||||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
|
||||||
* Merge result: merged
|
|
||||||
* Merge commit SHA: full 40-character SHA
|
|
||||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
|
||||||
* Branch protection: none / branch is not protected
|
|
||||||
* Open PR inventory proof: no other open PR references the branch
|
|
||||||
* Active heartbeat/claim/lease: none
|
|
||||||
|
|
||||||
### Local worktree removal checklist
|
|
||||||
|
|
||||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
|
||||||
|
|
||||||
* Session-owned worktree path: exact path under `branches/`
|
|
||||||
* Pre-removal tracked state: clean
|
|
||||||
* Pre-removal untracked state: clean
|
|
||||||
* Git worktree list after removal: command output or equivalent proof
|
|
||||||
|
|
||||||
### Skipped cleanup
|
|
||||||
|
|
||||||
If any gate fails, report:
|
|
||||||
|
|
||||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
|
||||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
|
||||||
|
|
||||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
|
||||||
|
|
||||||
## 29. Recovery handoff rules
|
## 29. Recovery handoff rules
|
||||||
|
|
||||||
If blocked, produce a recovery handoff with:
|
If blocked, produce a recovery handoff with:
|
||||||
@@ -1283,7 +1127,6 @@ Controller Handoff:
|
|||||||
* Files reviewed:
|
* Files reviewed:
|
||||||
* Validation:
|
* Validation:
|
||||||
* Validation failure history:
|
* Validation failure history:
|
||||||
* Validation cwd/HEAD proof:
|
|
||||||
* Official validation integrity status:
|
* Official validation integrity status:
|
||||||
* Terminal review mutation:
|
* Terminal review mutation:
|
||||||
* Review decision:
|
* Review decision:
|
||||||
|
|||||||
@@ -144,14 +144,6 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
|
|||||||
|
|
||||||
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
|
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
|
||||||
|
|
||||||
### Stacked PRs (explicit exception, #484)
|
|
||||||
|
|
||||||
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
|
|
||||||
|
|
||||||
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
|
|
||||||
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
|
|
||||||
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
|
|
||||||
|
|
||||||
## 5. No raw MCP repair during normal issue work
|
## 5. No raw MCP repair during normal issue work
|
||||||
|
|
||||||
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
|
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
|
||||||
@@ -615,29 +607,6 @@ After push, report:
|
|||||||
|
|
||||||
If push fails, stop and produce a recovery handoff.
|
If push fails, stop and produce a recovery handoff.
|
||||||
|
|
||||||
## 20A. Conflict-fix lease and push gate (#399)
|
|
||||||
|
|
||||||
When pushing to an existing PR branch to resolve merge conflicts:
|
|
||||||
|
|
||||||
1. Call `gitea_acquire_conflict_fix_lease` before any push.
|
|
||||||
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
|
|
||||||
* branch head before push
|
|
||||||
* branch head after push (local)
|
|
||||||
* session worktree path
|
|
||||||
* push cwd
|
|
||||||
* whether the push is fast-forward
|
|
||||||
3. Do not push when a reviewer holds an active lease on the same PR.
|
|
||||||
4. Do not force-push.
|
|
||||||
5. Do not push from the main checkout or wrong cwd.
|
|
||||||
|
|
||||||
Conflict-fix final reports must state:
|
|
||||||
|
|
||||||
* branch head before push
|
|
||||||
* branch head after push
|
|
||||||
* active reviewer lease status
|
|
||||||
* whether push was fast-forward
|
|
||||||
* whether any reviewer was active
|
|
||||||
|
|
||||||
## 21. PR creation rules
|
## 21. PR creation rules
|
||||||
|
|
||||||
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
|
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
"""Stacked-PR support for author issue locks and PR creation (#484).
|
|
||||||
|
|
||||||
Normal author work locks a worktree that is base-equivalent to ``master``/
|
|
||||||
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
|
|
||||||
PR is deliberately based on another unmerged PR's branch, so its worktree is not
|
|
||||||
master-equivalent and its PR base is not a normal base branch.
|
|
||||||
|
|
||||||
This module holds the pure decision logic that lets:
|
|
||||||
|
|
||||||
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
|
|
||||||
declared and proven to correspond to an open pull request, and
|
|
||||||
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
|
|
||||||
mismatched, or stale (merged/closed) branches.
|
|
||||||
|
|
||||||
The normal master-based path is unchanged: when no stacked base is declared, and
|
|
||||||
when the PR base is a normal base branch, these helpers are inert. Nothing here
|
|
||||||
bypasses the issue lock — a stacked base is recorded *on* the lock and re-checked
|
|
||||||
at PR time.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
|
||||||
|
|
||||||
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
|
|
||||||
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
|
|
||||||
|
|
||||||
|
|
||||||
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
|
|
||||||
"""True when ``base`` is a normal base branch (master/main/dev)."""
|
|
||||||
bases = base_branches or BASE_BRANCHES
|
|
||||||
return (base or "").strip() in bases
|
|
||||||
|
|
||||||
|
|
||||||
def _pr_head_ref(pr: dict) -> str:
|
|
||||||
head = pr.get("head") or {}
|
|
||||||
if isinstance(head, dict):
|
|
||||||
return (head.get("ref") or "").strip()
|
|
||||||
return (str(head) if head else "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
|
|
||||||
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
|
|
||||||
branch = (branch or "").strip()
|
|
||||||
if not branch:
|
|
||||||
return None
|
|
||||||
for pr in open_prs or []:
|
|
||||||
if (pr.get("state") or "").strip().lower() != "open":
|
|
||||||
continue
|
|
||||||
if _pr_head_ref(pr) == branch:
|
|
||||||
return pr
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def assess_stacked_base_declaration(
|
|
||||||
*,
|
|
||||||
stacked_base_branch: str | None,
|
|
||||||
stacked_base_pr: int | None,
|
|
||||||
open_prs: list[dict] | None,
|
|
||||||
) -> dict:
|
|
||||||
"""Validate an explicit stacked-base declaration at lock time.
|
|
||||||
|
|
||||||
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
|
|
||||||
(whether a stacked base was requested), and ``approved`` (the metadata to
|
|
||||||
persist on the lock when valid, else ``None``).
|
|
||||||
"""
|
|
||||||
branch = (stacked_base_branch or "").strip()
|
|
||||||
if not branch:
|
|
||||||
# No stacked base requested — normal master-based lock path.
|
|
||||||
return {"block": False, "reasons": [], "approved": None, "declared": False}
|
|
||||||
|
|
||||||
if branch in BASE_BRANCHES:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"declared": True,
|
|
||||||
"approved": None,
|
|
||||||
"reasons": [
|
|
||||||
f"stacked base '{branch}' is already a normal base branch; do not "
|
|
||||||
"declare a base branch as a stacked base"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if stacked_base_pr is None:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"declared": True,
|
|
||||||
"approved": None,
|
|
||||||
"reasons": [
|
|
||||||
"stacked base branch declared without stacked_base_pr; a stacked PR "
|
|
||||||
"must cite the open PR that owns the base branch"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
pr = find_open_pr_for_branch(open_prs, branch)
|
|
||||||
if pr is None:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"declared": True,
|
|
||||||
"approved": None,
|
|
||||||
"reasons": [
|
|
||||||
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
|
|
||||||
"request; arbitrary or stale branches are not allowed as stacked bases"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if int(pr.get("number")) != int(stacked_base_pr):
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"declared": True,
|
|
||||||
"approved": None,
|
|
||||||
"reasons": [
|
|
||||||
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
|
|
||||||
f"PR #{pr.get('number')} that owns base branch '{branch}'"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"block": False,
|
|
||||||
"declared": True,
|
|
||||||
"reasons": [],
|
|
||||||
"approved": {
|
|
||||||
"branch": branch,
|
|
||||||
"pr_number": int(pr.get("number")),
|
|
||||||
"verified_open": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_stacked_pr_body(
|
|
||||||
body: str | None, *, base_branch: str | None, pr_number: int | None
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
|
|
||||||
text = body or ""
|
|
||||||
low = text.lower()
|
|
||||||
missing: list[str] = []
|
|
||||||
if base_branch and base_branch not in text:
|
|
||||||
missing.append(f"base branch '{base_branch}'")
|
|
||||||
if pr_number is not None and f"#{pr_number}" not in text:
|
|
||||||
missing.append(f"stacked-on PR reference '#{pr_number}'")
|
|
||||||
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
|
|
||||||
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def assess_create_pr_base(
|
|
||||||
*,
|
|
||||||
base: str | None,
|
|
||||||
approved_stacked_base: dict | None,
|
|
||||||
body: str | None,
|
|
||||||
open_prs: list[dict] | None,
|
|
||||||
base_branches: frozenset[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Validate the PR base at create time.
|
|
||||||
|
|
||||||
Normal base branches pass through unchanged (``stacked`` False). A non-base
|
|
||||||
branch is allowed only when it matches the lock's approved stacked base, that
|
|
||||||
base still has an open PR, and the body documents the stack.
|
|
||||||
"""
|
|
||||||
bases = base_branches or BASE_BRANCHES
|
|
||||||
base = (base or "").strip()
|
|
||||||
if base in bases:
|
|
||||||
return {"block": False, "reasons": [], "stacked": False}
|
|
||||||
|
|
||||||
approved = approved_stacked_base or {}
|
|
||||||
approved_branch = (approved.get("branch") or "").strip()
|
|
||||||
if not approved_branch:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"stacked": True,
|
|
||||||
"reasons": [
|
|
||||||
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
|
|
||||||
"issue lock has no approved stacked base; re-lock with an explicit, "
|
|
||||||
"proof-backed stacked base to open a stacked PR"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if base != approved_branch:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"stacked": True,
|
|
||||||
"reasons": [
|
|
||||||
f"PR base '{base}' does not match the issue lock's approved stacked "
|
|
||||||
f"base '{approved_branch}'"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
pr = find_open_pr_for_branch(open_prs, base)
|
|
||||||
if pr is None:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"stacked": True,
|
|
||||||
"reasons": [
|
|
||||||
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
|
|
||||||
"request (dependency merged, closed, or stale); retarget/rebase onto "
|
|
||||||
"master or re-lock against a live base"
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
pr_number = approved.get("pr_number") or pr.get("number")
|
|
||||||
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
|
|
||||||
if missing:
|
|
||||||
return {
|
|
||||||
"block": True,
|
|
||||||
"stacked": True,
|
|
||||||
"reasons": [
|
|
||||||
"stacked PR body must document the stack; missing: "
|
|
||||||
+ ", ".join(missing)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
|
|
||||||
@@ -104,10 +104,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
"reconciliation_cleanup": {
|
|
||||||
"permission": "gitea.branch.delete",
|
|
||||||
"role": "author",
|
|
||||||
},
|
|
||||||
"work_issue": {
|
"work_issue": {
|
||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ class TestIssueLockArtifactWarning(unittest.TestCase):
|
|||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
)
|
)
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server._auth", return_value="token x")
|
@patch("mcp_server._auth", return_value="token x")
|
||||||
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
||||||
@patch("issue_lock_worktree.read_worktree_git_state")
|
@patch("issue_lock_worktree.read_worktree_git_state")
|
||||||
|
|||||||
+7
-50
@@ -157,7 +157,6 @@ class _AuditWiringBase(unittest.TestCase):
|
|||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _env(self, **extra):
|
def _env(self, **extra):
|
||||||
@@ -285,41 +284,8 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
self.assertEqual(result["number"], 9)
|
self.assertEqual(result["number"], 9)
|
||||||
|
|
||||||
|
|
||||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGatedToolAudit(_AuditWiringBase):
|
class TestGatedToolAudit(_AuditWiringBase):
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
from tests.test_mcp_server import _init_reviewer_session, _install_owned_reviewer_lease
|
|
||||||
import reviewer_pr_lease
|
|
||||||
import review_workflow_load
|
|
||||||
|
|
||||||
# Session init clears any prior session lease (#407) and loads workflow (#389).
|
|
||||||
_init_reviewer_session("prgs")
|
|
||||||
self.addCleanup(review_workflow_load.clear_review_workflow_load)
|
|
||||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
|
||||||
self._lease_patch.start()
|
|
||||||
self._auth_identity_patch = patch(
|
|
||||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
|
||||||
)
|
|
||||||
self._auth_identity_patch.start()
|
|
||||||
self._pr_lease_comments_patch = patch(
|
|
||||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
|
||||||
)
|
|
||||||
self._pr_lease_comments_patch.start()
|
|
||||||
self._pr_work_lease_patch = patch(
|
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
|
||||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
|
||||||
)
|
|
||||||
self._pr_work_lease_patch.start()
|
|
||||||
self.addCleanup(self._auth_identity_patch.stop)
|
|
||||||
self.addCleanup(self._lease_patch.stop)
|
|
||||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
|
||||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
|
|
||||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||||
return {"user": {"login": author}, "state": state,
|
return {"user": {"login": author}, "state": state,
|
||||||
"head": {"sha": sha}, "mergeable": mergeable}
|
"head": {"sha": sha}, "mergeable": mergeable}
|
||||||
@@ -332,14 +298,12 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot"),
|
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||||
self._pr("author-bot"),
|
self._pr("author-bot"),
|
||||||
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||||
"dismissed": False}],
|
|
||||||
{}, {"merged_commit_sha": "c1"},
|
{}, {"merged_commit_sha": "c1"},
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||||
expected_head_sha="abc123", remote="prgs")
|
expected_head_sha="abc123", remote="prgs")
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
@@ -359,7 +323,6 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
@@ -371,9 +334,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_submit_review_success_audited(self, _auth, mock_api):
|
def test_submit_review_success_audited(self, _auth, mock_api):
|
||||||
# mark_final_review_decision and submit each run eligibility (user + PR).
|
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||||
{"id": 7, "state": "APPROVED"},
|
{"id": 7, "state": "APPROVED"},
|
||||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
@@ -382,16 +343,12 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
from mcp_server import gitea_mark_final_review_decision
|
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||||
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
r = gitea_submit_pr_review(pr_number=8, action="approve",
|
||||||
)
|
body="LGTM", remote="prgs",
|
||||||
r = gitea_submit_pr_review(
|
final_review_decision_ready=True)
|
||||||
pr_number=8, action="approve",
|
|
||||||
body="LGTM", remote="prgs",
|
|
||||||
final_review_decision_ready=True,
|
|
||||||
)
|
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
self.assertEqual(len(recs), 1)
|
self.assertEqual(len(recs), 1)
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
"""Tests for audit vs cleanup reconciliation mode (#419)."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import audit_reconciliation_mode as arm
|
|
||||||
import mcp_server
|
|
||||||
from audit_reconciliation_mode import (
|
|
||||||
AUDIT_FORBIDDEN_TASKS,
|
|
||||||
PHASE_AUDIT,
|
|
||||||
PHASE_CLEANUP,
|
|
||||||
assess_audit_command_allowed,
|
|
||||||
assess_audit_reconciliation_report,
|
|
||||||
authorize_cleanup_phase,
|
|
||||||
check_audit_mutation_allowed,
|
|
||||||
check_cleanup_execution_allowed,
|
|
||||||
classify_cleanup_mutation,
|
|
||||||
clear_phase,
|
|
||||||
enter_audit_phase,
|
|
||||||
)
|
|
||||||
from final_report_validator import assess_final_report_validator
|
|
||||||
from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
|
||||||
from task_capability_map import required_permission, required_role
|
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
|
||||||
"profile_name": "prgs-author-delete",
|
|
||||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
|
||||||
"forbidden_operations": [],
|
|
||||||
"audit_label": "prgs-author-delete",
|
|
||||||
}
|
|
||||||
|
|
||||||
READ_PROFILE = {
|
|
||||||
"profile_name": "prgs-author",
|
|
||||||
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
|
||||||
"forbidden_operations": ["gitea.branch.delete"],
|
|
||||||
"audit_label": "prgs-author",
|
|
||||||
}
|
|
||||||
|
|
||||||
READ_ENV = {
|
|
||||||
"GITEA_MCP_CONFIG": os.path.join(
|
|
||||||
os.path.dirname(__file__), "..", "profiles.json"
|
|
||||||
),
|
|
||||||
"GITEA_MCP_PROFILE": "prgs-author",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize_cleanup(**kwargs):
|
|
||||||
defaults = {
|
|
||||||
"operator_approved": True,
|
|
||||||
"delete_capability_proven": True,
|
|
||||||
"safety_proof": {"safe_to_delete_remote": True},
|
|
||||||
"before_after_snapshot": {
|
|
||||||
"before": "remote branch exists",
|
|
||||||
"after": "remote branch absent",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return authorize_cleanup_phase(**defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_report(**overrides):
|
|
||||||
base = (
|
|
||||||
"Task mode: reconcile-landed-pr\n"
|
|
||||||
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
|
||||||
"Audit phase: read-only assessment\n"
|
|
||||||
"Cleanup phase authorized: true\n"
|
|
||||||
"Delete-branch capability proven: true\n"
|
|
||||||
"Branch safe to remove: true\n"
|
|
||||||
"Before/after state snapshot: remote branch feat/x present → absent\n"
|
|
||||||
"External-state mutations: deleted remote branch feat/x\n"
|
|
||||||
"Git ref mutations: none\n"
|
|
||||||
"Cleanup mutations: removed worktree branches/feat-x\n"
|
|
||||||
)
|
|
||||||
for key, value in overrides.items():
|
|
||||||
base = base.replace(key, value)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuditPhaseGates(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
clear_phase()
|
|
||||||
enter_audit_phase("reconcile-landed-pr")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
clear_phase()
|
|
||||||
|
|
||||||
def test_audit_blocks_delete_branch_task(self):
|
|
||||||
allowed, reasons = check_audit_mutation_allowed("delete_branch")
|
|
||||||
self.assertFalse(allowed)
|
|
||||||
self.assertTrue(reasons)
|
|
||||||
|
|
||||||
def test_audit_blocks_worktree_shell_commands(self):
|
|
||||||
allowed, reasons = assess_audit_command_allowed(
|
|
||||||
"git worktree remove branches/feat-x"
|
|
||||||
)
|
|
||||||
self.assertFalse(allowed)
|
|
||||||
self.assertTrue(reasons)
|
|
||||||
|
|
||||||
def test_audit_blocks_local_branch_delete_command(self):
|
|
||||||
allowed, reasons = assess_audit_command_allowed("git branch -D feat/x")
|
|
||||||
self.assertFalse(allowed)
|
|
||||||
|
|
||||||
def test_audit_allows_read_tasks(self):
|
|
||||||
allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr")
|
|
||||||
self.assertTrue(allowed)
|
|
||||||
|
|
||||||
def test_forbidden_set_covers_issue_and_pr_mutations(self):
|
|
||||||
for task in ("close_pr", "comment_issue", "commit_files", "push_branch"):
|
|
||||||
self.assertIn(task, AUDIT_FORBIDDEN_TASKS)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupAuthorization(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
clear_phase()
|
|
||||||
enter_audit_phase("reconcile_merged_cleanups")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
clear_phase()
|
|
||||||
|
|
||||||
def test_cleanup_without_approval_blocked(self):
|
|
||||||
result = authorize_cleanup_phase(
|
|
||||||
delete_capability_proven=True,
|
|
||||||
safety_proof={"safe_to_delete_remote": True},
|
|
||||||
before_after_snapshot={"before": "a", "after": "b"},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["authorized"])
|
|
||||||
|
|
||||||
def test_cleanup_without_capability_proof_blocked(self):
|
|
||||||
result = _authorize_cleanup(delete_capability_proven=False)
|
|
||||||
self.assertFalse(result["authorized"])
|
|
||||||
|
|
||||||
def test_cleanup_without_snapshot_blocked(self):
|
|
||||||
result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""})
|
|
||||||
self.assertFalse(result["authorized"])
|
|
||||||
|
|
||||||
def test_authorized_cleanup_switches_phase(self):
|
|
||||||
result = _authorize_cleanup()
|
|
||||||
self.assertTrue(result["authorized"])
|
|
||||||
self.assertEqual(result["phase"], PHASE_CLEANUP)
|
|
||||||
|
|
||||||
def test_cleanup_execution_allowed_only_after_authorization(self):
|
|
||||||
self.assertFalse(check_cleanup_execution_allowed()[0])
|
|
||||||
_authorize_cleanup()
|
|
||||||
self.assertTrue(check_cleanup_execution_allowed()[0])
|
|
||||||
|
|
||||||
|
|
||||||
class TestReportVerifier(unittest.TestCase):
|
|
||||||
def test_false_no_mutations_after_cleanup_blocked(self):
|
|
||||||
report = (
|
|
||||||
"Task mode: reconcile-landed-pr\n"
|
|
||||||
"No mutations performed.\n"
|
|
||||||
"delete_remote_branch feat/dup\n"
|
|
||||||
)
|
|
||||||
result = assess_audit_reconciliation_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertIn("no mutations", result["reasons"][0].lower())
|
|
||||||
|
|
||||||
def test_cleanup_without_authorization_fields_blocked(self):
|
|
||||||
report = (
|
|
||||||
"Task mode: reconcile-landed-pr\n"
|
|
||||||
"remove_local_worktree branches/feat-x\n"
|
|
||||||
)
|
|
||||||
result = assess_audit_reconciliation_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_authorized_cleanup_report_passes(self):
|
|
||||||
result = assess_audit_reconciliation_report(_cleanup_report())
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
def test_mutation_classification_enforced(self):
|
|
||||||
report = (
|
|
||||||
"Task mode: reconcile-landed-pr\n"
|
|
||||||
"Cleanup phase authorized: true\n"
|
|
||||||
"Delete-branch capability proven: true\n"
|
|
||||||
"Branch safe to remove: true\n"
|
|
||||||
"Before/after state snapshot: present\n"
|
|
||||||
"remove_local_worktree branches/feat-x\n"
|
|
||||||
)
|
|
||||||
result = assess_audit_reconciliation_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_proofs_export_matches_module(self):
|
|
||||||
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
|
||||||
self.assertEqual(
|
|
||||||
proofs_assess(report)["proven"],
|
|
||||||
assess_audit_reconciliation_report(report)["proven"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_final_report_validator_includes_boundary_rule(self):
|
|
||||||
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
report_text=report,
|
|
||||||
task_kind="reconcile_already_landed",
|
|
||||||
)
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reconcile.audit_cleanup_boundary", rule_ids)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMutationClassification(unittest.TestCase):
|
|
||||||
def test_remote_delete_is_external_state(self):
|
|
||||||
self.assertEqual(
|
|
||||||
classify_cleanup_mutation("delete_remote_branch"),
|
|
||||||
"external-state",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_worktree_remove_is_cleanup(self):
|
|
||||||
self.assertEqual(
|
|
||||||
classify_cleanup_mutation("remove_local_worktree"),
|
|
||||||
"cleanup",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpGates(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
clear_phase()
|
|
||||||
enter_audit_phase("reconcile_merged_cleanups")
|
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
|
||||||
self.mock_auth = patch(
|
|
||||||
"mcp_server.get_auth_header", return_value="token test"
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
clear_phase()
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
|
|
||||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
||||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
|
||||||
self.assertFalse(result["success"])
|
|
||||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
|
||||||
self.mock_api.assert_not_called()
|
|
||||||
|
|
||||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
||||||
def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
mcp_server.gitea_reconcile_merged_cleanups(
|
|
||||||
dry_run=False,
|
|
||||||
execute_confirmed=False,
|
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
||||||
@patch("mcp_server.get_profile", return_value=READ_PROFILE)
|
|
||||||
def test_cleanup_auth_fails_without_delete_capability(self, _profile):
|
|
||||||
result = mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
|
||||||
operator_approved=True,
|
|
||||||
delete_capability_proven=True,
|
|
||||||
safe_to_delete_remote=True,
|
|
||||||
before_state="exists",
|
|
||||||
after_state="gone",
|
|
||||||
)
|
|
||||||
self.assertFalse(result["authorized"])
|
|
||||||
self.assertFalse(result["delete_capability_verified"])
|
|
||||||
|
|
||||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
|
||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
|
||||||
def test_delete_branch_allowed_after_cleanup_authorization(self, _profile):
|
|
||||||
mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
|
||||||
operator_approved=True,
|
|
||||||
delete_capability_proven=True,
|
|
||||||
safe_to_delete_remote=True,
|
|
||||||
before_state="exists",
|
|
||||||
after_state="gone",
|
|
||||||
)
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
self.mock_api.return_value = {}
|
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestTaskCapabilityMap(unittest.TestCase):
|
|
||||||
def test_reconciliation_cleanup_maps_delete_permission(self):
|
|
||||||
self.assertEqual(
|
|
||||||
required_permission("reconciliation_cleanup"),
|
|
||||||
"gitea.branch.delete",
|
|
||||||
)
|
|
||||||
self.assertEqual(required_role("reconciliation_cleanup"), "author")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -106,32 +106,20 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
|||||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||||
@patch("os.path.exists", return_value=True)
|
@patch("os.path.exists", return_value=True)
|
||||||
@patch("os.path.isdir", return_value=True)
|
@patch("os.path.isdir", return_value=True)
|
||||||
@patch("author_mutation_worktree.subprocess.run")
|
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_amw_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||||
|
# Mock subprocess.run for git --git-common-dir to return a different path
|
||||||
|
mock_res = MagicMock()
|
||||||
|
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
|
||||||
|
mock_run.return_value = mock_res
|
||||||
|
|
||||||
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
|
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
|
||||||
|
|
||||||
def _subprocess_side_effect(cmd, *args, **kwargs):
|
|
||||||
mock_res = MagicMock(returncode=0)
|
|
||||||
if "--git-common-dir" in cmd:
|
|
||||||
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
|
||||||
if cwd == wrong_repo_path:
|
|
||||||
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
|
|
||||||
else:
|
|
||||||
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
|
||||||
else:
|
|
||||||
mock_res.stdout = ""
|
|
||||||
return mock_res
|
|
||||||
|
|
||||||
mock_run.side_effect = _subprocess_side_effect
|
|
||||||
mock_amw_run.side_effect = _subprocess_side_effect
|
|
||||||
|
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
srv.gitea_create_issue(
|
||||||
srv.gitea_create_issue(
|
title="Test issue", body="body", worktree_path=wrong_repo_path
|
||||||
title="Test issue", body="body", worktree_path=wrong_repo_path
|
)
|
||||||
)
|
|
||||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ def _review_handoff(**overrides):
|
|||||||
"- Selected PR: #203",
|
"- Selected PR: #203",
|
||||||
"- Reviewer eligibility: eligible",
|
"- Reviewer eligibility: eligible",
|
||||||
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Push occurred during validation: no",
|
|
||||||
"- Worktree path: branches/review-203",
|
"- Worktree path: branches/review-203",
|
||||||
"- Worktree dirty: clean",
|
"- Worktree dirty: clean",
|
||||||
"- Scratch worktree used: yes (branches/review-203)",
|
"- Scratch worktree used: yes (branches/review-203)",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Structured issue branch ownership (#440)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_branch_ownership as ibo # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueBranchOwnership(unittest.TestCase):
|
||||||
|
def test_canonical_branch_parses_issue_number(self):
|
||||||
|
self.assertEqual(
|
||||||
|
ibo.parse_issue_branch("feat/issue-420-server-code-parity"),
|
||||||
|
420,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prefix_variants_supported(self):
|
||||||
|
for prefix in ("fix", "feat", "docs", "chore"):
|
||||||
|
branch = f"{prefix}/issue-440-lock-recovery"
|
||||||
|
self.assertTrue(ibo.branch_belongs_to_issue(branch, 440))
|
||||||
|
|
||||||
|
def test_substring_false_positive_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
ibo.branch_belongs_to_issue("feat/my-issue-420-backport", 420)
|
||||||
|
)
|
||||||
|
self.assertFalse(ibo.branch_belongs_to_issue("release/issue-420-hotfix", 420))
|
||||||
|
|
||||||
|
def test_different_issue_number_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
ibo.branch_belongs_to_issue("feat/issue-421-server-code-parity", 420)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -11,7 +11,6 @@ from issue_lock_adoption import ( # noqa: E402
|
|||||||
NO_MATCH,
|
NO_MATCH,
|
||||||
assess_own_branch_adoption,
|
assess_own_branch_adoption,
|
||||||
build_adoption_proof,
|
build_adoption_proof,
|
||||||
build_non_adoption_lock_proof,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
REQ = "feat/issue-420-server-code-parity"
|
REQ = "feat/issue-420-server-code-parity"
|
||||||
@@ -26,16 +25,6 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["outcome"], ADOPT)
|
self.assertEqual(result["outcome"], ADOPT)
|
||||||
self.assertTrue(result["adopt"])
|
self.assertTrue(result["adopt"])
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertEqual(result["matched_branch"], REQ)
|
|
||||||
self.assertEqual(result["matched_head_sha"], "934688a")
|
|
||||||
|
|
||||||
def test_exact_own_branch_adopted_when_sha_missing(self):
|
|
||||||
result = assess_own_branch_adoption(
|
|
||||||
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
|
|
||||||
)
|
|
||||||
self.assertEqual(result["outcome"], ADOPT)
|
|
||||||
self.assertIsNone(result["matched_head_sha"])
|
|
||||||
|
|
||||||
def test_different_branch_same_issue_blocks(self):
|
def test_different_branch_same_issue_blocks(self):
|
||||||
result = assess_own_branch_adoption(
|
result = assess_own_branch_adoption(
|
||||||
@@ -45,20 +34,6 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
||||||
self.assertTrue(result["block"])
|
self.assertTrue(result["block"])
|
||||||
self.assertFalse(result["adopt"])
|
|
||||||
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
|
|
||||||
self.assertIn("fail closed", result["reason"])
|
|
||||||
|
|
||||||
def test_own_branch_plus_competing_branch_blocks(self):
|
|
||||||
# Ambiguous ownership: fail closed even though the exact branch exists.
|
|
||||||
result = assess_own_branch_adoption(
|
|
||||||
issue_number=420,
|
|
||||||
requested_branch=REQ,
|
|
||||||
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
|
|
||||||
)
|
|
||||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
|
|
||||||
|
|
||||||
def test_no_matching_branch_is_normal_path(self):
|
def test_no_matching_branch_is_normal_path(self):
|
||||||
result = assess_own_branch_adoption(
|
result = assess_own_branch_adoption(
|
||||||
@@ -67,44 +42,19 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
|
|||||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
||||||
)
|
)
|
||||||
self.assertEqual(result["outcome"], NO_MATCH)
|
self.assertEqual(result["outcome"], NO_MATCH)
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertFalse(result["adopt"])
|
|
||||||
|
|
||||||
def test_empty_branch_list_is_normal_path(self):
|
def test_substring_only_branch_name_is_ignored(self):
|
||||||
result = assess_own_branch_adoption(
|
result = assess_own_branch_adoption(
|
||||||
issue_number=420, requested_branch=REQ, existing_branches=[]
|
issue_number=420,
|
||||||
)
|
requested_branch=REQ,
|
||||||
self.assertEqual(result["outcome"], NO_MATCH)
|
existing_branches=[{"name": "feat/my-issue-420-backport"}],
|
||||||
|
|
||||||
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
|
|
||||||
# issue-420 must not be treated as competing work for issue #42.
|
|
||||||
own_branch = "feat/issue-42-widget"
|
|
||||||
result = assess_own_branch_adoption(
|
|
||||||
issue_number=42,
|
|
||||||
requested_branch=own_branch,
|
|
||||||
existing_branches=[
|
|
||||||
{"name": own_branch, "commit_sha": "abc1234"},
|
|
||||||
{"name": "feat/issue-420-server-code-parity"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.assertEqual(result["outcome"], ADOPT)
|
|
||||||
self.assertTrue(result["adopt"])
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertEqual(result["matched_branch"], own_branch)
|
|
||||||
|
|
||||||
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
|
|
||||||
result = assess_own_branch_adoption(
|
|
||||||
issue_number=42,
|
|
||||||
requested_branch="feat/issue-42-thing",
|
|
||||||
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
|
|
||||||
)
|
)
|
||||||
self.assertEqual(result["outcome"], NO_MATCH)
|
self.assertEqual(result["outcome"], NO_MATCH)
|
||||||
self.assertFalse(result["block"])
|
self.assertFalse(result["block"])
|
||||||
self.assertFalse(result["adopt"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildAdoptionProof(unittest.TestCase):
|
class TestBuildAdoptionProof(unittest.TestCase):
|
||||||
def test_proof_has_all_required_fields(self):
|
def test_proof_has_required_fields(self):
|
||||||
assessment = assess_own_branch_adoption(
|
assessment = assess_own_branch_adoption(
|
||||||
issue_number=420,
|
issue_number=420,
|
||||||
requested_branch=REQ,
|
requested_branch=REQ,
|
||||||
@@ -119,108 +69,8 @@ class TestBuildAdoptionProof(unittest.TestCase):
|
|||||||
lock_file_path="/tmp/example-lock.json",
|
lock_file_path="/tmp/example-lock.json",
|
||||||
lock_file_status="written",
|
lock_file_status="written",
|
||||||
)
|
)
|
||||||
for key in (
|
|
||||||
"issue_number",
|
|
||||||
"branch_name",
|
|
||||||
"branch_head_commit",
|
|
||||||
"adoption_reason",
|
|
||||||
"no_existing_pr_proof",
|
|
||||||
"no_competing_live_lock_proof",
|
|
||||||
"lock_file_path",
|
|
||||||
"lock_file_status",
|
|
||||||
):
|
|
||||||
self.assertIn(key, proof)
|
|
||||||
self.assertEqual(proof["branch_head_commit"], "934688a")
|
self.assertEqual(proof["branch_head_commit"], "934688a")
|
||||||
self.assertTrue(proof["no_existing_pr_proof"])
|
self.assertTrue(proof["no_existing_pr_proof"])
|
||||||
self.assertTrue(proof["no_competing_live_lock_proof"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestExplicitAdoptionProofFields(unittest.TestCase):
|
|
||||||
"""#477: explicit, citable adoption-proof fields for all outcomes."""
|
|
||||||
|
|
||||||
def _proof(self, assessment, branch):
|
|
||||||
return build_adoption_proof(
|
|
||||||
issue_number=420,
|
|
||||||
branch_name=branch,
|
|
||||||
assessment=assessment,
|
|
||||||
open_pr_checked=True,
|
|
||||||
competing_lock_checked=True,
|
|
||||||
lock_file_path="/tmp/example-lock.json",
|
|
||||||
lock_file_status="written",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_adopt_proof_exposes_explicit_fields(self):
|
|
||||||
assessment = assess_own_branch_adoption(
|
|
||||||
issue_number=420,
|
|
||||||
requested_branch=REQ,
|
|
||||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
|
||||||
)
|
|
||||||
proof = self._proof(assessment, REQ)
|
|
||||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
|
||||||
self.assertTrue(proof["adopted"])
|
|
||||||
self.assertEqual(proof["adopted_branch"], REQ)
|
|
||||||
self.assertEqual(proof["adopted_branch_head"], "934688a")
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
|
||||||
self.assertIn("gitea_create_pr", proof["safe_next_action"])
|
|
||||||
self.assertIn("exactly matches", proof["matcher_summary"])
|
|
||||||
|
|
||||||
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
|
|
||||||
assessment = assess_own_branch_adoption(
|
|
||||||
issue_number=420,
|
|
||||||
requested_branch=REQ,
|
|
||||||
existing_branches=[{"name": "feat/issue-420-rogue"}],
|
|
||||||
)
|
|
||||||
proof = self._proof(assessment, REQ)
|
|
||||||
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
|
|
||||||
self.assertFalse(proof["adopted"])
|
|
||||||
self.assertIsNone(proof["adopted_branch"])
|
|
||||||
self.assertIsNone(proof["adopted_branch_head"])
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
|
|
||||||
self.assertIn(
|
|
||||||
"feat/issue-420-rogue",
|
|
||||||
proof["competing_branch_check"]["competing_branches"],
|
|
||||||
)
|
|
||||||
self.assertIn("fail closed", proof["safe_next_action"])
|
|
||||||
|
|
||||||
def test_no_match_proof_does_not_claim_adoption(self):
|
|
||||||
assessment = assess_own_branch_adoption(
|
|
||||||
issue_number=420,
|
|
||||||
requested_branch=REQ,
|
|
||||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
|
||||||
)
|
|
||||||
proof = self._proof(assessment, REQ)
|
|
||||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
|
||||||
self.assertFalse(proof["adopted"])
|
|
||||||
self.assertIsNone(proof["adopted_branch"])
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
|
||||||
|
|
||||||
def test_substring_collision_stays_boundary_safe(self):
|
|
||||||
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
|
|
||||||
own = "feat/issue-42-widget"
|
|
||||||
assessment = assess_own_branch_adoption(
|
|
||||||
issue_number=42,
|
|
||||||
requested_branch=own,
|
|
||||||
existing_branches=[
|
|
||||||
{"name": own, "commit_sha": "abc1234"},
|
|
||||||
{"name": "feat/issue-420-server-code-parity"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
proof = self._proof(assessment, own)
|
|
||||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
|
||||||
self.assertEqual(proof["adopted_branch"], own)
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
|
||||||
|
|
||||||
def test_non_adoption_lock_proof_is_adoption_free(self):
|
|
||||||
proof = build_non_adoption_lock_proof(
|
|
||||||
issue_number=196, branch_name="feat/issue-196-mutations"
|
|
||||||
)
|
|
||||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
|
||||||
self.assertFalse(proof["adopted"])
|
|
||||||
self.assertIsNone(proof["adopted_branch"])
|
|
||||||
self.assertIsNone(proof["adopted_branch_head"])
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
|
||||||
self.assertIn("no adoption", proof["safe_next_action"].lower())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""End-to-end lock recovery scenarios for issue #440."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_adoption # noqa: E402
|
||||||
|
import issue_lock_store as ils # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from mcp_server import gitea_create_pr, gitea_lock_issue # noqa: E402
|
||||||
|
|
||||||
|
PRGS_REPO = mcp_server.REMOTES["prgs"]["repo"]
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
BRANCH = "feat/issue-420-server-code-parity"
|
||||||
|
ISSUE_WRITE_ENV = {
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
CREATE_PR_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.create,gitea.branch.push",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_master_git_state_for_lock():
|
||||||
|
return {
|
||||||
|
"current_branch": "master",
|
||||||
|
"porcelain_status": "",
|
||||||
|
"base_equivalent": True,
|
||||||
|
"inspected_git_root": "/tmp/repo",
|
||||||
|
"base_branch": "master",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_record(**overrides):
|
||||||
|
import issue_lock_provenance
|
||||||
|
|
||||||
|
work_lease = {
|
||||||
|
"operation_type": "author_issue_work",
|
||||||
|
"expires_at": "2999-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
record = {
|
||||||
|
"issue_number": 420,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": PRGS_REPO,
|
||||||
|
"worktree_path": os.path.realpath(os.getcwd()),
|
||||||
|
"work_lease": work_lease,
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue",
|
||||||
|
claimant=work_lease.get("claimant"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
record.update(overrides)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueLockRecovery(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmpdir.cleanup)
|
||||||
|
self.lock_dir = self._tmpdir.name
|
||||||
|
|
||||||
|
def test_substring_branch_does_not_trigger_adoption(self):
|
||||||
|
result = issue_lock_adoption.assess_own_branch_adoption(
|
||||||
|
issue_number=420,
|
||||||
|
requested_branch=BRANCH,
|
||||||
|
existing_branches=[{"name": "feat/my-issue-420-backport"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_adoption.NO_MATCH)
|
||||||
|
|
||||||
|
def test_competing_actor_branch_blocks_adoption(self):
|
||||||
|
result = issue_lock_adoption.assess_own_branch_adoption(
|
||||||
|
issue_number=420,
|
||||||
|
requested_branch=BRANCH,
|
||||||
|
existing_branches=[{"name": "feat/issue-420-other-work"}],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
|
)
|
||||||
|
def test_lock_recovery_after_restart_adopts_own_branch(self, _dup_fetcher, _auth, mock_api, _git):
|
||||||
|
mock_api.return_value = [{"name": BRANCH, "commit": {"id": "934688a"}}]
|
||||||
|
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with patch("os.getpid", return_value=9999):
|
||||||
|
res = gitea_lock_issue(
|
||||||
|
issue_number=420,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertIn("adoption", res)
|
||||||
|
found = ils.find_lock_for_branch(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo=PRGS_REPO,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
self.assertEqual(found["branch_name"], BRANCH)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_create_pr_resolves_durable_lock_after_session_loss(self, _auth, _role, mock_api):
|
||||||
|
mock_api.return_value = {"number": 421, "html_url": "https://example/pr/421"}
|
||||||
|
worktree = os.path.realpath(os.getcwd())
|
||||||
|
path = ils.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo=PRGS_REPO,
|
||||||
|
issue_number=420,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
ils.save_lock_file(path, _lock_record(worktree_path=worktree))
|
||||||
|
|
||||||
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with patch("os.getpid", return_value=8888):
|
||||||
|
self.assertIsNone(ils.read_session_issue_lock(lock_dir=self.lock_dir))
|
||||||
|
res = gitea_create_pr(
|
||||||
|
title=f"feat: server parity Closes #420",
|
||||||
|
head=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=worktree,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["number"], 421)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
return_value=([{
|
||||||
|
"number": 99,
|
||||||
|
"head": {"ref": BRANCH},
|
||||||
|
"title": "WIP",
|
||||||
|
"body": "",
|
||||||
|
}], [], {"status": "not_claimed"}),
|
||||||
|
)
|
||||||
|
def test_open_pr_blocks_recovery_lock(self, _dup_fetcher, _auth, mock_api, _git):
|
||||||
|
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
gitea_lock_issue(issue_number=420, branch_name=BRANCH, remote="prgs")
|
||||||
|
self.assertIn("open PR #99 already covers issue", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
|
"""Unit tests for keyed issue-lock storage (#443)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -157,30 +156,6 @@ class TestIssueLockStore(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
|
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
|
||||||
|
|
||||||
def test_approved_stacked_base_survives_round_trip(self):
|
|
||||||
# #484: the approved stacked base recorded on the lock must persist so
|
|
||||||
# gitea_create_pr can validate the non-master base at PR time.
|
|
||||||
record = _lock_record(
|
|
||||||
issue_number=482,
|
|
||||||
branch_name="feat/issue-482-skip-stale-request-changes-pr",
|
|
||||||
approved_stacked_base={
|
|
||||||
"branch": "feat/issue-478-mcp-menu-shell",
|
|
||||||
"pr_number": 479,
|
|
||||||
"verified_open": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
path = ils.lock_file_path(
|
|
||||||
remote="prgs",
|
|
||||||
org="Scaled-Tech-Consulting",
|
|
||||||
repo="Gitea-Tools",
|
|
||||||
issue_number=482,
|
|
||||||
)
|
|
||||||
ils.save_lock_file(path, record)
|
|
||||||
stored = ils.read_lock_file(path)
|
|
||||||
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
|
|
||||||
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
|
|
||||||
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
|
|
||||||
|
|
||||||
def test_atomic_write_preserves_unrelated_lock(self):
|
def test_atomic_write_preserves_unrelated_lock(self):
|
||||||
path_a = ils.lock_file_path(
|
path_a = ils.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
@@ -201,80 +176,6 @@ class TestIssueLockStore(unittest.TestCase):
|
|||||||
self.assertTrue(os.path.exists(path_b))
|
self.assertTrue(os.path.exists(path_b))
|
||||||
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
|
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
|
||||||
|
|
||||||
def test_concurrent_bind_same_issue_only_one_wins(self):
|
|
||||||
barrier = threading.Barrier(2)
|
|
||||||
results: list[str | Exception] = []
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
barrier.wait()
|
|
||||||
try:
|
|
||||||
ils.bind_session_lock(
|
|
||||||
_lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}")
|
|
||||||
)
|
|
||||||
results.append("ok")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
results.append(exc)
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
|
||||||
for thread in threads:
|
|
||||||
thread.start()
|
|
||||||
for thread in threads:
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
successes = [item for item in results if item == "ok"]
|
|
||||||
failures = [item for item in results if isinstance(item, Exception)]
|
|
||||||
self.assertEqual(len(successes), 1)
|
|
||||||
self.assertEqual(len(failures), 1)
|
|
||||||
failure_text = str(failures[0]).lower()
|
|
||||||
self.assertTrue(
|
|
||||||
"active" in failure_text or "lock contention" in failure_text,
|
|
||||||
failures[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_verify_lock_for_mutation_blocks_stale_lock(self):
|
|
||||||
record = _lock_record(
|
|
||||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
|
||||||
)
|
|
||||||
record["pid"] = 999999
|
|
||||||
record["session_pid"] = 999999
|
|
||||||
result = ils.verify_lock_for_mutation(
|
|
||||||
record,
|
|
||||||
issue_number=420,
|
|
||||||
branch_name="feat/issue-420-server-code-parity",
|
|
||||||
worktree_path="/tmp/wt-420",
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertIn("not live", result["reasons"][0])
|
|
||||||
|
|
||||||
def test_list_live_locks_excludes_stale_records(self):
|
|
||||||
live_path = ils.lock_file_path(
|
|
||||||
remote="prgs",
|
|
||||||
org="Scaled-Tech-Consulting",
|
|
||||||
repo="Gitea-Tools",
|
|
||||||
issue_number=420,
|
|
||||||
)
|
|
||||||
ils.save_lock_file(
|
|
||||||
live_path,
|
|
||||||
_lock_record(worktree_path="/tmp/wt-420"),
|
|
||||||
)
|
|
||||||
stale_path = ils.lock_file_path(
|
|
||||||
remote="prgs",
|
|
||||||
org="Scaled-Tech-Consulting",
|
|
||||||
repo="Gitea-Tools",
|
|
||||||
issue_number=440,
|
|
||||||
)
|
|
||||||
ils.save_lock_file(
|
|
||||||
stale_path,
|
|
||||||
_lock_record(
|
|
||||||
issue_number=440,
|
|
||||||
branch_name="feat/issue-440-recovery",
|
|
||||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
|
||||||
worktree_path="/tmp/wt-440",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
live = ils.list_live_locks(lock_dir=self.lock_dir)
|
|
||||||
self.assertEqual([entry["issue_number"] for entry in live], [420])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -72,59 +72,6 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
|
|||||||
self.assertTrue(result["proven"])
|
self.assertTrue(result["proven"])
|
||||||
|
|
||||||
|
|
||||||
class TestStackedBaseEquivalence(unittest.TestCase):
|
|
||||||
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
|
|
||||||
|
|
||||||
def _git(self, *args):
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
["git", "-C", self.repo, *args],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.repo = self.tmp.name
|
|
||||||
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
|
|
||||||
self._git("config", "user.email", "t@t")
|
|
||||||
self._git("config", "user.name", "t")
|
|
||||||
self._git("commit", "--allow-empty", "-q", "-m", "base")
|
|
||||||
# Rename the default branch away from master/main/dev so no *base* branch
|
|
||||||
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
|
|
||||||
self._git("branch", "-m", "trunk")
|
|
||||||
# Create a non-master "dependency" branch at the same commit, then a
|
|
||||||
# feature branch off it — mirrors a stacked worktree.
|
|
||||||
self._git("branch", "feat/issue-100-dep")
|
|
||||||
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.tmp.cleanup()
|
|
||||||
|
|
||||||
def test_stacked_base_not_equivalent_without_extra_bases(self):
|
|
||||||
state = issue_lock_worktree.read_worktree_git_state(self.repo)
|
|
||||||
# HEAD does not match master/main/dev, so base-equivalence is False.
|
|
||||||
self.assertFalse(state["base_equivalent"])
|
|
||||||
|
|
||||||
def test_stacked_base_equivalent_with_extra_bases(self):
|
|
||||||
state = issue_lock_worktree.read_worktree_git_state(
|
|
||||||
self.repo, extra_bases=("feat/issue-100-dep",)
|
|
||||||
)
|
|
||||||
self.assertTrue(state["base_equivalent"])
|
|
||||||
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
|
|
||||||
|
|
||||||
def test_unrelated_extra_base_does_not_anchor(self):
|
|
||||||
state = issue_lock_worktree.read_worktree_git_state(
|
|
||||||
self.repo, extra_bases=("feat/does-not-exist",)
|
|
||||||
)
|
|
||||||
self.assertFalse(state["base_equivalent"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
||||||
def test_explicit_path_wins(self):
|
def test_explicit_path_wins(self):
|
||||||
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for early duplicate-work detection (#400)."""
|
"""Tests for early duplicate-work detection (#400)."""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -8,8 +9,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
import issue_lock_provenance
|
|
||||||
import issue_lock_store
|
|
||||||
import issue_work_duplicate_gate as dup_gate
|
import issue_work_duplicate_gate as dup_gate
|
||||||
import mcp_server
|
import mcp_server
|
||||||
from issue_work_duplicate_gate import (
|
from issue_work_duplicate_gate import (
|
||||||
@@ -123,9 +122,8 @@ class TestDuplicateReportOutcome(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
|
def test_lock_issue_uses_injected_fetcher(self, _auth):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def fetcher(h, o, r, auth, issue_number):
|
def fetcher(h, o, r, auth, issue_number):
|
||||||
@@ -142,29 +140,26 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
|||||||
"porcelain_status": "",
|
"porcelain_status": "",
|
||||||
"base_equivalent": True,
|
"base_equivalent": True,
|
||||||
},
|
},
|
||||||
):
|
), patch.dict(os.environ, {
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||||
with patch.dict(os.environ, {
|
}, clear=True):
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
with patch.object(mcp_server, "ISSUE_LOCK_FILE", tempfile.mktemp()):
|
||||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
mcp_server.gitea_lock_issue(
|
||||||
}, clear=True):
|
issue_number=400,
|
||||||
mcp_server.gitea_lock_issue(
|
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||||
issue_number=400,
|
remote="prgs",
|
||||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
)
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertEqual(seen["issue_number"], 400)
|
self.assertEqual(seen["issue_number"], 400)
|
||||||
|
|
||||||
|
|
||||||
class TestMcpDuplicateRecheck(unittest.TestCase):
|
class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
self._env_patch = patch.dict(
|
self.lock_path = os.path.join(self._dir.name, "gitea_issue_lock.json")
|
||||||
os.environ,
|
self._lock_patch = patch.object(
|
||||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
mcp_server, "ISSUE_LOCK_FILE", self.lock_path
|
||||||
clear=False,
|
|
||||||
)
|
)
|
||||||
self._env_patch.start()
|
self._lock_patch.start()
|
||||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||||
"repo": "Example-Repo"},
|
"repo": "Example-Repo"},
|
||||||
@@ -177,27 +172,12 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
||||||
worktree_path = os.path.realpath(os.getcwd())
|
with open(self.lock_path, "w", encoding="utf-8") as fh:
|
||||||
work_lease = {
|
json.dump({
|
||||||
"operation_type": "author_issue_work",
|
"issue_number": issue_number,
|
||||||
"issue_number": issue_number,
|
"branch_name": branch,
|
||||||
"branch": branch,
|
"remote": "prgs",
|
||||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
}, fh)
|
||||||
"expires_at": "2999-01-01T00:00:00Z",
|
|
||||||
}
|
|
||||||
issue_lock_store.bind_session_lock({
|
|
||||||
"issue_number": issue_number,
|
|
||||||
"branch_name": branch,
|
|
||||||
"remote": "prgs",
|
|
||||||
"org": "Example-Org",
|
|
||||||
"repo": "Example-Repo",
|
|
||||||
"worktree_path": worktree_path,
|
|
||||||
"work_lease": work_lease,
|
|
||||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
|
||||||
tool="gitea_lock_issue",
|
|
||||||
claimant=work_lease.get("claimant"),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||||
@patch("mcp_server.get_profile", return_value={
|
@patch("mcp_server.get_profile", return_value={
|
||||||
@@ -261,7 +241,6 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
base="master",
|
base="master",
|
||||||
body="Closes #400",
|
body="Closes #400",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=os.path.realpath(os.getcwd()),
|
|
||||||
)
|
)
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertIsNone(result.get("number"))
|
self.assertIsNone(result.get("number"))
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
import mcp_server # noqa: E402
|
|
||||||
from mcp_server import ( # noqa: E402
|
from mcp_server import ( # noqa: E402
|
||||||
gitea_check_pr_eligibility,
|
gitea_check_pr_eligibility,
|
||||||
gitea_merge_pr,
|
gitea_merge_pr,
|
||||||
@@ -123,19 +122,15 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
|
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
|
||||||
from mcp_server import init_review_decision_lock
|
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||||
from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision
|
|
||||||
|
|
||||||
head_sha = FULL_HEAD_SHA
|
|
||||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "jcwalker3"}, # /user (inventory)
|
{"login": "jcwalker3"}, # /user (inventory)
|
||||||
{"login": "jcwalker3"}, # /user (submit eligibility)
|
{"login": "jcwalker3"}, # /user (submit eligibility)
|
||||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
|
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||||
]
|
]
|
||||||
|
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||||
init_review_decision_lock("prgs", "review_pr")
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
gitea_mark_final_review_decision(9, "approve", remote="prgs")
|
||||||
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
|
|
||||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_review_pr(
|
r = gitea_review_pr(
|
||||||
|
|||||||
@@ -87,14 +87,6 @@ def test_reconcile_landed_workflow_contract():
|
|||||||
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
|
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
|
||||||
assert "RECOVERY_HANDOFF_ONLY" in text
|
assert "RECOVERY_HANDOFF_ONLY" in text
|
||||||
assert "resolve_partial_reconciliation_plan" in text
|
assert "resolve_partial_reconciliation_plan" in text
|
||||||
assert "check_audit_mutation_allowed" in text
|
|
||||||
assert "gitea_authorize_reconciliation_cleanup_phase" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_reconciliation_verifier_exported():
|
|
||||||
from review_proofs import assess_audit_reconciliation_report
|
|
||||||
|
|
||||||
assert callable(assess_audit_reconciliation_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_issue_workflow_contract():
|
def test_create_issue_workflow_contract():
|
||||||
@@ -221,12 +213,6 @@ def test_validation_failure_history_verifier_exported():
|
|||||||
assert callable(assess_validation_failure_history_report)
|
assert callable(assess_validation_failure_history_report)
|
||||||
|
|
||||||
|
|
||||||
def test_validation_cwd_proof_verifier_exported():
|
|
||||||
from review_proofs import assess_validation_cwd_proof_report
|
|
||||||
|
|
||||||
assert callable(assess_validation_cwd_proof_report)
|
|
||||||
|
|
||||||
|
|
||||||
def test_prior_blocker_skip_verifier_exported():
|
def test_prior_blocker_skip_verifier_exported():
|
||||||
from review_proofs import assess_prior_blocker_skip_proof
|
from review_proofs import assess_prior_blocker_skip_proof
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import issue_lock_store
|
|
||||||
import mcp_server
|
|
||||||
from mcp_server import gitea_create_pr, gitea_lock_issue
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = {
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
CREATE_PR_ENV = {
|
|
||||||
"GITEA_PROFILE_NAME": "author-test",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
|
||||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
|
||||||
),
|
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
|
||||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_master_git_state_for_lock():
|
|
||||||
return {
|
|
||||||
"current_branch": "master",
|
|
||||||
"porcelain_status": "",
|
|
||||||
"base_equivalent": True,
|
|
||||||
"inspected_git_root": "/scratch/wt",
|
|
||||||
"base_branch": "origin/master",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _registered_tool_names() -> set[str]:
|
|
||||||
manager = mcp_server.mcp._tool_manager
|
|
||||||
tools = getattr(manager, "_tools", None) or {}
|
|
||||||
return set(tools.keys())
|
|
||||||
|
|
||||||
|
|
||||||
class TestLockIssueMcpRegistration(unittest.TestCase):
|
|
||||||
def test_gitea_lock_issue_registered_as_public_mcp_tool(self):
|
|
||||||
names = _registered_tool_names()
|
|
||||||
self.assertIn("gitea_lock_issue", names)
|
|
||||||
|
|
||||||
def test_internal_list_open_pulls_not_exposed_as_mcp_tool(self):
|
|
||||||
names = _registered_tool_names()
|
|
||||||
self.assertNotIn("_list_open_pulls", names)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreatePrLockRegistrationFlow(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._lock_dir = tempfile.TemporaryDirectory()
|
|
||||||
self._env_patcher = patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
**CREATE_PR_ENV,
|
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
self._env_patcher.start()
|
|
||||||
self._dup_fetcher_patcher = patch(
|
|
||||||
"mcp_server.issue_duplicate_context_fetcher",
|
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
|
||||||
)
|
|
||||||
self._dup_fetcher_patcher.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._dup_fetcher_patcher.stop()
|
|
||||||
self._env_patcher.stop()
|
|
||||||
self._lock_dir.cleanup()
|
|
||||||
|
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
|
||||||
return_value=(True, []))
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_create_pr_still_fails_closed_without_issue_lock(self, _auth, _role):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
gitea_create_pr(
|
|
||||||
title="feat: X Closes #521",
|
|
||||||
head="feat/issue-521-lock-issue-registration",
|
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertIn("Issue lock is missing", str(ctx.exception))
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch(
|
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
|
||||||
)
|
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
|
||||||
return_value=(True, []))
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_create_pr_proceeds_after_valid_issue_lock(
|
|
||||||
self, _auth, _role, _api, _git_state, mock_api_request
|
|
||||||
):
|
|
||||||
worktree = os.path.realpath(os.getcwd())
|
|
||||||
mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"}
|
|
||||||
lock_res = gitea_lock_issue(
|
|
||||||
issue_number=521,
|
|
||||||
branch_name="feat/issue-521-lock-issue-registration",
|
|
||||||
remote="prgs",
|
|
||||||
worktree_path=worktree,
|
|
||||||
)
|
|
||||||
self.assertTrue(lock_res["success"])
|
|
||||||
lock_path = lock_res["lock_file_path"]
|
|
||||||
self.assertTrue(os.path.exists(lock_path))
|
|
||||||
lock = issue_lock_store.read_lock_file(lock_path)
|
|
||||||
self.assertEqual(lock["issue_number"], 521)
|
|
||||||
|
|
||||||
res = gitea_create_pr(
|
|
||||||
title="fix: restore lock tool registration Closes #521",
|
|
||||||
head="feat/issue-521-lock-issue-registration",
|
|
||||||
remote="prgs",
|
|
||||||
worktree_path=worktree,
|
|
||||||
)
|
|
||||||
self.assertEqual(res["number"], 521)
|
|
||||||
+86
-388
@@ -49,7 +49,6 @@ import mcp_server
|
|||||||
import issue_lock_store
|
import issue_lock_store
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
FULL_HEAD_SHA = "a" * 40
|
|
||||||
|
|
||||||
_NO_BLOCKER_FEEDBACK = {
|
_NO_BLOCKER_FEEDBACK = {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -58,18 +57,11 @@ _NO_BLOCKER_FEEDBACK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _init_reviewer_session(remote="prgs"):
|
|
||||||
"""Seed review decision lock and required workflow-load proof (#389)."""
|
|
||||||
init_review_decision_lock(remote, "review_pr")
|
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_request_changes_ready(pr_number=8, **kwargs):
|
def _mark_request_changes_ready(pr_number=8, **kwargs):
|
||||||
"""Mark a request_changes decision ready with the #332 duplicate-
|
"""Mark a request_changes decision ready with the #332 duplicate-
|
||||||
suppression feedback fetch stubbed to 'no existing blocker'."""
|
suppression feedback fetch stubbed to 'no existing blocker'."""
|
||||||
with patch("mcp_server.gitea_get_pr_review_feedback",
|
with patch("mcp_server.gitea_get_pr_review_feedback",
|
||||||
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
||||||
kwargs.setdefault("expected_head_sha", "abc123")
|
|
||||||
return gitea_mark_final_review_decision(
|
return gitea_mark_final_review_decision(
|
||||||
pr_number, "request_changes", **kwargs)
|
pr_number, "request_changes", **kwargs)
|
||||||
|
|
||||||
@@ -89,105 +81,6 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
|
|||||||
return [_formal_review(reviewer, "APPROVED", sha=sha)]
|
return [_formal_review(reviewer, "APPROVED", sha=sha)]
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
|
|
||||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _reviewer_lease_comment(
|
|
||||||
pr_number,
|
|
||||||
*,
|
|
||||||
session_id=_DEFAULT_LEASE_SESSION,
|
|
||||||
head_sha="abc123",
|
|
||||||
reviewer="reviewer-bot",
|
|
||||||
):
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
body = reviewer_pr_lease.format_lease_body(
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
pr_number=pr_number,
|
|
||||||
issue_number=407,
|
|
||||||
reviewer_identity=reviewer,
|
|
||||||
profile="gitea-reviewer",
|
|
||||||
session_id=session_id,
|
|
||||||
worktree="branches/review-test",
|
|
||||||
phase="claimed",
|
|
||||||
candidate_head=head_sha,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="b" * 40,
|
|
||||||
last_activity=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
return {"id": 9001, "body": body, "user": {"login": reviewer}}
|
|
||||||
|
|
||||||
|
|
||||||
def _install_owned_reviewer_lease(
|
|
||||||
pr_number,
|
|
||||||
*,
|
|
||||||
session_id=_DEFAULT_LEASE_SESSION,
|
|
||||||
head_sha="abc123",
|
|
||||||
):
|
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
reviewer_pr_lease.clear_session_lease()
|
|
||||||
reviewer_pr_lease.record_session_lease({
|
|
||||||
"pr_number": pr_number,
|
|
||||||
"session_id": session_id,
|
|
||||||
"candidate_head": head_sha,
|
|
||||||
"target_branch": "master",
|
|
||||||
})
|
|
||||||
return patch(
|
|
||||||
"mcp_server._fetch_pr_comments",
|
|
||||||
return_value=[
|
|
||||||
_reviewer_lease_comment(
|
|
||||||
pr_number,
|
|
||||||
session_id=session_id,
|
|
||||||
head_sha=head_sha,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_ready_review_decision(
|
|
||||||
pr_number,
|
|
||||||
action,
|
|
||||||
*,
|
|
||||||
sha=FULL_HEAD_SHA,
|
|
||||||
remote="prgs",
|
|
||||||
org=None,
|
|
||||||
repo=None,
|
|
||||||
):
|
|
||||||
"""Mark the review-decision lock ready without mark_final API calls (#399)."""
|
|
||||||
import mcp_server as _m
|
|
||||||
|
|
||||||
resolved_org, resolved_repo = org, repo
|
|
||||||
if remote in _m.REMOTES:
|
|
||||||
_, resolved_org, resolved_repo = _m._resolve(remote, None, org, repo)
|
|
||||||
profile_name = (_m.get_profile().get("profile_name") or "").strip()
|
|
||||||
session_lock = (
|
|
||||||
(os.environ.get(_m.SESSION_PROFILE_LOCK_ENV) or "").strip()
|
|
||||||
or profile_name
|
|
||||||
)
|
|
||||||
_m._save_review_decision_lock({
|
|
||||||
"task": "review_pr",
|
|
||||||
"remote": remote,
|
|
||||||
"session_pid": os.getpid(),
|
|
||||||
"session_profile": profile_name,
|
|
||||||
"session_profile_lock": session_lock,
|
|
||||||
"final_review_decision_ready": True,
|
|
||||||
"ready_pr_number": pr_number,
|
|
||||||
"ready_action": action,
|
|
||||||
"ready_expected_head_sha": sha,
|
|
||||||
"ready_remote": remote,
|
|
||||||
"ready_org": resolved_org,
|
|
||||||
"ready_repo": resolved_repo,
|
|
||||||
"live_mutations": [],
|
|
||||||
"correction_authorized": False,
|
|
||||||
"correction_reason": None,
|
|
||||||
})
|
|
||||||
_m.gitea_load_review_workflow()
|
|
||||||
|
|
||||||
|
|
||||||
# Issue-write tools are profile-gated (#69).
|
# Issue-write tools are profile-gated (#69).
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = {
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
@@ -675,31 +568,6 @@ class TestViewPR(unittest.TestCase):
|
|||||||
class TestMergePR(unittest.TestCase):
|
class TestMergePR(unittest.TestCase):
|
||||||
"""Gated merge workflow (#16). gitea_merge_pr is the only merge path."""
|
"""Gated merge workflow (#16). gitea_merge_pr is the only merge path."""
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
|
||||||
self._lease_patch.start()
|
|
||||||
self._auth_identity_patch = patch(
|
|
||||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
|
||||||
)
|
|
||||||
self._auth_identity_patch.start()
|
|
||||||
self.addCleanup(self._auth_identity_patch.stop)
|
|
||||||
self.addCleanup(self._lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
self._pr_lease_comments_patch = patch(
|
|
||||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
|
||||||
)
|
|
||||||
self._pr_lease_comments_patch.start()
|
|
||||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
|
||||||
self._pr_work_lease_patch = patch(
|
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
|
||||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
|
||||||
)
|
|
||||||
self._pr_work_lease_patch.start()
|
|
||||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
|
||||||
|
|
||||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||||
return {
|
return {
|
||||||
"user": {"login": author},
|
"user": {"login": author},
|
||||||
@@ -772,8 +640,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["b.py", "a.py"],
|
expected_changed_files=["b.py", "a.py"], remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs")
|
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
|
|
||||||
# -- read-back / cleanup surfacing (#98) -----------------------------------
|
# -- read-back / cleanup surfacing (#98) -----------------------------------
|
||||||
@@ -791,10 +658,8 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8),
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
# The merge itself is still reported performed/successful.
|
# The merge itself is still reported performed/successful.
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
self.assertEqual(r["merge_result"], "PR #8 merged via 'merge'.")
|
self.assertEqual(r["merge_result"], "PR #8 merged via 'merge'.")
|
||||||
@@ -822,10 +687,8 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8),
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
self.assertEqual(r["merge_commit"], "c9")
|
self.assertEqual(r["merge_commit"], "c9")
|
||||||
self.assertTrue(r["cleanup_status"].startswith("skipped (cleanup error:"))
|
self.assertTrue(r["cleanup_status"].startswith("skipped (cleanup error:"))
|
||||||
@@ -839,7 +702,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
@@ -850,7 +713,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
|
|
||||||
@@ -965,11 +828,9 @@ class TestMergePR(unittest.TestCase):
|
|||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="deadbeef", remote="prgs")
|
expected_head_sha="deadbeef", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any(
|
self.assertIn(
|
||||||
"expected head SHA does not match current PR head (fail closed)" in reason
|
"expected head SHA does not match current PR head (fail closed)",
|
||||||
or "PR head changed during lease" in reason
|
r["reasons"])
|
||||||
for reason in r["reasons"]
|
|
||||||
))
|
|
||||||
self._assert_no_merge_call(mock_api)
|
self._assert_no_merge_call(mock_api)
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@@ -984,8 +845,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["a.py", "b.py"],
|
expected_changed_files=["a.py", "b.py"], remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs")
|
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"PR changed files do not match expected_changed_files (fail closed)",
|
"PR changed files do not match expected_changed_files (fail closed)",
|
||||||
@@ -998,7 +858,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_invalid_merge_method_rejected(self, mock_api):
|
def test_invalid_merge_method_rejected(self, mock_api):
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation="MERGE PR 8", do="octopus", expected_head_sha="abc123", remote="prgs")
|
pr_number=8, confirmation="MERGE PR 8", do="octopus", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any("unknown merge method" in x for x in r["reasons"]))
|
self.assertTrue(any("unknown merge method" in x for x in r["reasons"]))
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
@@ -1016,9 +876,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
blob = repr(r).lower()
|
blob = repr(r).lower()
|
||||||
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||||
self.assertNotIn(secret, blob)
|
self.assertNotIn(secret, blob)
|
||||||
@@ -1035,38 +893,12 @@ class TestMergePR(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
blob = repr(r)
|
blob = repr(r)
|
||||||
self.assertIn("[REDACTED]", blob)
|
self.assertIn("[REDACTED]", blob)
|
||||||
self.assertNotIn("abc-secret-xyz", blob)
|
self.assertNotIn("abc-secret-xyz", blob)
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_merge_blocked_on_stale_approval_head(self, _auth, mock_api):
|
|
||||||
old_sha = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
|
|
||||||
new_sha = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
|
|
||||||
mock_api.side_effect = [
|
|
||||||
{"login": "merger-bot"}, self._pr("author-bot", sha=new_sha),
|
|
||||||
self._pr("author-bot", sha=new_sha),
|
|
||||||
[_formal_review("reviewer-bot", "APPROVED", sha=old_sha)],
|
|
||||||
]
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
r = gitea_merge_pr(
|
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
|
||||||
expected_head_sha=new_sha)
|
|
||||||
self.assertFalse(r["performed"])
|
|
||||||
self.assertTrue(r.get("approval_visible"))
|
|
||||||
self.assertFalse(r.get("approval_at_current_head"))
|
|
||||||
self.assertTrue(any("stale approval" in x for x in r["reasons"]))
|
|
||||||
self.assertTrue(any(old_sha in x for x in r["reasons"]))
|
|
||||||
self.assertTrue(any(new_sha in x for x in r["reasons"]))
|
|
||||||
self._assert_no_merge_call(mock_api)
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_merge_blocked_without_visible_approval(self, _auth, mock_api):
|
def test_merge_blocked_without_visible_approval(self, _auth, mock_api):
|
||||||
@@ -1079,9 +911,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertFalse(r.get("approval_visible"))
|
self.assertFalse(r.get("approval_visible"))
|
||||||
self.assertTrue(any("no visible APPROVED review" in x for x in r["reasons"]))
|
self.assertTrue(any("no visible APPROVED review" in x for x in r["reasons"]))
|
||||||
@@ -1102,9 +932,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
expected_head_sha="abc123", remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(r.get("has_blocking_change_requests"))
|
self.assertTrue(r.get("has_blocking_change_requests"))
|
||||||
self.assertTrue(any("REQUEST_CHANGES" in x for x in r["reasons"]))
|
self.assertTrue(any("REQUEST_CHANGES" in x for x in r["reasons"]))
|
||||||
@@ -1205,18 +1033,16 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
head_sha = FULL_HEAD_SHA
|
mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||||
mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
|
||||||
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
||||||
{"login": "jcwalker3"}, # /api/v1/user (submit eligibility)
|
{"login": "jcwalker3"}, # /api/v1/user (submit eligibility)
|
||||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/1
|
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/1
|
||||||
]
|
]
|
||||||
from mcp_server import init_review_decision_lock
|
from mcp_server import init_review_decision_lock
|
||||||
init_review_decision_lock("prgs", "review_pr")
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
gitea_mark_final_review_decision(1, "approve", remote="prgs")
|
||||||
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
|
|
||||||
result = gitea_review_pr(
|
result = gitea_review_pr(
|
||||||
pr_number=1,
|
pr_number=1,
|
||||||
event="APPROVE",
|
event="APPROVE",
|
||||||
@@ -1872,7 +1698,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
|
|||||||
"""Block incidental live review mutations during validation."""
|
"""Block incidental live review mutations during validation."""
|
||||||
|
|
||||||
PR = 203
|
PR = 203
|
||||||
SHA = FULL_HEAD_SHA
|
SHA = "abc123"
|
||||||
|
|
||||||
def _pr(self, author, sha=SHA):
|
def _pr(self, author, sha=SHA):
|
||||||
return {
|
return {
|
||||||
@@ -1883,32 +1709,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
import reviewer_pr_lease
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
|
|
||||||
_init_reviewer_session("prgs")
|
|
||||||
self._lease_patch = _install_owned_reviewer_lease(
|
|
||||||
self.PR, head_sha=self.SHA,
|
|
||||||
)
|
|
||||||
self._lease_patch.start()
|
|
||||||
self._auth_identity_patch = patch(
|
|
||||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
|
||||||
)
|
|
||||||
self._auth_identity_patch.start()
|
|
||||||
self.addCleanup(self._auth_identity_patch.stop)
|
|
||||||
self.addCleanup(self._lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
self._pr_lease_comments_patch = patch(
|
|
||||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
|
||||||
)
|
|
||||||
self._pr_lease_comments_patch.start()
|
|
||||||
self._pr_work_lease_patch = patch(
|
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
|
||||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
|
||||||
)
|
|
||||||
self._pr_work_lease_patch.start()
|
|
||||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
|
||||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
|
||||||
|
|
||||||
|
|
||||||
def _env(self):
|
def _env(self):
|
||||||
return patch.dict(os.environ, {
|
return patch.dict(os.environ, {
|
||||||
@@ -2003,32 +1804,8 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
"""Gated review-mutation tool (#15)."""
|
"""Gated review-mutation tool (#15)."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
import reviewer_pr_lease
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
|
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||||
_init_reviewer_session("prgs")
|
|
||||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
|
||||||
self._lease_patch.start()
|
|
||||||
self._auth_identity_patch = patch(
|
|
||||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
|
||||||
)
|
|
||||||
self._auth_identity_patch.start()
|
|
||||||
self._pr_lease_comments_patch = patch(
|
|
||||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
|
||||||
)
|
|
||||||
self._pr_lease_comments_patch.start()
|
|
||||||
self._pr_work_lease_patch = patch(
|
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
|
||||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
|
||||||
)
|
|
||||||
self._pr_work_lease_patch.start()
|
|
||||||
gitea_mark_final_review_decision(
|
|
||||||
8, "approve", remote="prgs", expected_head_sha="abc123",
|
|
||||||
)
|
|
||||||
self.addCleanup(self._auth_identity_patch.stop)
|
|
||||||
self.addCleanup(self._lease_patch.stop)
|
|
||||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
|
||||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
|
|
||||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||||
return {
|
return {
|
||||||
@@ -2188,11 +1965,10 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_comment_succeeds_when_review_eligible(self, _auth, mock_api):
|
def test_comment_succeeds_when_review_eligible(self, _auth, mock_api):
|
||||||
|
gitea_mark_final_review_decision(8, "comment", remote="prgs")
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 3},
|
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 3},
|
||||||
]
|
]
|
||||||
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
@@ -2208,15 +1984,12 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
patch("mcp_server.api_request") as mock_api:
|
patch("mcp_server.api_request") as mock_api:
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "jcwalker3"}, self._pr("jcwalker3"),
|
|
||||||
{"login": "jcwalker3"}, self._pr("jcwalker3"), {"id": 4},
|
{"login": "jcwalker3"}, self._pr("jcwalker3"), {"id": 4},
|
||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(8, "comment", remote="prgs")
|
||||||
8, "comment", remote="prgs", expected_head_sha="abc123",
|
|
||||||
)
|
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="comment", body="note", remote="prgs",
|
pr_number=8, action="comment", body="note", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2259,22 +2032,20 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
patch("mcp_server.api_request") as mock_api:
|
patch("mcp_server.api_request") as mock_api:
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot", sha="deadbeef"),
|
{"login": "reviewer-bot"}, self._pr("author-bot", sha="abc123"),
|
||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve",
|
pr_number=8, action="approve",
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="deadbeef", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any(
|
self.assertIn(
|
||||||
"expected head SHA does not match current PR head (fail closed)" in reason
|
"expected head SHA does not match current PR head (fail closed)",
|
||||||
or "PR head changed during lease" in reason
|
r["reasons"])
|
||||||
for reason in r["reasons"]
|
|
||||||
))
|
|
||||||
self._assert_no_mutation(mock_api)
|
self._assert_no_mutation(mock_api)
|
||||||
|
|
||||||
def test_head_sha_match_allows(self):
|
def test_head_sha_match_allows(self):
|
||||||
@@ -2317,7 +2088,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
gitea_mark_final_review_decision(5, "approve", remote="prgs")
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=5, action="approve", remote="prgs",
|
pr_number=5, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2337,8 +2108,9 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
gitea_mark_final_review_decision(5, "approve", remote="prgs")
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=5, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -2425,8 +2197,8 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
os.remove(spoof_path)
|
os.remove(spoof_path)
|
||||||
|
|
||||||
def test_mark_final_decision_rejects_remote_mismatch(self):
|
def test_mark_final_decision_rejects_remote_mismatch(self):
|
||||||
_init_reviewer_session("prgs")
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools", expected_head_sha="abc123")
|
r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools")
|
||||||
self.assertFalse(r["marked_ready"])
|
self.assertFalse(r["marked_ready"])
|
||||||
self.assertTrue(any("does not match locked remote" in x for x in r["reasons"]))
|
self.assertTrue(any("does not match locked remote" in x for x in r["reasons"]))
|
||||||
|
|
||||||
@@ -2438,30 +2210,28 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
{"id": 42, "state": "APPROVED"},
|
{"id": 42, "state": "APPROVED"},
|
||||||
[_formal_review("reviewer-bot", "APPROVED", review_id=42)],
|
[_formal_review("reviewer-bot", "APPROVED", review_id=42)],
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
|
||||||
{"id": 43, "state": "REQUEST_CHANGES"},
|
{"id": 43, "state": "REQUEST_CHANGES"},
|
||||||
[_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=43)],
|
[_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=43)],
|
||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
||||||
with patch("mcp_server.gitea_get_pr_review_feedback",
|
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||||
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with patch.dict(os.environ, env, clear=True):
|
first = gitea_submit_pr_review(
|
||||||
first = gitea_submit_pr_review(
|
pr_number=8, action="approve", remote="prgs",
|
||||||
pr_number=8, action="approve", remote="prgs",
|
final_review_decision_ready=True,
|
||||||
final_review_decision_ready=True,
|
)
|
||||||
)
|
auth = gitea_authorize_review_correction(
|
||||||
auth = gitea_authorize_review_correction(
|
prior_review_id=42,
|
||||||
prior_review_id=42,
|
prior_review_state="approve",
|
||||||
prior_review_state="approve",
|
reason="operator approved correcting mistaken approve",
|
||||||
reason="operator approved correcting mistaken approve",
|
operator_authorized=True,
|
||||||
operator_authorized=True,
|
)
|
||||||
)
|
_mark_request_changes_ready(remote="prgs")
|
||||||
_mark_request_changes_ready(remote="prgs")
|
second = gitea_submit_pr_review(
|
||||||
second = gitea_submit_pr_review(
|
pr_number=8, action="request_changes", remote="prgs",
|
||||||
pr_number=8, action="request_changes", remote="prgs",
|
final_review_decision_ready=True,
|
||||||
final_review_decision_ready=True,
|
)
|
||||||
)
|
|
||||||
self.assertTrue(first["performed"])
|
self.assertTrue(first["performed"])
|
||||||
self.assertTrue(auth["authorized"])
|
self.assertTrue(auth["authorized"])
|
||||||
self.assertTrue(second["performed"])
|
self.assertTrue(second["performed"])
|
||||||
@@ -2473,7 +2243,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
# Mark decision for specific remote, org, repo
|
# Mark decision for specific remote, org, repo
|
||||||
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
gitea_mark_final_review_decision(8, "approve", remote="prgs", org="MyOrg", repo="MyRepo")
|
||||||
|
|
||||||
# Mismatched remote
|
# Mismatched remote
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
@@ -2509,18 +2279,12 @@ if __name__ == "__main__":
|
|||||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||||
self.mock_audit = patch("gitea_audit.write_event").start()
|
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||||
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
||||||
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
|
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
|
||||||
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
|
||||||
patch(
|
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
|
||||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
patch.stopall()
|
patch.stopall()
|
||||||
@@ -2565,24 +2329,16 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
||||||
|
|
||||||
def test_merge_pr_with_closes_removes_label(self):
|
def test_merge_pr_with_closes_removes_label(self):
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
head_sha = FULL_HEAD_SHA
|
|
||||||
lease_patch = _install_owned_reviewer_lease(1, head_sha=head_sha)
|
|
||||||
lease_patch.start()
|
|
||||||
self.addCleanup(lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
|
|
||||||
def api_side_effect(method, url, auth, payload=None):
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
if method == "GET" and "/user" in url:
|
if method == "GET" and "/user" in url:
|
||||||
return {"login": "merger"}
|
return {"login": "merger"}
|
||||||
if method == "GET" and url.endswith("/reviews"):
|
if method == "GET" and url.endswith("/reviews"):
|
||||||
return [_formal_review("reviewer", "APPROVED", sha=head_sha)]
|
return [_formal_review("reviewer", "APPROVED", sha="sha123")]
|
||||||
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||||
return {
|
return {
|
||||||
"user": {"login": "author"},
|
"user": {"login": "author"},
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"head": {"sha": head_sha, "ref": "feat/my-branch"},
|
"head": {"sha": "sha123", "ref": "feat/my-branch"},
|
||||||
"base": {"ref": "main"},
|
"base": {"ref": "main"},
|
||||||
"mergeable": True,
|
"mergeable": True,
|
||||||
"merged_commit_sha": "merge123",
|
"merged_commit_sha": "merge123",
|
||||||
@@ -2602,32 +2358,21 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
return {}
|
return {}
|
||||||
self.mock_api.side_effect = api_side_effect
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
res = gitea_merge_pr(
|
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||||
pr_number=1, confirmation="MERGE PR 1", do="merge",
|
|
||||||
expected_head_sha=head_sha,
|
|
||||||
)
|
|
||||||
self.assertTrue(res["performed"])
|
self.assertTrue(res["performed"])
|
||||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
|
||||||
def test_merge_pr_with_branch_name_removes_label(self):
|
def test_merge_pr_with_branch_name_removes_label(self):
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
head_sha = FULL_HEAD_SHA
|
|
||||||
lease_patch = _install_owned_reviewer_lease(1, head_sha=head_sha)
|
|
||||||
lease_patch.start()
|
|
||||||
self.addCleanup(lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
|
|
||||||
def api_side_effect(method, url, auth, payload=None):
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
if method == "GET" and "/user" in url:
|
if method == "GET" and "/user" in url:
|
||||||
return {"login": "merger"}
|
return {"login": "merger"}
|
||||||
if method == "GET" and url.endswith("/reviews"):
|
if method == "GET" and url.endswith("/reviews"):
|
||||||
return [_formal_review("reviewer", "APPROVED", sha=head_sha)]
|
return [_formal_review("reviewer", "APPROVED", sha="sha123")]
|
||||||
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||||
return {
|
return {
|
||||||
"user": {"login": "author"},
|
"user": {"login": "author"},
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"head": {"sha": head_sha, "ref": "fix/issue-123-slug"},
|
"head": {"sha": "sha123", "ref": "fix/issue-123-slug"},
|
||||||
"base": {"ref": "main"},
|
"base": {"ref": "main"},
|
||||||
"mergeable": True,
|
"mergeable": True,
|
||||||
"merged_commit_sha": "merge123",
|
"merged_commit_sha": "merge123",
|
||||||
@@ -2647,10 +2392,7 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
return {}
|
return {}
|
||||||
self.mock_api.side_effect = api_side_effect
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
res = gitea_merge_pr(
|
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||||
pr_number=1, confirmation="MERGE PR 1", do="merge",
|
|
||||||
expected_head_sha=head_sha,
|
|
||||||
)
|
|
||||||
self.assertTrue(res["performed"])
|
self.assertTrue(res["performed"])
|
||||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
|
||||||
@@ -3302,12 +3044,10 @@ class TestVerifyMutationAuthority(unittest.TestCase):
|
|||||||
# profile; the active profile resolves as reviewer — side-channel
|
# profile; the active profile resolves as reviewer — side-channel
|
||||||
# override rejected even with a matching in-process authority.
|
# override rejected even with a matching in-process authority.
|
||||||
self._authority()
|
self._authority()
|
||||||
with patch("mcp_server.gitea_config.is_runtime_switching_enabled",
|
with patch.dict(os.environ,
|
||||||
return_value=False):
|
{"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}):
|
||||||
with patch.dict(os.environ,
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
{"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}):
|
mcp_server.verify_mutation_authority("prgs")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
mcp_server.verify_mutation_authority("prgs")
|
|
||||||
self.assertIn("side-channel override rejected", str(ctx.exception))
|
self.assertIn("side-channel override rejected", str(ctx.exception))
|
||||||
|
|
||||||
def test_foreign_pid_authority_is_not_trusted(self):
|
def test_foreign_pid_authority_is_not_trusted(self):
|
||||||
@@ -3406,7 +3146,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
def test_lock_issue_mismatch_branch_fails(self):
|
def test_lock_issue_mismatch_branch_fails(self):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
|
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
|
||||||
self.assertIn("must contain locked issue pattern", str(ctx.exception))
|
self.assertIn("must match", str(ctx.exception))
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
@@ -3463,52 +3203,12 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
|
def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
|
||||||
branch = "feat/issue-196-mutations"
|
branch = "feat/issue-196-mutations"
|
||||||
self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"})
|
|
||||||
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
|
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
|
||||||
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertIn("adoption", res)
|
self.assertIn("adoption", res)
|
||||||
self.assertEqual(res["adoption"]["branch_head_commit"], "abc123")
|
self.assertEqual(res["adoption"]["branch_head_commit"], "abc123")
|
||||||
|
|
||||||
@patch(
|
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
|
||||||
)
|
|
||||||
@patch("mcp_server.api_get_all")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_lock_issue_adoption_response_has_explicit_proof(self, _auth, mock_api, _git_state):
|
|
||||||
# #477 AC1: the live lock response must carry citable adoption proof.
|
|
||||||
branch = "feat/issue-196-mutations"
|
|
||||||
self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"})
|
|
||||||
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
|
|
||||||
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
|
||||||
proof = res["adoption"]
|
|
||||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
|
||||||
self.assertTrue(proof["adopted"])
|
|
||||||
self.assertEqual(proof["adopted_branch"], branch)
|
|
||||||
self.assertEqual(proof["adopted_branch_head"], "abc123")
|
|
||||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
|
||||||
self.assertIn("gitea_create_pr", proof["safe_next_action"])
|
|
||||||
self.assertIn("196", proof["matcher_summary"])
|
|
||||||
|
|
||||||
@patch(
|
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
|
||||||
)
|
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_lock_issue_no_match_response_does_not_claim_adoption(self, _auth, _api, _git_state):
|
|
||||||
# #477 AC2/AC3: a normal (NO_MATCH) lock must carry adoption-free proof
|
|
||||||
# and must NOT expose an ``adoption`` block.
|
|
||||||
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertNotIn("adoption", res)
|
|
||||||
check = res["adoption_check"]
|
|
||||||
self.assertEqual(check["adoption_decision"], "NO_MATCH")
|
|
||||||
self.assertFalse(check["adopted"])
|
|
||||||
self.assertIsNone(check["adopted_branch"])
|
|
||||||
self.assertEqual(check["competing_branch_check"]["result"], "clear")
|
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
return_value=_clean_master_git_state_for_lock(),
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
@@ -3730,17 +3430,16 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
|
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
|
||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
env = self._create_pr_env()
|
||||||
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with patch.dict(os.environ, env, clear=True):
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.lock_file_path(
|
||||||
issue_lock_store.lock_file_path(
|
remote="prgs",
|
||||||
remote="prgs",
|
org="Scaled-Tech-Consulting",
|
||||||
org="Scaled-Tech-Consulting",
|
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
issue_number=447,
|
||||||
issue_number=447,
|
lock_dir=self._lock_dir.name,
|
||||||
lock_dir=lock_dir,
|
),
|
||||||
),
|
|
||||||
_sample_issue_lock(
|
_sample_issue_lock(
|
||||||
issue_number=447,
|
issue_number=447,
|
||||||
branch_name="feat/issue-447-lock-provenance",
|
branch_name="feat/issue-447-lock-provenance",
|
||||||
@@ -3750,14 +3449,14 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree_path=worktree,
|
worktree_path=worktree,
|
||||||
lock_provenance=None,
|
lock_provenance=None,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_create_pr(
|
||||||
|
title="feat: lock provenance Closes #447",
|
||||||
|
head="feat/issue-447-lock-provenance",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=worktree,
|
||||||
)
|
)
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
gitea_create_pr(
|
|
||||||
title="feat: lock provenance Closes #447",
|
|
||||||
head="feat/issue-447-lock-provenance",
|
|
||||||
remote="prgs",
|
|
||||||
worktree_path=worktree,
|
|
||||||
)
|
|
||||||
self.assertIn("lock provenance", str(ctx.exception).lower())
|
self.assertIn("lock provenance", str(ctx.exception).lower())
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@@ -3889,7 +3588,7 @@ class TestPreflightVerification(unittest.TestCase):
|
|||||||
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
|
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
mcp_server.verify_preflight_purity()
|
mcp_server.verify_preflight_purity()
|
||||||
self.assertIn("forbidden from modifying tracked workspace files", str(ctx.exception))
|
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
|
||||||
self.assertIn("reviewer_edit.py", str(ctx.exception))
|
self.assertIn("reviewer_edit.py", str(ctx.exception))
|
||||||
|
|
||||||
# Foreign pre-existing dirty state does not block when unchanged.
|
# Foreign pre-existing dirty state does not block when unchanged.
|
||||||
@@ -3955,8 +3654,7 @@ class TestPreflightVerification(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("resolved workspace", msg)
|
self.assertIn("active task workspace root", msg)
|
||||||
self.assertIn(worktree, msg)
|
self.assertIn("inspected git root", msg)
|
||||||
self.assertIn("worktree_path argument", msg)
|
self.assertIn("dirty files: task_file.py", msg)
|
||||||
self.assertIn("task_file.py", msg)
|
self.assertIn("dirty scope:", msg)
|
||||||
self.assertIn("author namespace", msg)
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Hermetic tests for merge approval head pinning (#471)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from merge_approval_gate import assess_merge_approval_head # noqa: E402
|
|
||||||
|
|
||||||
HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
|
|
||||||
HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergeApprovalGate(unittest.TestCase):
|
|
||||||
def test_fresh_approval_at_current_head(self):
|
|
||||||
result = assess_merge_approval_head(
|
|
||||||
current_head_sha=HEAD_NEW,
|
|
||||||
latest_by_reviewer={
|
|
||||||
"reviewer1": {
|
|
||||||
"verdict": "APPROVED",
|
|
||||||
"dismissed": False,
|
|
||||||
"reviewed_head_sha": HEAD_NEW,
|
|
||||||
"submitted_at": "2026-07-06T12:00:00Z",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["approval_at_current_head"])
|
|
||||||
self.assertIsNone(result["stale_approval_block_reason"])
|
|
||||||
|
|
||||||
def test_stale_approval_after_rebase(self):
|
|
||||||
result = assess_merge_approval_head(
|
|
||||||
current_head_sha=HEAD_NEW,
|
|
||||||
latest_by_reviewer={
|
|
||||||
"reviewer1": {
|
|
||||||
"verdict": "APPROVED",
|
|
||||||
"dismissed": False,
|
|
||||||
"reviewed_head_sha": HEAD_OLD,
|
|
||||||
"submitted_at": "2026-07-06T10:00:00Z",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["approval_at_current_head"])
|
|
||||||
self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD)
|
|
||||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
|
||||||
self.assertIn(HEAD_OLD, result["stale_approval_block_reason"])
|
|
||||||
self.assertIn(HEAD_NEW, result["stale_approval_block_reason"])
|
|
||||||
self.assertIn("re-review PR at current head", result["stale_approval_block_reason"])
|
|
||||||
|
|
||||||
def test_no_approval_entries(self):
|
|
||||||
result = assess_merge_approval_head(
|
|
||||||
current_head_sha=HEAD_NEW,
|
|
||||||
latest_by_reviewer={},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["approval_at_current_head"])
|
|
||||||
self.assertIsNone(result["latest_approved_head_sha"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"""Tests for namespace-scoped MCP workspace binding (#510)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import gitea_mcp_server as srv # noqa: E402
|
|
||||||
import namespace_workspace_binding as nwb # noqa: E402
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
if REPO_ROOT.parent.name == "branches":
|
|
||||||
CONTROL_ROOT = str(REPO_ROOT.parent.parent)
|
|
||||||
else:
|
|
||||||
CONTROL_ROOT = str(REPO_ROOT)
|
|
||||||
|
|
||||||
AUTHOR_DIRTY = f"{CONTROL_ROOT}/branches/mcp-author-worktree"
|
|
||||||
MERGER_CLEAN = f"{CONTROL_ROOT}/branches/merge-pr487-submit"
|
|
||||||
REVIEWER_CLEAN = f"{CONTROL_ROOT}/branches/review-pr487-submit"
|
|
||||||
RECONCILER_CLEAN = f"{CONTROL_ROOT}/branches/reconcile-pr487"
|
|
||||||
MCP_PROCESS_ROOT = CONTROL_ROOT
|
|
||||||
|
|
||||||
|
|
||||||
class TestNamespaceWorkspaceModule(unittest.TestCase):
|
|
||||||
def test_author_env_ignored_for_merger_namespace(self):
|
|
||||||
workspace, source = nwb.resolve_namespace_workspace(
|
|
||||||
role_kind="merger",
|
|
||||||
worktree_path=None,
|
|
||||||
process_project_root=MCP_PROCESS_ROOT,
|
|
||||||
env={
|
|
||||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
|
||||||
nwb.MERGER_WORKTREE_ENV: MERGER_CLEAN,
|
|
||||||
},
|
|
||||||
profile_name="gitea-merger",
|
|
||||||
)
|
|
||||||
self.assertEqual(workspace, os.path.realpath(MERGER_CLEAN))
|
|
||||||
self.assertEqual(source, f"{nwb.MERGER_WORKTREE_ENV} environment variable")
|
|
||||||
|
|
||||||
def test_author_env_ignored_for_reviewer_namespace(self):
|
|
||||||
workspace, source = nwb.resolve_namespace_workspace(
|
|
||||||
role_kind="reviewer",
|
|
||||||
worktree_path=None,
|
|
||||||
process_project_root=MCP_PROCESS_ROOT,
|
|
||||||
env={
|
|
||||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
|
||||||
nwb.REVIEWER_WORKTREE_ENV: REVIEWER_CLEAN,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(workspace, os.path.realpath(REVIEWER_CLEAN))
|
|
||||||
self.assertEqual(source, f"{nwb.REVIEWER_WORKTREE_ENV} environment variable")
|
|
||||||
|
|
||||||
def test_author_env_ignored_for_reconciler_namespace(self):
|
|
||||||
workspace, source = nwb.resolve_namespace_workspace(
|
|
||||||
role_kind="reconciler",
|
|
||||||
worktree_path=None,
|
|
||||||
process_project_root=MCP_PROCESS_ROOT,
|
|
||||||
env={
|
|
||||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
|
||||||
nwb.RECONCILER_WORKTREE_ENV: RECONCILER_CLEAN,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(workspace, os.path.realpath(RECONCILER_CLEAN))
|
|
||||||
self.assertEqual(source, f"{nwb.RECONCILER_WORKTREE_ENV} environment variable")
|
|
||||||
|
|
||||||
def test_merger_profile_maps_to_merger_namespace(self):
|
|
||||||
role = nwb.normalize_role_kind("reviewer", profile_name="gitea-merger")
|
|
||||||
self.assertEqual(role, "merger")
|
|
||||||
|
|
||||||
def test_error_message_includes_path_and_binding_source(self):
|
|
||||||
msg = nwb.format_namespace_workspace_binding_error(
|
|
||||||
role_kind="merger",
|
|
||||||
workspace_path=AUTHOR_DIRTY,
|
|
||||||
binding_source=f"{nwb.AUTHOR_WORKTREE_ENV} environment variable",
|
|
||||||
dirty_files=["gitea_mcp_server.py"],
|
|
||||||
ignored_bindings=[f"{nwb.AUTHOR_WORKTREE_ENV}={AUTHOR_DIRTY} (ignored for merger namespace)"],
|
|
||||||
)
|
|
||||||
self.assertIn(AUTHOR_DIRTY, msg)
|
|
||||||
self.assertIn("via", msg.lower())
|
|
||||||
self.assertIn(nwb.AUTHOR_WORKTREE_ENV, msg)
|
|
||||||
self.assertIn("Do not clean or reset foreign role worktrees", msg)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._saved = {
|
|
||||||
"whoami_called": srv._preflight_whoami_called,
|
|
||||||
"capability_called": srv._preflight_capability_called,
|
|
||||||
"resolved_role": srv._preflight_resolved_role,
|
|
||||||
"whoami_violation": srv._preflight_whoami_violation,
|
|
||||||
"capability_violation": srv._preflight_capability_violation,
|
|
||||||
"in_test": srv._preflight_in_test_mode,
|
|
||||||
}
|
|
||||||
srv._preflight_whoami_called = True
|
|
||||||
srv._preflight_capability_called = True
|
|
||||||
srv._preflight_whoami_violation = False
|
|
||||||
srv._preflight_capability_violation = False
|
|
||||||
srv._preflight_in_test_mode = lambda: False
|
|
||||||
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
|
|
||||||
self._env_patch.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
srv._preflight_whoami_called = self._saved["whoami_called"]
|
|
||||||
srv._preflight_capability_called = self._saved["capability_called"]
|
|
||||||
srv._preflight_resolved_role = self._saved["resolved_role"]
|
|
||||||
srv._preflight_whoami_violation = self._saved["whoami_violation"]
|
|
||||||
srv._preflight_capability_violation = self._saved["capability_violation"]
|
|
||||||
srv._preflight_in_test_mode = self._saved["in_test"]
|
|
||||||
self._env_patch.stop()
|
|
||||||
for key in (
|
|
||||||
nwb.AUTHOR_WORKTREE_ENV,
|
|
||||||
nwb.MERGER_WORKTREE_ENV,
|
|
||||||
nwb.REVIEWER_WORKTREE_ENV,
|
|
||||||
nwb.RECONCILER_WORKTREE_ENV,
|
|
||||||
nwb.ACTIVE_WORKTREE_ENV,
|
|
||||||
):
|
|
||||||
os.environ.pop(key, None)
|
|
||||||
|
|
||||||
def _merger_profile(self):
|
|
||||||
return {
|
|
||||||
"profile_name": "gitea-merger",
|
|
||||||
"allowed_operations": ["gitea.pr.merge", "gitea.read"],
|
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _reviewer_profile(self):
|
|
||||||
return {
|
|
||||||
"profile_name": "prgs-reviewer",
|
|
||||||
"allowed_operations": ["gitea.pr.approve", "gitea.pr.review", "gitea.read"],
|
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _reconciler_profile(self):
|
|
||||||
return {
|
|
||||||
"profile_name": "prgs-reconciler",
|
|
||||||
"allowed_operations": ["gitea.pr.close", "gitea.read"],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.create",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _author_profile(self):
|
|
||||||
return {
|
|
||||||
"profile_name": "prgs-author",
|
|
||||||
"allowed_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.read"],
|
|
||||||
"forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_dirty_author_worktree_does_not_block_merger_with_clean_workspace(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
|
||||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
|
||||||
srv._preflight_resolved_role = "reviewer"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_dirty_author_worktree_does_not_block_reviewer_with_clean_workspace(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
|
||||||
os.environ[nwb.REVIEWER_WORKTREE_ENV] = REVIEWER_CLEAN
|
|
||||||
srv._preflight_resolved_role = "reviewer"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reviewer_profile()):
|
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=REVIEWER_CLEAN)
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_dirty_author_worktree_does_not_block_reconciler_with_clean_workspace(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
|
||||||
os.environ[nwb.RECONCILER_WORKTREE_ENV] = RECONCILER_CLEAN
|
|
||||||
srv._preflight_resolved_role = "reconciler"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reconciler_profile()):
|
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=RECONCILER_CLEAN)
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_dirty_active_task_workspace_still_blocks_mutations(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
|
||||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
|
||||||
srv._preflight_resolved_role = "reviewer"
|
|
||||||
dirty = " M namespace_workspace_binding.py\n"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
|
||||||
with mock.patch("gitea_mcp_server._get_workspace_porcelain", return_value=dirty):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
|
||||||
self.assertIn(MERGER_CLEAN, str(ctx.exception))
|
|
||||||
self.assertIn("binding", str(ctx.exception).lower())
|
|
||||||
|
|
||||||
def test_root_workspace_mutation_still_blocked_for_author(self):
|
|
||||||
srv._preflight_resolved_role = "author"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._author_profile()):
|
|
||||||
with mock.patch(
|
|
||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value={"current_branch": "master"},
|
|
||||||
):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
srv.verify_preflight_purity("prgs")
|
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_pr487_style_merge_binds_clean_merger_workspace(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
|
||||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
|
||||||
srv._preflight_resolved_role = "reviewer"
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
|
||||||
resolved = srv._verify_role_mutation_workspace("prgs")
|
|
||||||
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
|
|
||||||
@@ -95,15 +95,10 @@ class PermissionReportBase(unittest.TestCase):
|
|||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
self._write_config(CONFIG)
|
self._write_config(CONFIG)
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
self._remotes.stop()
|
self._remotes.stop()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
gitea_config._active_profile_override = None
|
gitea_config._active_profile_override = None
|
||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
@@ -235,9 +230,7 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
|||||||
return PR_PAYLOAD
|
return PR_PAYLOAD
|
||||||
mock_api.side_effect = fake_api
|
mock_api.side_effect = fake_api
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||||
from tests.test_mcp_server import _seed_ready_review_decision
|
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
|
||||||
|
|
||||||
_seed_ready_review_decision(42, "approve", remote="prgs")
|
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
res = mcp_server.gitea_submit_pr_review(
|
res = mcp_server.gitea_submit_pr_review(
|
||||||
pr_number=42, action="approve", body="lgtm", remote="prgs",
|
pr_number=42, action="approve", body="lgtm", remote="prgs",
|
||||||
@@ -255,8 +248,6 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
|||||||
return {"login": "author-user"}
|
return {"login": "author-user"}
|
||||||
return PR_PAYLOAD
|
return PR_PAYLOAD
|
||||||
mock_api.side_effect = fake_api
|
mock_api.side_effect = fake_api
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
res = mcp_server.gitea_merge_pr(
|
res = mcp_server.gitea_merge_pr(
|
||||||
pr_number=42, confirmation="MERGE PR 42",
|
pr_number=42, confirmation="MERGE PR 42",
|
||||||
@@ -281,9 +272,7 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
|
|||||||
return PR_PAYLOAD
|
return PR_PAYLOAD
|
||||||
mock_api.side_effect = fake_api
|
mock_api.side_effect = fake_api
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||||
from tests.test_mcp_server import _seed_ready_review_decision
|
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
|
||||||
|
|
||||||
_seed_ready_review_decision(42, "comment", remote="prgs")
|
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
res = mcp_server.gitea_submit_pr_review(
|
res = mcp_server.gitea_submit_pr_review(
|
||||||
pr_number=42, action="comment", body="finding", remote="prgs",
|
pr_number=42, action="comment", body="finding", remote="prgs",
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
|
||||||
from post_merge_cleanup_proof import ( # noqa: E402
|
|
||||||
CLEANUP_SKIPPED,
|
|
||||||
assess_post_merge_cleanup_proof,
|
|
||||||
)
|
|
||||||
|
|
||||||
MERGE_SHA = "a" * 40
|
|
||||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
|
||||||
WORKTREE = "branches/review-pr374-conflicts"
|
|
||||||
|
|
||||||
|
|
||||||
def _full_remote_cleanup_report(**overrides):
|
|
||||||
fields = {
|
|
||||||
"Task": "review PR #374",
|
|
||||||
"Merge result": "merged",
|
|
||||||
"Merge commit SHA": MERGE_SHA,
|
|
||||||
"Cleanup status": "remote branch deleted",
|
|
||||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
|
||||||
"Merged PR head branch": HEAD_BRANCH,
|
|
||||||
"Deleted branch": HEAD_BRANCH,
|
|
||||||
"Branch protection": "none",
|
|
||||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
|
||||||
"Active heartbeat/claim/lease": "none",
|
|
||||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
|
||||||
}
|
|
||||||
fields.update(overrides)
|
|
||||||
lines = ["## Controller Handoff", ""]
|
|
||||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _full_worktree_cleanup_report(**overrides):
|
|
||||||
fields = {
|
|
||||||
"Task": "review PR #374",
|
|
||||||
"Merge result": "merged",
|
|
||||||
"Cleanup status": "local worktree removed",
|
|
||||||
"Removed worktree path": WORKTREE,
|
|
||||||
"Pre-removal tracked state": "clean",
|
|
||||||
"Pre-removal untracked state": "clean",
|
|
||||||
"Git worktree list after removal": "only main checkout listed",
|
|
||||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
|
||||||
}
|
|
||||||
fields.update(overrides)
|
|
||||||
lines = ["## Controller Handoff", ""]
|
|
||||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
|
||||||
def test_no_cleanup_claim_passes(self):
|
|
||||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
def test_missing_capability_proof_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(
|
|
||||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
|
||||||
)
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_unmerged_pr_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_wrong_branch_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(
|
|
||||||
**{"Deleted branch": "feat/other-branch"}
|
|
||||||
)
|
|
||||||
report += "\nDeleted branch does not match merged PR head branch"
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_protected_branch_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_open_pr_reference_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(
|
|
||||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
|
||||||
)
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_active_claim_blocked(self):
|
|
||||||
report = _full_remote_cleanup_report(
|
|
||||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
|
||||||
)
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_dirty_worktree_blocked(self):
|
|
||||||
report = _full_worktree_cleanup_report(
|
|
||||||
**{"Pre-removal tracked state": "dirty"}
|
|
||||||
)
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_foreign_worktree_blocked(self):
|
|
||||||
report = _full_worktree_cleanup_report(
|
|
||||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
|
||||||
)
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_cleanup_skipped_with_blocker_passes(self):
|
|
||||||
report = "\n".join([
|
|
||||||
"## Controller Handoff",
|
|
||||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
|
||||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
|
||||||
])
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
|
||||||
|
|
||||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
|
||||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
|
||||||
result = assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_full_remote_cleanup_passes(self):
|
|
||||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertTrue(result["remote_delete_claimed"])
|
|
||||||
|
|
||||||
def test_full_worktree_cleanup_passes(self):
|
|
||||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertTrue(result["worktree_remove_claimed"])
|
|
||||||
|
|
||||||
def test_validator_integration_blocks_unproven_delete(self):
|
|
||||||
report = (
|
|
||||||
"## Controller Handoff\n"
|
|
||||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
|
||||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
|
||||||
|
|
||||||
def test_validator_integration_allows_skipped(self):
|
|
||||||
report = "\n".join([
|
|
||||||
"## Controller Handoff",
|
|
||||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
|
||||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
|
||||||
])
|
|
||||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
|
||||||
cleanup_findings = [
|
|
||||||
f for f in result["findings"]
|
|
||||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
|
||||||
]
|
|
||||||
self.assertEqual(cleanup_findings, [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"""Post-merge moot reviewer-lease handling (#515).
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
a. Reviewer-lease acquisition/adoption is refused on an already-merged/closed
|
|
||||||
PR (fail closed, no mutation).
|
|
||||||
b. The post-merge moot cleanup path is safe and idempotent.
|
|
||||||
c. An active foreign lease on an *open* PR is never force-cleaned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import reviewer_pr_lease as leases # noqa: E402
|
|
||||||
from mcp_server import ( # noqa: E402
|
|
||||||
gitea_acquire_reviewer_pr_lease,
|
|
||||||
gitea_cleanup_post_merge_moot_lease,
|
|
||||||
)
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
|
||||||
MERGER_ENV = {
|
|
||||||
"GITEA_PROFILE_NAME": "prgs-merger",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
|
||||||
}
|
|
||||||
PR = 487
|
|
||||||
ISSUE = 485
|
|
||||||
SESSION = "97274-676d20a825c4"
|
|
||||||
|
|
||||||
|
|
||||||
def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed",
|
|
||||||
candidate_head="a" * 40):
|
|
||||||
body = leases.format_lease_body(
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
pr_number=pr_number,
|
|
||||||
issue_number=ISSUE,
|
|
||||||
reviewer_identity="sysadmin",
|
|
||||||
profile="prgs-reviewer",
|
|
||||||
session_id=session_id,
|
|
||||||
worktree="branches/review-pr487",
|
|
||||||
phase=phase,
|
|
||||||
candidate_head=candidate_head,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="b" * 40,
|
|
||||||
last_activity=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
return {"id": 6603, "body": body, "user": {"login": "sysadmin"}}
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Pure logic
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
class TestAcquireRefusedOnMergedPR(unittest.TestCase):
|
|
||||||
def test_acquire_refused_when_pr_merged_or_closed(self):
|
|
||||||
result = leases.assess_acquire_lease(
|
|
||||||
[_lease_comment()],
|
|
||||||
pr_number=PR,
|
|
||||||
reviewer_identity="sysadmin",
|
|
||||||
profile="prgs-merger",
|
|
||||||
session_id="new-session",
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
issue_number=ISSUE,
|
|
||||||
worktree="branches/merge-pr487",
|
|
||||||
candidate_head="a" * 40,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="b" * 40,
|
|
||||||
pr_merged_or_closed=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["acquire_allowed"])
|
|
||||||
self.assertTrue(result["post_merge_moot"])
|
|
||||||
self.assertIsNone(result["lease_body"])
|
|
||||||
self.assertTrue(any("post_merge_moot" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_acquire_still_allowed_on_open_pr_without_flag(self):
|
|
||||||
result = leases.assess_acquire_lease(
|
|
||||||
[],
|
|
||||||
pr_number=PR,
|
|
||||||
reviewer_identity="sysadmin",
|
|
||||||
profile="prgs-reviewer",
|
|
||||||
session_id="s1",
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
issue_number=ISSUE,
|
|
||||||
worktree="branches/review-pr487",
|
|
||||||
candidate_head="a" * 40,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="b" * 40,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["acquire_allowed"])
|
|
||||||
self.assertFalse(result["post_merge_moot"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestPostMergeMootAssessment(unittest.TestCase):
|
|
||||||
def test_merged_pr_with_active_lease_is_moot_and_cleanable(self):
|
|
||||||
a = leases.assess_post_merge_moot_lease(
|
|
||||||
[_lease_comment()],
|
|
||||||
pr_number=PR,
|
|
||||||
pr_merged=True,
|
|
||||||
pr_state="closed",
|
|
||||||
merge_commit_sha="c" * 40,
|
|
||||||
)
|
|
||||||
self.assertTrue(a["pr_merged_or_closed"])
|
|
||||||
self.assertTrue(a["is_moot"])
|
|
||||||
self.assertTrue(a["cleanup_allowed"])
|
|
||||||
self.assertIsNotNone(a["release_body"])
|
|
||||||
self.assertIn("phase: released", a["release_body"])
|
|
||||||
self.assertIn("blocker: post-merge-moot", a["release_body"])
|
|
||||||
|
|
||||||
def test_open_pr_active_lease_never_cleaned(self):
|
|
||||||
a = leases.assess_post_merge_moot_lease(
|
|
||||||
[_lease_comment()],
|
|
||||||
pr_number=PR,
|
|
||||||
pr_merged=False,
|
|
||||||
pr_state="open",
|
|
||||||
)
|
|
||||||
self.assertFalse(a["pr_merged_or_closed"])
|
|
||||||
self.assertFalse(a["is_moot"])
|
|
||||||
self.assertFalse(a["cleanup_allowed"])
|
|
||||||
self.assertIsNone(a["release_body"])
|
|
||||||
self.assertTrue(any("still open" in r for r in a["reasons"]))
|
|
||||||
|
|
||||||
def test_merged_pr_without_lease_nothing_to_clean(self):
|
|
||||||
a = leases.assess_post_merge_moot_lease(
|
|
||||||
[], pr_number=PR, pr_merged=True, pr_state="closed")
|
|
||||||
self.assertTrue(a["pr_merged_or_closed"])
|
|
||||||
self.assertFalse(a["is_moot"])
|
|
||||||
self.assertFalse(a["cleanup_allowed"])
|
|
||||||
self.assertTrue(any("nothing to clean" in r for r in a["reasons"]))
|
|
||||||
|
|
||||||
def test_cleanup_is_idempotent(self):
|
|
||||||
"""After the released marker is posted, a re-assess finds nothing to clean."""
|
|
||||||
first = leases.assess_post_merge_moot_lease(
|
|
||||||
[_lease_comment()], pr_number=PR, pr_merged=True, pr_state="closed")
|
|
||||||
self.assertTrue(first["cleanup_allowed"])
|
|
||||||
released_comment = {
|
|
||||||
"id": 7000, "body": first["release_body"], "user": {"login": "sysadmin"}}
|
|
||||||
second = leases.assess_post_merge_moot_lease(
|
|
||||||
[_lease_comment(), released_comment],
|
|
||||||
pr_number=PR, pr_merged=True, pr_state="closed")
|
|
||||||
self.assertFalse(second["is_moot"])
|
|
||||||
self.assertFalse(second["cleanup_allowed"])
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Server tools
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999):
|
|
||||||
"""Build an api_request side effect keyed on method + url."""
|
|
||||||
calls = {"post": []}
|
|
||||||
|
|
||||||
def _side(method, url, auth=None, payload=None, *a, **k):
|
|
||||||
m = (method or "").upper()
|
|
||||||
if m == "POST":
|
|
||||||
calls["post"].append({"url": url, "payload": payload})
|
|
||||||
return {"id": posted_id}
|
|
||||||
if "/comments" in url:
|
|
||||||
return list(comments)
|
|
||||||
if "/pulls/" in url:
|
|
||||||
pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40}
|
|
||||||
if pr_merged:
|
|
||||||
pr["merged"] = True
|
|
||||||
pr["merged_at"] = "2026-07-08T07:46:04Z"
|
|
||||||
return pr
|
|
||||||
if "/issues/" in url:
|
|
||||||
return {"state": "closed" if pr_merged else "open", "number": ISSUE}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return _side, calls
|
|
||||||
|
|
||||||
|
|
||||||
class TestAcquireToolRefusesMergedPR(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
leases.clear_session_lease()
|
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
|
||||||
@patch("mcp_server._authenticated_username", return_value="sysadmin")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
def test_acquire_tool_fails_closed_on_merged_pr_without_posting(
|
|
||||||
self, mock_api, _auth, _user, _purity):
|
|
||||||
side, calls = _api_side_effect(
|
|
||||||
pr_state="closed", pr_merged=True, comments=[])
|
|
||||||
mock_api.side_effect = side
|
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
|
||||||
result = gitea_acquire_reviewer_pr_lease(
|
|
||||||
pr_number=PR,
|
|
||||||
worktree="branches/merge-pr487",
|
|
||||||
candidate_head="a" * 40,
|
|
||||||
issue_number=ISSUE,
|
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertFalse(result["success"])
|
|
||||||
self.assertFalse(result["acquired"])
|
|
||||||
self.assertTrue(result.get("post_merge_moot"))
|
|
||||||
self.assertEqual(calls["post"], [], "must not post a lease comment")
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupTool(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
leases.clear_session_lease()
|
|
||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
def test_read_only_reports_moot_without_mutating(self, mock_api, _auth):
|
|
||||||
side, calls = _api_side_effect(
|
|
||||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
|
||||||
mock_api.side_effect = side
|
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
|
||||||
result = gitea_cleanup_post_merge_moot_lease(
|
|
||||||
pr_number=PR, apply=False, remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
self.assertTrue(result["pr_merged_or_closed"])
|
|
||||||
self.assertTrue(result["lease_moot"])
|
|
||||||
self.assertFalse(result["cleanup_performed"])
|
|
||||||
self.assertTrue(result["no_merge_or_adoption"])
|
|
||||||
self.assertEqual(result["mode"], "read_only")
|
|
||||||
self.assertEqual(calls["post"], [])
|
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
def test_apply_posts_released_marker_on_merged_pr(
|
|
||||||
self, mock_api, _auth, _purity):
|
|
||||||
side, calls = _api_side_effect(
|
|
||||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
|
||||||
mock_api.side_effect = side
|
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
|
||||||
result = gitea_cleanup_post_merge_moot_lease(
|
|
||||||
pr_number=PR, apply=True, remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
self.assertTrue(result["cleanup_performed"])
|
|
||||||
self.assertEqual(result["released_comment_id"], 9999)
|
|
||||||
self.assertEqual(len(calls["post"]), 1)
|
|
||||||
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
|
||||||
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
|
||||||
|
|
||||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
def test_apply_refuses_to_clean_open_pr(self, mock_api, _auth, _purity):
|
|
||||||
side, calls = _api_side_effect(
|
|
||||||
pr_state="open", pr_merged=False, comments=[_lease_comment()])
|
|
||||||
mock_api.side_effect = side
|
|
||||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
|
||||||
result = gitea_cleanup_post_merge_moot_lease(
|
|
||||||
pr_number=PR, apply=True, remote="prgs")
|
|
||||||
self.assertFalse(result["cleanup_performed"])
|
|
||||||
self.assertFalse(result["pr_merged_or_closed"])
|
|
||||||
self.assertEqual(calls["post"], [], "never force-clean an open PR lease")
|
|
||||||
self.assertTrue(
|
|
||||||
any("still open" in r for r in result.get("cleanup_skipped_reason", [])))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from mcp_server import ( # noqa: E402
|
|
||||||
_list_pr_lease_comments,
|
|
||||||
gitea_list_issue_comments,
|
|
||||||
)
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
|
||||||
AUTHOR_ENV = {
|
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
|
||||||
mock_api.return_value = {"message": "Unauthorized"}
|
|
||||||
result = _list_pr_lease_comments(
|
|
||||||
12, remote="prgs", host=None, org=None, repo=None)
|
|
||||||
self.assertEqual(result, [])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
|
|
||||||
mock_api.return_value = None
|
|
||||||
result = _list_pr_lease_comments(
|
|
||||||
12, remote="prgs", host=None, org=None, repo=None)
|
|
||||||
self.assertEqual(result, [])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
|
|
||||||
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
|
|
||||||
mock_api.return_value = [comment]
|
|
||||||
result = _list_pr_lease_comments(
|
|
||||||
12, remote="prgs", host=None, org=None, repo=None, limit=5)
|
|
||||||
self.assertEqual(result, [comment])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
|
||||||
mock_api.return_value = {"message": "Unauthorized"}
|
|
||||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
self.assertEqual(result["comments"], [])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api):
|
|
||||||
mock_api.return_value = [
|
|
||||||
{
|
|
||||||
"id": 101,
|
|
||||||
"user": {"login": "alice"},
|
|
||||||
"body": "hello",
|
|
||||||
"created_at": "2026-07-03T00:00:00Z",
|
|
||||||
"updated_at": "2026-07-03T01:00:00Z",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
self.assertEqual(len(result["comments"]), 1)
|
|
||||||
self.assertEqual(result["comments"][0]["author"], "alice")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -11,7 +11,6 @@ from mcp_server import (
|
|||||||
gitea_view_pr,
|
gitea_view_pr,
|
||||||
gitea_review_pr,
|
gitea_review_pr,
|
||||||
gitea_check_pr_eligibility,
|
gitea_check_pr_eligibility,
|
||||||
gitea_load_review_workflow,
|
|
||||||
)
|
)
|
||||||
import gitea_config
|
import gitea_config
|
||||||
|
|
||||||
@@ -129,29 +128,18 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
from mcp_server import init_review_decision_lock
|
|
||||||
from tests.test_mcp_server import (
|
|
||||||
FULL_HEAD_SHA,
|
|
||||||
_install_owned_reviewer_lease,
|
|
||||||
_seed_ready_review_decision,
|
|
||||||
)
|
|
||||||
import reviewer_pr_lease
|
|
||||||
|
|
||||||
head_sha = FULL_HEAD_SHA
|
|
||||||
mock_fetch.return_value = _final_page_fetch([
|
mock_fetch.return_value = _final_page_fetch([
|
||||||
{"number": 1, "title": "PR 1", "state": "open",
|
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
|
||||||
"head": {"ref": "branch1", "sha": head_sha},
|
|
||||||
"base": {"ref": "master"}, "mergeable": True,
|
|
||||||
"user": {"login": "other_user"}}
|
|
||||||
])
|
])
|
||||||
|
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
|
||||||
|
# POST review (#244: state + visible-verdict GET reviews).
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "reviewer1"},
|
{"login": "reviewer1"},
|
||||||
{"login": "reviewer1"},
|
{"login": "reviewer1"},
|
||||||
{
|
{
|
||||||
"user": {"login": "other_user"},
|
"user": {"login": "other_user"},
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"head": {"sha": head_sha},
|
"head": {"sha": "abc1"},
|
||||||
"mergeable": True,
|
"mergeable": True,
|
||||||
},
|
},
|
||||||
{"id": 100, "state": "APPROVED"},
|
{"id": 100, "state": "APPROVED"},
|
||||||
@@ -160,27 +148,17 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"id": 100,
|
"id": 100,
|
||||||
"user": {"login": "reviewer1"},
|
"user": {"login": "reviewer1"},
|
||||||
"state": "APPROVED",
|
"state": "APPROVED",
|
||||||
"commit_id": head_sha,
|
"commit_id": "abc1",
|
||||||
"submitted_at": "2026-07-06T10:00:00Z",
|
"submitted_at": "2026-07-06T10:00:00Z",
|
||||||
"dismissed": False,
|
"dismissed": False,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
|
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||||
patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
init_review_decision_lock("prgs", "review_pr")
|
||||||
init_review_decision_lock("prgs", "review_pr")
|
gitea_mark_final_review_decision(1, "approve", remote="prgs")
|
||||||
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
|
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
|
||||||
lease_patch = _install_owned_reviewer_lease(
|
|
||||||
1, head_sha=head_sha, session_id="inventory-review-lease",
|
|
||||||
)
|
|
||||||
lease_patch.start()
|
|
||||||
self.addCleanup(lease_patch.stop)
|
|
||||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
|
||||||
result = gitea_review_pr(
|
|
||||||
pr_number=1, event="APPROVE", remote="prgs",
|
|
||||||
final_review_decision_ready=True,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
||||||
self.assertIn("Repository:", result["message"])
|
self.assertIn("Repository:", result["message"])
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Regression tests for conflict-fix and reviewer PR work leases (#399)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from pr_work_lease import ( # noqa: E402
|
|
||||||
CONFLICT_FIX_LEASE_MARKER,
|
|
||||||
REVIEWER_LEASE_MARKER,
|
|
||||||
assess_conflict_fix_final_report,
|
|
||||||
assess_conflict_fix_push,
|
|
||||||
assess_head_sha_equality,
|
|
||||||
assess_reviewer_mutation_blocked,
|
|
||||||
assess_reviewer_stale_head_final_report,
|
|
||||||
format_conflict_fix_lease_body,
|
|
||||||
parse_conflict_fix_lease_comment,
|
|
||||||
parse_reviewer_lease_comment,
|
|
||||||
)
|
|
||||||
|
|
||||||
HEAD_A = "a" * 40
|
|
||||||
HEAD_B = "b" * 40
|
|
||||||
NOW = datetime(2026, 7, 7, 15, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _reviewer_lease_body(*, phase: str = "validating", expires_minutes: int = 60) -> str:
|
|
||||||
expires = (NOW + timedelta(minutes=expires_minutes)).isoformat().replace("+00:00", "Z")
|
|
||||||
return "\n".join([
|
|
||||||
REVIEWER_LEASE_MARKER,
|
|
||||||
"pr: #376",
|
|
||||||
"phase: " + phase,
|
|
||||||
f"candidate_head: {HEAD_A}",
|
|
||||||
f"expires_at: {expires}",
|
|
||||||
"profile: prgs-reviewer",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _conflict_fix_body(*, phase: str = "claimed", worktree: str = "branches/fix-376") -> str:
|
|
||||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
|
||||||
return "\n".join([
|
|
||||||
CONFLICT_FIX_LEASE_MARKER,
|
|
||||||
"pr: #376",
|
|
||||||
f"phase: {phase}",
|
|
||||||
f"worktree: {worktree}",
|
|
||||||
f"head_before: {HEAD_A}",
|
|
||||||
f"expires_at: {expires}",
|
|
||||||
"profile: prgs-author",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class TestLeaseParsing(unittest.TestCase):
|
|
||||||
def test_parse_reviewer_lease(self):
|
|
||||||
parsed = parse_reviewer_lease_comment(_reviewer_lease_body())
|
|
||||||
self.assertEqual(parsed["pr_number"], 376)
|
|
||||||
self.assertEqual(parsed["phase"], "validating")
|
|
||||||
self.assertEqual(parsed["candidate_head"], HEAD_A)
|
|
||||||
|
|
||||||
def test_parse_conflict_fix_lease(self):
|
|
||||||
parsed = parse_conflict_fix_lease_comment(_conflict_fix_body())
|
|
||||||
self.assertEqual(parsed["pr_number"], 376)
|
|
||||||
self.assertEqual(parsed["phase"], "claimed")
|
|
||||||
|
|
||||||
|
|
||||||
class TestConflictFixPushGate(unittest.TestCase):
|
|
||||||
def test_blocks_push_during_active_reviewer_lease(self):
|
|
||||||
comments = [{"body": _reviewer_lease_body()}]
|
|
||||||
result = assess_conflict_fix_push(
|
|
||||||
pr_number=376,
|
|
||||||
comments=comments,
|
|
||||||
branch_head_before=HEAD_A,
|
|
||||||
branch_head_after=HEAD_B,
|
|
||||||
worktree_path="branches/fix-376",
|
|
||||||
push_cwd="/proj/branches/fix-376",
|
|
||||||
is_fast_forward=True,
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["push_allowed"])
|
|
||||||
self.assertTrue(any("reviewer lease" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_rejects_non_fast_forward(self):
|
|
||||||
result = assess_conflict_fix_push(
|
|
||||||
pr_number=376,
|
|
||||||
comments=[],
|
|
||||||
branch_head_before=HEAD_A,
|
|
||||||
branch_head_after=HEAD_B,
|
|
||||||
worktree_path="branches/fix-376",
|
|
||||||
push_cwd="/proj/branches/fix-376",
|
|
||||||
is_fast_forward=False,
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["push_allowed"])
|
|
||||||
self.assertTrue(any("non-fast-forward" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_wrong_cwd_push_attempt(self):
|
|
||||||
result = assess_conflict_fix_push(
|
|
||||||
pr_number=376,
|
|
||||||
comments=[],
|
|
||||||
branch_head_before=HEAD_A,
|
|
||||||
branch_head_after=HEAD_B,
|
|
||||||
worktree_path="branches/fix-376",
|
|
||||||
push_cwd="/proj/master",
|
|
||||||
is_fast_forward=True,
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["push_allowed"])
|
|
||||||
self.assertTrue(any("cwd" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_sibling_conflict_fix_collision(self):
|
|
||||||
comments = [{"body": _conflict_fix_body(phase="pushing", worktree="branches/other")}]
|
|
||||||
result = assess_conflict_fix_push(
|
|
||||||
pr_number=376,
|
|
||||||
comments=comments,
|
|
||||||
branch_head_before=HEAD_A,
|
|
||||||
branch_head_after=HEAD_B,
|
|
||||||
worktree_path="branches/fix-376",
|
|
||||||
push_cwd="/proj/branches/fix-376",
|
|
||||||
is_fast_forward=True,
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["push_allowed"])
|
|
||||||
self.assertTrue(any("sibling conflict-fix" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerMutationGate(unittest.TestCase):
|
|
||||||
def test_blocks_review_during_conflict_fix(self):
|
|
||||||
comments = [{"body": _conflict_fix_body(phase="pushing")}]
|
|
||||||
result = assess_reviewer_mutation_blocked(
|
|
||||||
pr_number=376,
|
|
||||||
comments=comments,
|
|
||||||
reviewed_head_sha=HEAD_A,
|
|
||||||
live_head_sha=HEAD_A,
|
|
||||||
mutation="approve",
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["mutation_allowed"])
|
|
||||||
self.assertTrue(any("conflict-fix lease" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_stale_head_blocks_approval(self):
|
|
||||||
result = assess_reviewer_mutation_blocked(
|
|
||||||
pr_number=376,
|
|
||||||
comments=[],
|
|
||||||
reviewed_head_sha=HEAD_A,
|
|
||||||
live_head_sha=HEAD_B,
|
|
||||||
mutation="merge",
|
|
||||||
now=NOW,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["mutation_allowed"])
|
|
||||||
self.assertTrue(result["head_check"]["head_changed"])
|
|
||||||
|
|
||||||
def test_head_equality_required_fields(self):
|
|
||||||
result = assess_head_sha_equality(HEAD_A, HEAD_B)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["head_changed"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestFinalReportProof(unittest.TestCase):
|
|
||||||
def test_reviewer_stale_head_report_requires_fields(self):
|
|
||||||
result = assess_reviewer_stale_head_final_report("no head proof here")
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_reviewer_stale_head_report_passes(self):
|
|
||||||
report = "\n".join([
|
|
||||||
f"Reviewed head SHA: {HEAD_A}",
|
|
||||||
f"Final live head SHA before approval: {HEAD_A}",
|
|
||||||
f"Final live head SHA before merge: {HEAD_A}",
|
|
||||||
"Push occurred during validation: no",
|
|
||||||
])
|
|
||||||
result = assess_reviewer_stale_head_final_report(report)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
def test_conflict_fix_report_requires_fields(self):
|
|
||||||
result = assess_conflict_fix_final_report("incomplete")
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
|
|
||||||
def test_conflict_fix_report_passes(self):
|
|
||||||
report = "\n".join([
|
|
||||||
f"Branch head before push: {HEAD_A}",
|
|
||||||
f"Branch head after push: {HEAD_B}",
|
|
||||||
"Active reviewer lease status: none",
|
|
||||||
"Whether push was fast-forward: yes",
|
|
||||||
"Whether any reviewer was active: no",
|
|
||||||
])
|
|
||||||
result = assess_conflict_fix_final_report(report)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatLease(unittest.TestCase):
|
|
||||||
def test_format_conflict_fix_lease_includes_marker(self):
|
|
||||||
body = format_conflict_fix_lease_body(
|
|
||||||
pr_number=376,
|
|
||||||
branch="feat/x",
|
|
||||||
worktree="branches/fix-376",
|
|
||||||
profile="prgs-author",
|
|
||||||
head_before=HEAD_A,
|
|
||||||
)
|
|
||||||
self.assertIn(CONFLICT_FIX_LEASE_MARKER, body)
|
|
||||||
parsed = parse_conflict_fix_lease_comment(body)
|
|
||||||
self.assertEqual(parsed["pr_number"], 376)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""#469: capability preflight survives interleaved read-only whoami calls."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import gitea_mcp_server as mcp_server
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreflightReadSurvival(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.orig_whoami = mcp_server._preflight_whoami_called
|
|
||||||
self.orig_capability = mcp_server._preflight_capability_called
|
|
||||||
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
|
|
||||||
self.orig_capability_violation = mcp_server._preflight_capability_violation
|
|
||||||
self.orig_resolved_role = mcp_server._preflight_resolved_role
|
|
||||||
self.orig_resolved_task = mcp_server._preflight_resolved_task
|
|
||||||
self.orig_process_start = mcp_server._process_start_porcelain
|
|
||||||
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
|
|
||||||
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
|
|
||||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
|
||||||
if key in os.environ:
|
|
||||||
del os.environ[key]
|
|
||||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
|
||||||
mcp_server._preflight_whoami_called = False
|
|
||||||
mcp_server._preflight_capability_called = False
|
|
||||||
mcp_server._preflight_whoami_violation = False
|
|
||||||
mcp_server._preflight_capability_violation = False
|
|
||||||
mcp_server._preflight_resolved_role = None
|
|
||||||
mcp_server._preflight_resolved_task = None
|
|
||||||
mcp_server._process_start_porcelain = ""
|
|
||||||
mcp_server._preflight_whoami_baseline_porcelain = None
|
|
||||||
mcp_server._preflight_capability_baseline_porcelain = None
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
mcp_server._preflight_whoami_called = self.orig_whoami
|
|
||||||
mcp_server._preflight_capability_called = self.orig_capability
|
|
||||||
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
|
|
||||||
mcp_server._preflight_capability_violation = self.orig_capability_violation
|
|
||||||
mcp_server._preflight_resolved_role = self.orig_resolved_role
|
|
||||||
mcp_server._preflight_resolved_task = self.orig_resolved_task
|
|
||||||
mcp_server._process_start_porcelain = self.orig_process_start
|
|
||||||
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
|
|
||||||
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
|
|
||||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
|
||||||
if key in os.environ:
|
|
||||||
del os.environ[key]
|
|
||||||
|
|
||||||
def test_interleaved_whoami_preserves_capability(self):
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check(
|
|
||||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
|
||||||
)
|
|
||||||
self.assertTrue(mcp_server._preflight_capability_called)
|
|
||||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
|
||||||
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
self.assertTrue(mcp_server._preflight_capability_called)
|
|
||||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
|
||||||
|
|
||||||
mcp_server.verify_preflight_purity(task="close_pr")
|
|
||||||
self.assertFalse(mcp_server._preflight_capability_called)
|
|
||||||
|
|
||||||
def test_missing_capability_still_fails_closed(self):
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
mcp_server.verify_preflight_purity(task="close_pr")
|
|
||||||
self.assertIn("has not been resolved", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_task_mismatch_fails_closed(self):
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check(
|
|
||||||
"capability", resolved_role="author", resolved_task="create_issue"
|
|
||||||
)
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
mcp_server.verify_preflight_purity(task="close_pr")
|
|
||||||
self.assertIn("task mismatch", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_capability_consumed_after_mutation_gate(self):
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check(
|
|
||||||
"capability", resolved_role="author", resolved_task="create_issue"
|
|
||||||
)
|
|
||||||
mcp_server.verify_preflight_purity(task="create_issue")
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
mcp_server.verify_preflight_purity(task="create_issue")
|
|
||||||
self.assertIn("has not been resolved", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_whoami_recovery_after_violation_clears_capability(self):
|
|
||||||
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
self.assertTrue(mcp_server._preflight_whoami_violation)
|
|
||||||
|
|
||||||
del os.environ["GITEA_TEST_FORCE_DIRTY"]
|
|
||||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
self.assertFalse(mcp_server._preflight_whoami_violation)
|
|
||||||
self.assertFalse(mcp_server._preflight_capability_called)
|
|
||||||
|
|
||||||
mcp_server.record_preflight_check(
|
|
||||||
"capability", resolved_role="reviewer", resolved_task="review_pr"
|
|
||||||
)
|
|
||||||
mcp_server.verify_preflight_purity(task="review_pr")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Reconciler close_pr must not require author branches/ worktree (#468)."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import gitea_mcp_server as srv
|
|
||||||
|
|
||||||
FAKE_AUTH = "token test"
|
|
||||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
|
||||||
|
|
||||||
RECONCILER_PROFILE = {
|
|
||||||
"profile_name": "prgs-reconciler",
|
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
|
||||||
"forbidden_operations": [
|
|
||||||
"gitea.pr.approve",
|
|
||||||
"gitea.pr.merge",
|
|
||||||
"gitea.pr.review",
|
|
||||||
"gitea.pr.create",
|
|
||||||
"gitea.branch.push",
|
|
||||||
"gitea.repo.commit",
|
|
||||||
],
|
|
||||||
"audit_label": "prgs-reconciler",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
srv._preflight_whoami_called = True
|
|
||||||
srv._preflight_capability_called = True
|
|
||||||
srv._preflight_whoami_violation = False
|
|
||||||
srv._preflight_capability_violation = False
|
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
|
||||||
srv._preflight_in_test_mode = lambda: False
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
|
||||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
|
||||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
|
||||||
@patch("gitea_mcp_server.api_request")
|
|
||||||
def test_reconciler_close_pr_from_control_checkout_succeeds(
|
|
||||||
self, mock_api, _profile, _ns, _auth
|
|
||||||
):
|
|
||||||
srv._preflight_resolved_role = "reconciler"
|
|
||||||
mock_api.return_value = {
|
|
||||||
"number": 414,
|
|
||||||
"title": "old",
|
|
||||||
"body": "",
|
|
||||||
"state": "closed",
|
|
||||||
"html_url": "https://gitea.example.com/pulls/414",
|
|
||||||
}
|
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
|
||||||
with patch.dict(os.environ, {}, clear=False):
|
|
||||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
|
||||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
|
||||||
result = srv.gitea_edit_pr(414, state="closed", remote="prgs")
|
|
||||||
self.assertTrue(result["success"])
|
|
||||||
self.assertEqual(result["state"], "closed")
|
|
||||||
mock_api.assert_called_once()
|
|
||||||
|
|
||||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
|
||||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
|
||||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
|
||||||
@patch(
|
|
||||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
|
||||||
return_value=(True, []),
|
|
||||||
)
|
|
||||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch(
|
|
||||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
||||||
return_value={"current_branch": "master"},
|
|
||||||
)
|
|
||||||
def test_author_create_issue_still_blocked_on_control_checkout(
|
|
||||||
self, _git, _get_all, _role, _ns, _prof, _auth
|
|
||||||
):
|
|
||||||
srv._preflight_resolved_role = "author"
|
|
||||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
srv.gitea_create_issue(title="Test", body="body")
|
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -115,22 +115,6 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase):
|
|||||||
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
|
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
|
||||||
self.assertFalse(result["has_blocking_change_requests"])
|
self.assertFalse(result["has_blocking_change_requests"])
|
||||||
self.assertTrue(result["approval_visible"])
|
self.assertTrue(result["approval_visible"])
|
||||||
self.assertTrue(result["approval_at_current_head"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
|
||||||
@patch("mcp_server.get_profile")
|
|
||||||
def test_stale_approval_not_at_current_head(self, mock_get_profile, _auth, mock_api):
|
|
||||||
mock_get_profile.return_value = self._profile()
|
|
||||||
mock_api.side_effect = [
|
|
||||||
_pr_details(head_sha="newhead3"),
|
|
||||||
[_review("reviewer1", "APPROVED", commit_id="oldhead1")],
|
|
||||||
]
|
|
||||||
result = gitea_get_pr_review_feedback(pr_number=5, remote="prgs")
|
|
||||||
self.assertTrue(result["approval_visible"])
|
|
||||||
self.assertFalse(result["approval_at_current_head"])
|
|
||||||
self.assertEqual(result["latest_approved_head_sha"], "oldhead1")
|
|
||||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ def _minimal_review_report(**overrides):
|
|||||||
"- Issue/PR: #182 / PR #203",
|
"- Issue/PR: #182 / PR #203",
|
||||||
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
"- Files changed: review_proofs.py",
|
"- Files changed: review_proofs.py",
|
||||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
|
||||||
"- Push occurred during validation: no",
|
|
||||||
"- Mutations: review only",
|
"- Mutations: review only",
|
||||||
"- File edits by reviewer: none",
|
"- File edits by reviewer: none",
|
||||||
"- Worktree/index mutations: none",
|
"- Worktree/index mutations: none",
|
||||||
@@ -84,7 +80,7 @@ class TestReviewFinalReportSchema(unittest.TestCase):
|
|||||||
|
|
||||||
def test_reviewed_head_without_validation_blocks(self):
|
def test_reviewed_head_without_validation_blocks(self):
|
||||||
report = _minimal_review_report().replace(
|
report = _minimal_review_report().replace(
|
||||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||||
"- Validation: not run",
|
"- Validation: not run",
|
||||||
)
|
)
|
||||||
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
"""Tests for workflow-load session boundary tracking (#403)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import final_report_validator
|
|
||||||
import review_workflow_boundary
|
|
||||||
import review_workflow_load
|
|
||||||
import mcp_server
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreReviewClassification(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
review_workflow_boundary.clear_pre_review_commands()
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
|
|
||||||
def test_inventory_command_allowed(self):
|
|
||||||
result = review_workflow_boundary.classify_pre_review_command(
|
|
||||||
"gitea_list_prs remote=prgs",
|
|
||||||
cwd="/tmp",
|
|
||||||
project_root="/repo/Gitea-Tools",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
result["classification"],
|
|
||||||
review_workflow_boundary.CLASSIFICATION_READ_ONLY_INVENTORY,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_main_checkout_pytest_is_boundary_violation(self):
|
|
||||||
root = "/repo/Gitea-Tools"
|
|
||||||
result = review_workflow_boundary.classify_pre_review_command(
|
|
||||||
"python -m pytest tests/",
|
|
||||||
cwd=root,
|
|
||||||
project_root=root,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
result["classification"],
|
|
||||||
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_profiles_json_inspection_is_boundary_violation(self):
|
|
||||||
result = review_workflow_boundary.classify_pre_review_command(
|
|
||||||
"cat profiles.json",
|
|
||||||
cwd="/repo/Gitea-Tools",
|
|
||||||
project_root="/repo/Gitea-Tools",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
result["classification"],
|
|
||||||
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestWorkflowLoadBoundaryGate(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
review_workflow_boundary.clear_pre_review_commands()
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", "reviewer")
|
|
||||||
|
|
||||||
def _root(self) -> str:
|
|
||||||
return str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
|
||||||
|
|
||||||
def test_boundary_violation_blocks_mutation_after_load(self):
|
|
||||||
root = self._root()
|
|
||||||
review_workflow_boundary.record_pre_review_command(
|
|
||||||
"python -m pytest tests/",
|
|
||||||
cwd=root,
|
|
||||||
project_root=root,
|
|
||||||
)
|
|
||||||
res = mcp_server.gitea_load_review_workflow()
|
|
||||||
self.assertFalse(res["success"])
|
|
||||||
self.assertEqual(res["boundary_status"], "violation")
|
|
||||||
blocked = mcp_server.gitea_mark_final_review_decision(
|
|
||||||
42, "approve", remote="prgs")
|
|
||||||
self.assertFalse(blocked["marked_ready"])
|
|
||||||
joined = " ".join(blocked["reasons"]).lower()
|
|
||||||
self.assertTrue(
|
|
||||||
"validation" in joined or "boundary" in joined or "workflow" in joined
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_clean_inventory_then_load_passes(self):
|
|
||||||
root = self._root()
|
|
||||||
review_workflow_boundary.record_pre_review_command(
|
|
||||||
"gitea_list_prs remote=prgs",
|
|
||||||
cwd=root,
|
|
||||||
project_root=root,
|
|
||||||
)
|
|
||||||
res = mcp_server.gitea_load_review_workflow()
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertEqual(res["boundary_status"], "clean")
|
|
||||||
blockers = review_workflow_load.review_workflow_load_blockers(root)
|
|
||||||
self.assertEqual(blockers, [])
|
|
||||||
|
|
||||||
def test_file_view_narrative_fails_validator_without_helper(self):
|
|
||||||
report = (
|
|
||||||
"## Controller Handoff\n"
|
|
||||||
"- Task: review-merge-pr\n"
|
|
||||||
"- I read the canonical workflow review-merge-pr.md before review.\n"
|
|
||||||
)
|
|
||||||
findings = final_report_validator.assess_final_report_validator(
|
|
||||||
report,
|
|
||||||
task_kind="review_pr",
|
|
||||||
)
|
|
||||||
self.assertTrue(any(
|
|
||||||
f["rule_id"] == "reviewer.workflow_load_boundary"
|
|
||||||
for f in findings.get("findings") or []
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_helper_result_passes_validator(self):
|
|
||||||
root = self._root()
|
|
||||||
review_workflow_load.record_review_workflow_load(root)
|
|
||||||
helper = review_workflow_boundary.workflow_load_helper_result(
|
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD,
|
|
||||||
root,
|
|
||||||
)
|
|
||||||
report = (
|
|
||||||
"## Controller Handoff\n"
|
|
||||||
"- Task: review-merge-pr\n"
|
|
||||||
f"- Workflow-load helper result: workflow_hash: {helper['workflow_hash']}; "
|
|
||||||
f"boundary_status: {helper['boundary_status']}\n"
|
|
||||||
)
|
|
||||||
findings = final_report_validator.assess_final_report_validator(
|
|
||||||
report,
|
|
||||||
task_kind="review_pr",
|
|
||||||
)
|
|
||||||
boundary_findings = [
|
|
||||||
f for f in (findings.get("findings") or [])
|
|
||||||
if f.get("rule_id") == "reviewer.workflow_load_boundary"
|
|
||||||
]
|
|
||||||
self.assertEqual(boundary_findings, [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"""Tests for canonical review workflow load proof (#389)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import review_workflow_load
|
|
||||||
import mcp_server
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewWorkflowLoadModule(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server._save_review_decision_lock(None)
|
|
||||||
|
|
||||||
def test_load_records_hash_and_schema(self):
|
|
||||||
root = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
|
||||||
recorded = review_workflow_load.record_review_workflow_load(root)
|
|
||||||
self.assertEqual(
|
|
||||||
recorded["workflow_source"],
|
|
||||||
review_workflow_load.WORKFLOW_REL_PATH,
|
|
||||||
)
|
|
||||||
self.assertEqual(recorded["task_mode"], "review-merge-pr")
|
|
||||||
self.assertRegex(recorded["workflow_hash"], r"^[0-9a-f]{12}$")
|
|
||||||
self.assertEqual(
|
|
||||||
recorded["final_report_schema_path"],
|
|
||||||
review_workflow_load.SCHEMA_REL_PATH,
|
|
||||||
)
|
|
||||||
status = review_workflow_load.workflow_load_status(root)
|
|
||||||
self.assertTrue(status["workflow_load_proof_present"])
|
|
||||||
self.assertTrue(status["workflow_load_valid"])
|
|
||||||
|
|
||||||
def test_stale_session_pid_blocks(self):
|
|
||||||
root = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
|
||||||
review_workflow_load.record_review_workflow_load(root)
|
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD["session_pid"] = 0
|
|
||||||
blockers = review_workflow_load.review_workflow_load_blockers(root)
|
|
||||||
self.assertTrue(any("different process" in b for b in blockers))
|
|
||||||
|
|
||||||
def test_prompt_conflict_detected(self):
|
|
||||||
conflict, reasons = review_workflow_load.assess_prompt_conflict(
|
|
||||||
"Run work-issue author implementation only")
|
|
||||||
self.assertTrue(conflict)
|
|
||||||
self.assertTrue(reasons)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewWorkflowLoadGates(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", "reviewer")
|
|
||||||
|
|
||||||
def _load_workflow(self):
|
|
||||||
return mcp_server.gitea_load_review_workflow()
|
|
||||||
|
|
||||||
def test_mcp_helper_returns_required_fields(self):
|
|
||||||
res = self._load_workflow()
|
|
||||||
self.assertTrue(res["success"])
|
|
||||||
self.assertTrue(res["loaded"])
|
|
||||||
self.assertIn("workflow_source", res)
|
|
||||||
self.assertIn("workflow_hash", res)
|
|
||||||
self.assertIn("final_report_schema_path", res)
|
|
||||||
self.assertIn("final_report_schema_hash", res)
|
|
||||||
|
|
||||||
def test_mark_final_blocked_without_load(self):
|
|
||||||
res = mcp_server.gitea_mark_final_review_decision(
|
|
||||||
42, "approve", remote="prgs")
|
|
||||||
self.assertFalse(res["marked_ready"])
|
|
||||||
self.assertTrue(any(
|
|
||||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
|
||||||
self.assertTrue(any(
|
|
||||||
"approve/merge replay" in r.lower() or "Do not call" in r
|
|
||||||
for r in res["reasons"]))
|
|
||||||
|
|
||||||
def test_submit_review_blocked_without_load(self):
|
|
||||||
with patch("mcp_server.gitea_check_pr_eligibility") as elig:
|
|
||||||
elig.return_value = {
|
|
||||||
"eligible": True,
|
|
||||||
"authenticated_user": "rev",
|
|
||||||
"profile_name": "prgs-reviewer",
|
|
||||||
"pr_author": "author",
|
|
||||||
"head_sha": "abc123",
|
|
||||||
"reasons": [],
|
|
||||||
}
|
|
||||||
res = mcp_server.gitea_submit_pr_review(
|
|
||||||
42,
|
|
||||||
"approve",
|
|
||||||
remote="prgs",
|
|
||||||
final_review_decision_ready=True,
|
|
||||||
)
|
|
||||||
self.assertFalse(res["performed"])
|
|
||||||
self.assertTrue(any(
|
|
||||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
|
||||||
|
|
||||||
def test_merge_blocked_without_load(self):
|
|
||||||
res = mcp_server.gitea_merge_pr(
|
|
||||||
42,
|
|
||||||
confirmation="MERGE PR 42",
|
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertFalse(res["performed"])
|
|
||||||
self.assertTrue(any(
|
|
||||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
|
||||||
|
|
||||||
def test_resolve_capability_reports_missing_load(self):
|
|
||||||
with patch.object(mcp_server, "_ensure_matching_profile"):
|
|
||||||
with patch.object(
|
|
||||||
mcp_server.gitea_config, "is_runtime_switching_enabled",
|
|
||||||
return_value=False):
|
|
||||||
with patch.object(
|
|
||||||
mcp_server, "_authenticated_username",
|
|
||||||
return_value="rev"):
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
|
||||||
"review_pr", remote="prgs")
|
|
||||||
proof = res.get("workflow_load_proof") or {}
|
|
||||||
self.assertFalse(proof.get("workflow_load_valid"))
|
|
||||||
self.assertTrue(any(
|
|
||||||
"gitea_load_review_workflow" in g
|
|
||||||
for g in res.get("task_role_guidance") or []))
|
|
||||||
|
|
||||||
def test_init_review_lock_clears_prior_load(self):
|
|
||||||
self._load_workflow()
|
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
|
||||||
blockers = review_workflow_load.review_workflow_load_blockers(
|
|
||||||
str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
self.assertTrue(blockers)
|
|
||||||
|
|
||||||
def test_dry_run_allowed_without_load(self):
|
|
||||||
with patch("mcp_server.get_auth_header", return_value="Basic dGVzdA=="), \
|
|
||||||
patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
|
||||||
patch("mcp_server.gitea_check_pr_eligibility") as elig:
|
|
||||||
elig.return_value = {
|
|
||||||
"eligible": True,
|
|
||||||
"authenticated_user": "rev",
|
|
||||||
"profile_name": "prgs-reviewer",
|
|
||||||
"pr_author": "author",
|
|
||||||
"head_sha": "abc123",
|
|
||||||
"reasons": [],
|
|
||||||
}
|
|
||||||
res = mcp_server.gitea_dry_run_pr_review(
|
|
||||||
42, "approve", remote="prgs")
|
|
||||||
self.assertNotIn(
|
|
||||||
"gitea_load_review_workflow",
|
|
||||||
" ".join(res.get("reasons") or []),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
"""Tests for per-PR reviewer leases (#407)."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import reviewer_pr_lease as leases
|
|
||||||
|
|
||||||
|
|
||||||
def _lease_comment(
|
|
||||||
pr_number: int,
|
|
||||||
session_id: str,
|
|
||||||
*,
|
|
||||||
phase: str = "claimed",
|
|
||||||
minutes_ago: int = 0,
|
|
||||||
candidate_head: str = "a" * 40,
|
|
||||||
) -> dict:
|
|
||||||
now = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
|
||||||
body = leases.format_lease_body(
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
pr_number=pr_number,
|
|
||||||
issue_number=295,
|
|
||||||
reviewer_identity="rev1",
|
|
||||||
profile="prgs-reviewer",
|
|
||||||
session_id=session_id,
|
|
||||||
worktree="branches/review-pr382",
|
|
||||||
phase=phase,
|
|
||||||
candidate_head=candidate_head,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="b" * 40,
|
|
||||||
last_activity=now,
|
|
||||||
)
|
|
||||||
return {"id": 1, "body": body, "user": {"login": "rev1"}}
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseAcquire(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
leases.clear_session_lease()
|
|
||||||
|
|
||||||
def test_two_reviewers_cannot_lease_same_pr(self):
|
|
||||||
comments = [_lease_comment(382, "session-a")]
|
|
||||||
result = leases.assess_acquire_lease(
|
|
||||||
comments,
|
|
||||||
pr_number=382,
|
|
||||||
reviewer_identity="rev2",
|
|
||||||
profile="prgs-reviewer",
|
|
||||||
session_id="session-b",
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
issue_number=295,
|
|
||||||
worktree="branches/review-pr382-b",
|
|
||||||
candidate_head="c" * 40,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="d" * 40,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["acquire_allowed"])
|
|
||||||
self.assertTrue(any("already has active" in r for r in result["reasons"]))
|
|
||||||
|
|
||||||
def test_two_reviewers_can_lease_different_prs(self):
|
|
||||||
comments = [_lease_comment(382, "session-a")]
|
|
||||||
result = leases.assess_acquire_lease(
|
|
||||||
comments,
|
|
||||||
pr_number=383,
|
|
||||||
reviewer_identity="rev2",
|
|
||||||
profile="prgs-reviewer",
|
|
||||||
session_id="session-b",
|
|
||||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
|
||||||
issue_number=296,
|
|
||||||
worktree="branches/review-pr383",
|
|
||||||
candidate_head="c" * 40,
|
|
||||||
target_branch="master",
|
|
||||||
target_branch_sha="d" * 40,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["acquire_allowed"])
|
|
||||||
self.assertIsNotNone(result["lease_body"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseFreshness(unittest.TestCase):
|
|
||||||
def test_stale_warning_after_30_minutes(self):
|
|
||||||
lease = leases.parse_lease_comment(
|
|
||||||
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
leases.classify_lease_freshness(lease),
|
|
||||||
"stale_warning",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_reclaimable_after_60_minutes(self):
|
|
||||||
lease = leases.parse_lease_comment(
|
|
||||||
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
leases.classify_lease_freshness(lease),
|
|
||||||
"reclaimable",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseMutationGate(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
leases.clear_session_lease()
|
|
||||||
|
|
||||||
def test_reviewer_without_lease_cannot_mutate(self):
|
|
||||||
head = "f" * 40
|
|
||||||
comments = [_lease_comment(382, "other-session", candidate_head=head)]
|
|
||||||
result = leases.assess_mutation_lease_gate(
|
|
||||||
pr_number=382,
|
|
||||||
comments=comments,
|
|
||||||
reviewer_identity="rev1",
|
|
||||||
session_id="my-session",
|
|
||||||
mutation="approve",
|
|
||||||
live_head_sha=head,
|
|
||||||
pinned_head_sha=head,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_owned_lease_allows_mutation(self):
|
|
||||||
head = "f" * 40
|
|
||||||
comments = [_lease_comment(382, "my-session", candidate_head=head)]
|
|
||||||
leases.record_session_lease({
|
|
||||||
"pr_number": 382,
|
|
||||||
"session_id": "my-session",
|
|
||||||
"candidate_head": head,
|
|
||||||
"target_branch": "master",
|
|
||||||
})
|
|
||||||
result = leases.assess_mutation_lease_gate(
|
|
||||||
pr_number=382,
|
|
||||||
comments=comments,
|
|
||||||
reviewer_identity="rev1",
|
|
||||||
session_id="my-session",
|
|
||||||
mutation="approve",
|
|
||||||
live_head_sha=head,
|
|
||||||
pinned_head_sha=head,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
def test_head_change_invalidates_lease(self):
|
|
||||||
reviewed = "f" * 40
|
|
||||||
live = "e" * 40
|
|
||||||
comments = [_lease_comment(382, "my-session", candidate_head=reviewed)]
|
|
||||||
leases.record_session_lease({
|
|
||||||
"pr_number": 382,
|
|
||||||
"session_id": "my-session",
|
|
||||||
"candidate_head": reviewed,
|
|
||||||
})
|
|
||||||
result = leases.assess_mutation_lease_gate(
|
|
||||||
pr_number=382,
|
|
||||||
comments=comments,
|
|
||||||
reviewer_identity="rev1",
|
|
||||||
session_id="my-session",
|
|
||||||
mutation="merge",
|
|
||||||
live_head_sha=live,
|
|
||||||
pinned_head_sha=reviewed,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(any("head" in r.lower() for r in result["reasons"]))
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseMcpGate(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
leases.clear_session_lease()
|
|
||||||
patch("mcp_server.verify_preflight_purity").start()
|
|
||||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
|
||||||
mcp_server = __import__("mcp_server")
|
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
|
||||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", "reviewer")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
patch.stopall()
|
|
||||||
leases.clear_session_lease()
|
|
||||||
|
|
||||||
def test_reviewer_pr_lease_gate_helper_blocks_without_session(self):
|
|
||||||
import mcp_server
|
|
||||||
head = "a" * 40
|
|
||||||
with patch("mcp_server._fetch_pr_comments", return_value=[]):
|
|
||||||
reasons = mcp_server._reviewer_pr_lease_gate(
|
|
||||||
pr_number=382,
|
|
||||||
remote="prgs",
|
|
||||||
host=None,
|
|
||||||
org=None,
|
|
||||||
repo=None,
|
|
||||||
mutation="approve",
|
|
||||||
live_head_sha=head,
|
|
||||||
pinned_head_sha=head,
|
|
||||||
)
|
|
||||||
self.assertTrue(any("lease" in r.lower() for r in reasons))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""Tests for validation cwd/HEAD proof verifier (#398)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
|
||||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report # noqa: E402
|
|
||||||
|
|
||||||
ROOT = "/Users/jasonwalker/Development/Gitea-Tools"
|
|
||||||
WORKTREE = f"{ROOT}/branches/review-feat-issue-398"
|
|
||||||
HEAD = "f5953549aad5e822f14f52d3ea3c6d7990109384"
|
|
||||||
|
|
||||||
|
|
||||||
def _proof_backed_report() -> str:
|
|
||||||
return "\n".join([
|
|
||||||
f"Candidate head SHA: {HEAD}",
|
|
||||||
f"pwd: {WORKTREE}",
|
|
||||||
f"git rev-parse HEAD: {HEAD}",
|
|
||||||
"git status --short --branch: ## feat/issue-398...prgs/master",
|
|
||||||
f"Validation command: cd {WORKTREE} && venv/bin/python -m pytest tests/ -q",
|
|
||||||
"Result: 1497 passed, 6 skipped",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationCwdProof(unittest.TestCase):
|
|
||||||
def test_no_validation_claim_passes(self):
|
|
||||||
result = assess_validation_cwd_proof_report("Review decision: approve")
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
|
|
||||||
def test_missing_cwd_proof_fails(self):
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
f"Validation command: pytest tests/\nCandidate head SHA: {HEAD}",
|
|
||||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_main_checkout_cwd_blocks(self):
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
"\n".join([
|
|
||||||
f"Candidate head SHA: {HEAD}",
|
|
||||||
f"pwd: {ROOT}",
|
|
||||||
f"git rev-parse HEAD: {HEAD}",
|
|
||||||
"git status --short --branch: ## master",
|
|
||||||
"Validation command: pytest tests/ -q",
|
|
||||||
]),
|
|
||||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
|
||||||
project_root=ROOT,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["violations"])
|
|
||||||
|
|
||||||
def test_wrong_head_blocks(self):
|
|
||||||
wrong = "a" * 40
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
"\n".join([
|
|
||||||
f"Candidate head SHA: {HEAD}",
|
|
||||||
f"pwd: {WORKTREE}",
|
|
||||||
f"git rev-parse HEAD: {wrong}",
|
|
||||||
"git status --short --branch: clean",
|
|
||||||
f"Validation command: cd {WORKTREE} && pytest -q",
|
|
||||||
]),
|
|
||||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
|
||||||
project_root=ROOT,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(result["violations"])
|
|
||||||
|
|
||||||
def test_fully_proof_backed_passes(self):
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
_proof_backed_report(),
|
|
||||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
|
||||||
project_root=ROOT,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_baseline_without_cwd_fails(self):
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
"\n".join([
|
|
||||||
_proof_backed_report(),
|
|
||||||
"Baseline validation command: pytest tests/ -q",
|
|
||||||
]),
|
|
||||||
validation_session={
|
|
||||||
"validation_ran": True,
|
|
||||||
"expected_head_sha": HEAD,
|
|
||||||
"baseline_validation_ran": True,
|
|
||||||
},
|
|
||||||
project_root=ROOT,
|
|
||||||
)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertTrue(
|
|
||||||
any("baseline" in r.lower() for r in result["reasons"])
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_baseline_with_full_proof_passes(self):
|
|
||||||
result = assess_validation_cwd_proof_report(
|
|
||||||
"\n".join([
|
|
||||||
_proof_backed_report(),
|
|
||||||
f"Baseline worktree: {ROOT}/branches/baseline-master-pr376",
|
|
||||||
f"Baseline target SHA: {HEAD}",
|
|
||||||
f"Baseline validation command: cd {ROOT}/branches/baseline-master-pr376 && pytest -q",
|
|
||||||
]),
|
|
||||||
validation_session={
|
|
||||||
"validation_ran": True,
|
|
||||||
"expected_head_sha": HEAD,
|
|
||||||
"baseline_validation_ran": True,
|
|
||||||
},
|
|
||||||
project_root=ROOT,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"], result["reasons"])
|
|
||||||
|
|
||||||
def test_final_report_validator_integration(self):
|
|
||||||
result = assess_final_report_validator(
|
|
||||||
"Validation command: pytest tests/ -q",
|
|
||||||
"review_pr",
|
|
||||||
validation_session={"validation_ran": True},
|
|
||||||
)
|
|
||||||
self.assertTrue(result["blocked"] or result["downgraded"])
|
|
||||||
self.assertTrue(
|
|
||||||
any(
|
|
||||||
f.get("rule_id") == "reviewer.validation_cwd_proof"
|
|
||||||
for f in result.get("findings") or []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_exported_from_review_proofs(self):
|
|
||||||
from review_proofs import assess_validation_cwd_proof_report as exported
|
|
||||||
|
|
||||||
self.assertTrue(callable(exported))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
"""Unit tests for stacked-PR base policy (#484)."""
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import stacked_pr_support as sps
|
|
||||||
|
|
||||||
|
|
||||||
def _pr(number, branch, state="open"):
|
|
||||||
return {"number": number, "state": state, "head": {"ref": branch}}
|
|
||||||
|
|
||||||
|
|
||||||
OPEN_PRS = [
|
|
||||||
_pr(479, "feat/issue-478-mcp-menu-shell"),
|
|
||||||
_pr(481, "feat/issue-477-lock-adoption-proof"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Motivating case (#482 stacked on #479 / #478).
|
|
||||||
STACKED_BODY = (
|
|
||||||
"Closes #482.\n\n"
|
|
||||||
"Stacked on PR #479 / issue #478.\n"
|
|
||||||
"Base branch: feat/issue-478-mcp-menu-shell\n"
|
|
||||||
"Head branch: feat/issue-482-skip-stale-request-changes-pr\n"
|
|
||||||
"Do not merge before PR #479 lands."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsBaseBranch(unittest.TestCase):
|
|
||||||
def test_master_main_dev_are_base(self):
|
|
||||||
for b in ("master", "main", "dev"):
|
|
||||||
self.assertTrue(sps.is_base_branch(b))
|
|
||||||
|
|
||||||
def test_feature_branch_is_not_base(self):
|
|
||||||
self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestStackedBaseDeclaration(unittest.TestCase):
|
|
||||||
def test_no_declaration_is_normal_path(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS
|
|
||||||
)
|
|
||||||
self.assertFalse(out["block"])
|
|
||||||
self.assertFalse(out["declared"])
|
|
||||||
self.assertIsNone(out["approved"])
|
|
||||||
|
|
||||||
def test_valid_open_pr_base_is_approved(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
|
||||||
stacked_base_pr=479,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertFalse(out["block"])
|
|
||||||
self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell")
|
|
||||||
self.assertEqual(out["approved"]["pr_number"], 479)
|
|
||||||
self.assertTrue(out["approved"]["verified_open"])
|
|
||||||
|
|
||||||
def test_missing_pr_number_blocks(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
|
||||||
stacked_base_pr=None,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("without stacked_base_pr", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_arbitrary_branch_with_no_open_pr_blocks(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="feat/random-unrelated-branch",
|
|
||||||
stacked_base_pr=999,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_stale_merged_base_blocks(self):
|
|
||||||
merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")]
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
|
||||||
stacked_base_pr=479,
|
|
||||||
open_prs=merged,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIsNone(out["approved"])
|
|
||||||
|
|
||||||
def test_pr_number_mismatch_blocks(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
|
||||||
stacked_base_pr=481, # wrong PR for this branch
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("does not match the open", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_declaring_a_base_branch_blocks(self):
|
|
||||||
out = sps.assess_stacked_base_declaration(
|
|
||||||
stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("already a normal base branch", out["reasons"][0])
|
|
||||||
|
|
||||||
|
|
||||||
class TestStackedPrBody(unittest.TestCase):
|
|
||||||
def test_complete_body_has_no_missing_fields(self):
|
|
||||||
missing = sps.assess_stacked_pr_body(
|
|
||||||
STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
|
||||||
)
|
|
||||||
self.assertEqual(missing, [])
|
|
||||||
|
|
||||||
def test_missing_all_fields(self):
|
|
||||||
missing = sps.assess_stacked_pr_body(
|
|
||||||
"just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
|
||||||
)
|
|
||||||
self.assertEqual(len(missing), 3)
|
|
||||||
|
|
||||||
def test_missing_merge_ordering_only(self):
|
|
||||||
body = "Base branch feat/issue-478-mcp-menu-shell for PR #479"
|
|
||||||
missing = sps.assess_stacked_pr_body(
|
|
||||||
body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
|
||||||
)
|
|
||||||
self.assertEqual(len(missing), 1)
|
|
||||||
self.assertIn("merge-ordering", missing[0])
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreatePrBase(unittest.TestCase):
|
|
||||||
APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True}
|
|
||||||
|
|
||||||
def test_master_base_passes_without_stacked_metadata(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="master", approved_stacked_base=None, body="Closes #1", open_prs=[]
|
|
||||||
)
|
|
||||||
self.assertFalse(out["block"])
|
|
||||||
self.assertFalse(out["stacked"])
|
|
||||||
|
|
||||||
def test_non_base_without_approval_blocks(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="feat/issue-478-mcp-menu-shell",
|
|
||||||
approved_stacked_base=None,
|
|
||||||
body=STACKED_BODY,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("no approved stacked base", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_non_base_mismatched_approval_blocks(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="feat/some-other-branch",
|
|
||||||
approved_stacked_base=self.APPROVED,
|
|
||||||
body=STACKED_BODY,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_approved_base_with_good_body_passes(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="feat/issue-478-mcp-menu-shell",
|
|
||||||
approved_stacked_base=self.APPROVED,
|
|
||||||
body=STACKED_BODY,
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertFalse(out["block"])
|
|
||||||
self.assertTrue(out["stacked"])
|
|
||||||
self.assertEqual(out["stacked_base_pr"], 479)
|
|
||||||
|
|
||||||
def test_approved_base_now_stale_blocks(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="feat/issue-478-mcp-menu-shell",
|
|
||||||
approved_stacked_base=self.APPROVED,
|
|
||||||
body=STACKED_BODY,
|
|
||||||
open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")],
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("no longer corresponds to an OPEN", out["reasons"][0])
|
|
||||||
|
|
||||||
def test_approved_base_with_incomplete_body_blocks(self):
|
|
||||||
out = sps.assess_create_pr_base(
|
|
||||||
base="feat/issue-478-mcp-menu-shell",
|
|
||||||
approved_stacked_base=self.APPROVED,
|
|
||||||
body="Closes #482 only",
|
|
||||||
open_prs=OPEN_PRS,
|
|
||||||
)
|
|
||||||
self.assertTrue(out["block"])
|
|
||||||
self.assertIn("must document the stack", out["reasons"][0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -12,8 +12,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
|
|
||||||
HEAD_SHA = "a" * 40
|
|
||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False):
|
def _lock(mutations=None, correction=False):
|
||||||
return {
|
return {
|
||||||
@@ -36,11 +34,7 @@ def _lock(mutations=None, correction=False):
|
|||||||
|
|
||||||
|
|
||||||
def _seed(mutations=None, correction=False):
|
def _seed(mutations=None, correction=False):
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
|
||||||
mcp_server._save_review_decision_lock(_lock(mutations, correction))
|
mcp_server._save_review_decision_lock(_lock(mutations, correction))
|
||||||
mcp_server.gitea_load_review_workflow()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1,
|
APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1,
|
||||||
@@ -52,7 +46,6 @@ RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
|
|||||||
class TestTerminalHardStopReasons(unittest.TestCase):
|
class TestTerminalHardStopReasons(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
|
|
||||||
def test_no_lock_no_reasons(self):
|
def test_no_lock_no_reasons(self):
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
@@ -97,10 +90,7 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMergeHardStopWiring(unittest.TestCase):
|
class TestMergeHardStopWiring(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
|
|
||||||
def test_merge_blocked_after_request_changes(self):
|
def test_merge_blocked_after_request_changes(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
@@ -121,10 +111,7 @@ class TestMergeHardStopWiring(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMarkFinalHardStopWiring(unittest.TestCase):
|
class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
|
|
||||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
@@ -140,32 +127,20 @@ def _feedback(blocking, stale=False, success=True):
|
|||||||
"success": success,
|
"success": success,
|
||||||
"has_blocking_change_requests": blocking,
|
"has_blocking_change_requests": blocking,
|
||||||
"review_feedback_stale": stale,
|
"review_feedback_stale": stale,
|
||||||
"current_head_sha": HEAD_SHA,
|
"current_head_sha": "abc123",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _mark(action, pr_number=6, **kwargs):
|
|
||||||
kwargs.setdefault("expected_head_sha", HEAD_SHA)
|
|
||||||
no_lease_block = {"block": False, "reasons": [], "mutation_allowed": True}
|
|
||||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
|
||||||
patch("mcp_server._pr_work_lease_reviewer_block", return_value=no_lease_block):
|
|
||||||
return mcp_server.gitea_mark_final_review_decision(
|
|
||||||
pr_number=pr_number, action=action, remote="prgs", **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load.clear_review_workflow_load()
|
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
|
||||||
|
|
||||||
def test_duplicate_request_changes_blocked_at_same_head(self):
|
def test_duplicate_request_changes_blocked_at_same_head(self):
|
||||||
_seed()
|
_seed()
|
||||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||||
return_value=_feedback(blocking=True, stale=False)):
|
return_value=_feedback(blocking=True, stale=False)):
|
||||||
result = _mark("request_changes")
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="request_changes", remote="prgs")
|
||||||
self.assertFalse(result["marked_ready"])
|
self.assertFalse(result["marked_ready"])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("duplicate" in r for r in result["reasons"]),
|
any("duplicate" in r for r in result["reasons"]),
|
||||||
@@ -175,14 +150,16 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
|||||||
_seed()
|
_seed()
|
||||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||||
return_value=_feedback(blocking=True, stale=True)):
|
return_value=_feedback(blocking=True, stale=True)):
|
||||||
result = _mark("request_changes")
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="request_changes", remote="prgs")
|
||||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
|
||||||
def test_request_changes_allowed_when_no_blocker(self):
|
def test_request_changes_allowed_when_no_blocker(self):
|
||||||
_seed()
|
_seed()
|
||||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||||
return_value=_feedback(blocking=False)):
|
return_value=_feedback(blocking=False)):
|
||||||
result = _mark("request_changes")
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="request_changes", remote="prgs")
|
||||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
|
||||||
def test_request_changes_fails_closed_when_feedback_unavailable(self):
|
def test_request_changes_fails_closed_when_feedback_unavailable(self):
|
||||||
@@ -190,7 +167,8 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
|||||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||||
return_value=_feedback(blocking=False,
|
return_value=_feedback(blocking=False,
|
||||||
success=False)):
|
success=False)):
|
||||||
result = _mark("request_changes")
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="request_changes", remote="prgs")
|
||||||
self.assertFalse(result["marked_ready"])
|
self.assertFalse(result["marked_ready"])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("could not verify" in r for r in result["reasons"]),
|
any("could not verify" in r for r in result["reasons"]),
|
||||||
@@ -200,7 +178,8 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
|||||||
_seed()
|
_seed()
|
||||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||||
side_effect=AssertionError("must not be called")):
|
side_effect=AssertionError("must not be called")):
|
||||||
result = _mark("approve")
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=6, action="approve", remote="prgs")
|
||||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
"""Tests for validation status vocabulary (#406)."""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
|
||||||
from validation_status_vocabulary import ( # noqa: E402
|
|
||||||
STATUS_BASELINE_EQUIVALENT,
|
|
||||||
STATUS_FAILED,
|
|
||||||
STATUS_MERGE_SIM_RESOLVED,
|
|
||||||
STATUS_PASSED,
|
|
||||||
STATUS_TRANSIENT_PASS,
|
|
||||||
assess_validation_status_vocabulary,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handoff(**extra):
|
|
||||||
fields = {
|
|
||||||
"Task": "review PR #386",
|
|
||||||
"Validation status": STATUS_PASSED,
|
|
||||||
"Raw PR-head validation result": "passed",
|
|
||||||
"Merge simulation result": "not run",
|
|
||||||
"Baseline worktree used": "none",
|
|
||||||
}
|
|
||||||
fields.update(extra)
|
|
||||||
lines = ["## Controller Handoff", ""]
|
|
||||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationStatusVocabulary(unittest.TestCase):
|
|
||||||
def test_raw_head_pass_status(self):
|
|
||||||
report = _handoff()
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertEqual(result["status_claimed"], STATUS_PASSED)
|
|
||||||
|
|
||||||
def test_raw_head_failure_with_baseline_match(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
"Baseline worktree used": "branches/baseline-master-pr386",
|
|
||||||
"Baseline target SHA": "a" * 40,
|
|
||||||
"Baseline failures": "test_foo failed",
|
|
||||||
"PR failures": "test_foo failed",
|
|
||||||
"Failure signatures match": "true",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertTrue(result["baseline_proof_complete"])
|
|
||||||
|
|
||||||
def test_baseline_equivalent_without_baseline_proof_blocked(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertIn("baseline-equivalent", result["reasons"][0])
|
|
||||||
|
|
||||||
def test_merge_simulation_resolution_passes(self):
|
|
||||||
report = "\n".join([
|
|
||||||
_handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_MERGE_SIM_RESOLVED,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
"Merge simulation result": "passed",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"Worktree/index mutations: merge simulation in branches/review-pr386",
|
|
||||||
"Worktree path: branches/review-pr386",
|
|
||||||
"Pre-simulation clean status: clean",
|
|
||||||
"Merge result: clean merge",
|
|
||||||
"Abort command: git merge --abort",
|
|
||||||
"Post-abort clean status: clean",
|
|
||||||
])
|
|
||||||
command_log = [
|
|
||||||
{"command": "git merge --no-commit prgs/master"},
|
|
||||||
{"command": "git merge --abort"},
|
|
||||||
]
|
|
||||||
result = assess_validation_status_vocabulary(
|
|
||||||
report, command_log=command_log
|
|
||||||
)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
self.assertTrue(result["merge_simulation_passed"])
|
|
||||||
|
|
||||||
def test_merge_simulation_failure_stays_failed(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_FAILED,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
"Merge simulation result": "failed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
def test_failed_status_with_passing_merge_sim_blocked(self):
|
|
||||||
report = "\n".join([
|
|
||||||
_handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_FAILED,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
"Merge simulation result": "passed",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"Worktree/index mutations: merge simulation",
|
|
||||||
"Worktree path: branches/review-pr386",
|
|
||||||
"Pre-simulation clean status: clean",
|
|
||||||
"Merge result: clean",
|
|
||||||
"Abort command: git merge --abort",
|
|
||||||
"Post-abort clean status: clean",
|
|
||||||
])
|
|
||||||
result = assess_validation_status_vocabulary(
|
|
||||||
report,
|
|
||||||
command_log=[{"command": "git merge --no-commit prgs/master"}],
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_transient_failure_then_pass(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_TRANSIENT_PASS,
|
|
||||||
"Raw PR-head validation result": "passed",
|
|
||||||
"Transient validation failure history": (
|
|
||||||
"first run failed with infra flake; rerun passed"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
def test_transient_pass_without_history_blocked(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_TRANSIENT_PASS,
|
|
||||||
"Raw PR-head validation result": "passed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_bare_passed_after_raw_failure_blocked(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_PASSED,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_validation_status_vocabulary(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_wrong_baseline_label_when_merge_sim_used_blocked(self):
|
|
||||||
report = "\n".join([
|
|
||||||
_handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
"Merge simulation result": "passed",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"Worktree/index mutations: merge simulation",
|
|
||||||
"Worktree path: branches/review-pr386",
|
|
||||||
"Pre-simulation clean status: clean",
|
|
||||||
"Merge result: clean",
|
|
||||||
"Abort command: git merge --abort",
|
|
||||||
"Post-abort clean status: clean",
|
|
||||||
])
|
|
||||||
result = assess_validation_status_vocabulary(
|
|
||||||
report,
|
|
||||||
command_log=[{"command": "git merge --no-commit prgs/master"}],
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
joined = " ".join(result["reasons"]).lower()
|
|
||||||
self.assertTrue(
|
|
||||||
"misleading" in joined or "baseline-equivalent" in joined
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_final_report_validator_integration_blocks_misleading_label(self):
|
|
||||||
report = _handoff(
|
|
||||||
**{
|
|
||||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
|
||||||
"Raw PR-head validation result": "failed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = assess_final_report_validator(report, "review_pr")
|
|
||||||
blocked_ids = {f["rule_id"] for f in result["findings"]}
|
|
||||||
self.assertIn("reviewer.validation_status_vocabulary", blocked_ids)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"""Tests for runtime_context / mutation-guard workspace alignment (#460)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import author_mutation_worktree as amw # noqa: E402
|
|
||||||
import gitea_mcp_server as srv # noqa: E402
|
|
||||||
|
|
||||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
|
||||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
|
||||||
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
|
|
||||||
|
|
||||||
|
|
||||||
class TestCanonicalRepoRoot(unittest.TestCase):
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
def test_resolves_main_repo_from_branches_worktree(self, mock_run):
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=0,
|
|
||||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
|
||||||
)
|
|
||||||
root = amw.resolve_canonical_repo_root(BRANCHES_WORKTREE, MCP_PROCESS_ROOT)
|
|
||||||
self.assertEqual(root, CONTROL_ROOT)
|
|
||||||
|
|
||||||
def test_falls_back_when_git_unavailable(self):
|
|
||||||
root = amw.resolve_canonical_repo_root("/missing/path", MCP_PROCESS_ROOT)
|
|
||||||
self.assertEqual(root, os.path.realpath(MCP_PROCESS_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
class TestWorkspaceRepoMembership(unittest.TestCase):
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
def test_valid_branches_worktree_accepted(self, mock_run, *_exists):
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=0,
|
|
||||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
|
||||||
)
|
|
||||||
result = amw.assess_workspace_repo_membership(
|
|
||||||
workspace_path=BRANCHES_WORKTREE,
|
|
||||||
canonical_repo_root=CONTROL_ROOT,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
self.assertFalse(result["block"])
|
|
||||||
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
def test_wrong_repo_rejected(self, mock_run, *_exists):
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=0,
|
|
||||||
stdout="/other/repo/.git\n",
|
|
||||||
)
|
|
||||||
result = amw.assess_workspace_repo_membership(
|
|
||||||
workspace_path=BRANCHES_WORKTREE,
|
|
||||||
canonical_repo_root=CONTROL_ROOT,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertIn("does not belong", result["reasons"][0])
|
|
||||||
|
|
||||||
@mock.patch("os.path.exists", return_value=False)
|
|
||||||
def test_missing_worktree_rejected(self, *_exists):
|
|
||||||
result = amw.assess_workspace_repo_membership(
|
|
||||||
workspace_path=f"{CONTROL_ROOT}/branches/missing-worktree",
|
|
||||||
canonical_repo_root=CONTROL_ROOT,
|
|
||||||
)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertIn("does not exist", result["reasons"][0])
|
|
||||||
|
|
||||||
|
|
||||||
class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
srv._preflight_whoami_called = True
|
|
||||||
srv._preflight_capability_called = True
|
|
||||||
srv._preflight_resolved_role = "author"
|
|
||||||
self._orig_in_test = srv._preflight_in_test_mode
|
|
||||||
srv._preflight_in_test_mode = lambda: False
|
|
||||||
self._env_patch = mock.patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{},
|
|
||||||
clear=False,
|
|
||||||
)
|
|
||||||
self._env_patch.start()
|
|
||||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
|
||||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
srv._preflight_in_test_mode = self._orig_in_test
|
|
||||||
self._env_patch.stop()
|
|
||||||
|
|
||||||
def test_runtime_context_and_guard_share_resolved_workspace(self):
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
|
||||||
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
|
||||||
self.assertEqual(ctx["workspace_path"], os.path.realpath(BRANCHES_WORKTREE))
|
|
||||||
self.assertEqual(ctx["canonical_repo_root"], CONTROL_ROOT)
|
|
||||||
self.assertFalse(ctx["roots_aligned"])
|
|
||||||
self.assertEqual(
|
|
||||||
status["preflight_workspace"]["active_task_workspace_root"],
|
|
||||||
os.path.realpath(BRANCHES_WORKTREE),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
status["preflight_workspace"]["canonical_repository_root"],
|
|
||||||
CONTROL_ROOT,
|
|
||||||
)
|
|
||||||
self.assertIn("workspace_root_mismatch", status["preflight_workspace"])
|
|
||||||
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
def test_declared_branches_worktree_passes_when_mcp_root_differs(
|
|
||||||
self, _exists, _isdir, mock_run
|
|
||||||
):
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=0,
|
|
||||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
|
||||||
)
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
|
||||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
|
||||||
srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE)
|
|
||||||
|
|
||||||
def test_stable_checkout_still_rejected(self):
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
|
||||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
srv.verify_preflight_purity()
|
|
||||||
self.assertIn("stable control checkout", str(ctx.exception))
|
|
||||||
|
|
||||||
@mock.patch("os.path.isdir", return_value=True)
|
|
||||||
@mock.patch("os.path.exists", return_value=True)
|
|
||||||
@mock.patch("subprocess.run")
|
|
||||||
def test_non_branches_worktree_rejected(self, mock_run, *_exists):
|
|
||||||
outside = "/tmp/outside-repo-checkout"
|
|
||||||
mock_run.return_value = MagicMock(
|
|
||||||
returncode=0,
|
|
||||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
|
||||||
)
|
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
|
||||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
srv.verify_preflight_purity(worktree_path=outside)
|
|
||||||
self.assertIn("not under", str(ctx.exception))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
"""Precise validation-status vocabulary for reviewer final reports (#406)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from reviewer_merge_simulation import assess_merge_simulation_report
|
|
||||||
|
|
||||||
STATUS_PASSED = "passed"
|
|
||||||
STATUS_FAILED = "failed"
|
|
||||||
STATUS_BASELINE_EQUIVALENT = "baseline-equivalent failure accepted"
|
|
||||||
STATUS_MERGE_SIM_RESOLVED = "raw-head failure resolved by merge simulation"
|
|
||||||
STATUS_TRANSIENT_PASS = "passed after transient failure investigation"
|
|
||||||
|
|
||||||
ALLOWED_VALIDATION_STATUSES = frozenset({
|
|
||||||
STATUS_PASSED,
|
|
||||||
STATUS_FAILED,
|
|
||||||
STATUS_BASELINE_EQUIVALENT,
|
|
||||||
STATUS_MERGE_SIM_RESOLVED,
|
|
||||||
STATUS_TRANSIENT_PASS,
|
|
||||||
})
|
|
||||||
|
|
||||||
_STATUS_FIELD_RE = re.compile(
|
|
||||||
r"^\s*[-*]?\s*(?:validation status|pr-head validation status|"
|
|
||||||
r"official validation status)\s*:\s*(.+?)\s*$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_RAW_HEAD_RESULT_RE = re.compile(
|
|
||||||
r"^\s*[-*]?\s*raw pr-head validation result\s*:\s*(.+?)\s*$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_MERGE_SIM_RESULT_RE = re.compile(
|
|
||||||
r"^\s*[-*]?\s*merge simulation result\s*:\s*(.+?)\s*$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_BASELINE_WORKTREE_USED_RE = re.compile(
|
|
||||||
r"^\s*[-*]?\s*baseline (?:validation )?worktree(?: used)?\s*:\s*(.+?)\s*$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_BASELINE_TARGET_SHA_RE = re.compile(
|
|
||||||
r"^\s*[-*]?\s*baseline target sha\s*:\s*([0-9a-f]{7,40})\s*$",
|
|
||||||
re.IGNORECASE | re.MULTILINE,
|
|
||||||
)
|
|
||||||
_FAILURE_SIGNATURE_RE = re.compile(
|
|
||||||
r"failure signatures match\s*:\s*(true|yes)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_BASELINE_FAILURES_RE = re.compile(
|
|
||||||
r"baseline failures\s*:",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_TRANSIENT_HISTORY_RE = re.compile(
|
|
||||||
r"(?:transient validation failure|earlier validation failure|"
|
|
||||||
r"prior failure|failure history|failed then passed)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _first_match(pattern: re.Pattern[str], text: str) -> str:
|
|
||||||
match = pattern.search(text or "")
|
|
||||||
return (match.group(1).strip() if match else "")
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_status_label(raw: str) -> str:
|
|
||||||
text = (raw or "").strip().lower()
|
|
||||||
for status in ALLOWED_VALIDATION_STATUSES:
|
|
||||||
if text == status.lower():
|
|
||||||
return status
|
|
||||||
return raw.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _baseline_proof_complete(text: str, baseline_proof: dict | None) -> bool:
|
|
||||||
proof = baseline_proof or {}
|
|
||||||
worktree = (
|
|
||||||
(proof.get("worktree_path") or "").strip()
|
|
||||||
or _first_match(_BASELINE_WORKTREE_USED_RE, text)
|
|
||||||
).lower()
|
|
||||||
if not worktree or worktree in {"none", "n/a", "not used", "not applicable"}:
|
|
||||||
return False
|
|
||||||
if "branches/" not in worktree and not worktree.startswith("branches/"):
|
|
||||||
return False
|
|
||||||
target_sha = (proof.get("baseline_target_sha") or "").strip()
|
|
||||||
if not target_sha:
|
|
||||||
target_sha = _first_match(_BASELINE_TARGET_SHA_RE, text)
|
|
||||||
if not _FULL_SHA.match(target_sha or ""):
|
|
||||||
return False
|
|
||||||
if proof.get("failure_signatures_match") is True:
|
|
||||||
return True
|
|
||||||
if _FAILURE_SIGNATURE_RE.search(text) and _BASELINE_FAILURES_RE.search(text):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_simulation_passed(text: str, command_log: list | None) -> bool:
|
|
||||||
merge_result = _first_match(_MERGE_SIM_RESULT_RE, text).lower()
|
|
||||||
if merge_result in {"passed", "pass", "clean", "succeeded", "success"}:
|
|
||||||
sim = assess_merge_simulation_report(text, command_log=command_log)
|
|
||||||
return sim.get("proven") and not sim.get("block")
|
|
||||||
if "pass" in merge_result and "fail" not in merge_result:
|
|
||||||
sim = assess_merge_simulation_report(text, command_log=command_log)
|
|
||||||
return sim.get("proven") and not sim.get("block")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_head_failed(text: str) -> bool:
|
|
||||||
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
|
|
||||||
if raw in {"failed", "fail", "failure"}:
|
|
||||||
return True
|
|
||||||
if "fail" in raw and "pass" not in raw:
|
|
||||||
return True
|
|
||||||
return bool(re.search(r"\bfailed\b.*pr-head validation", text, re.IGNORECASE))
|
|
||||||
|
|
||||||
|
|
||||||
def _raw_head_passed(text: str) -> bool:
|
|
||||||
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
|
|
||||||
return raw in {"passed", "pass", "success"}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_validation_status_vocabulary(
|
|
||||||
report_text: str,
|
|
||||||
*,
|
|
||||||
command_log: list | None = None,
|
|
||||||
baseline_proof: dict | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Bind validation-status labels to the proof path that actually ran (#406)."""
|
|
||||||
text = report_text or ""
|
|
||||||
reasons: list[str] = []
|
|
||||||
status_raw = _first_match(_STATUS_FIELD_RE, text)
|
|
||||||
status = _normalize_status_label(status_raw) if status_raw else ""
|
|
||||||
|
|
||||||
if status_raw and status not in ALLOWED_VALIDATION_STATUSES:
|
|
||||||
reasons.append(
|
|
||||||
f"unknown validation status {status_raw!r}; use one of "
|
|
||||||
f"{sorted(ALLOWED_VALIDATION_STATUSES)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_BASELINE_EQUIVALENT:
|
|
||||||
if not _baseline_proof_complete(text, baseline_proof):
|
|
||||||
reasons.append(
|
|
||||||
"baseline-equivalent failure accepted requires baseline "
|
|
||||||
"worktree path, baseline target SHA, and matching failure "
|
|
||||||
"signatures (#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_MERGE_SIM_RESOLVED:
|
|
||||||
if not _raw_head_failed(text):
|
|
||||||
reasons.append(
|
|
||||||
"raw-head failure resolved by merge simulation requires "
|
|
||||||
"raw PR-head validation result: failed (#406)"
|
|
||||||
)
|
|
||||||
if not _merge_simulation_passed(text, command_log):
|
|
||||||
reasons.append(
|
|
||||||
"raw-head failure resolved by merge simulation requires "
|
|
||||||
"passing merge simulation with worktree/index mutation proof "
|
|
||||||
"(#317/#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_TRANSIENT_PASS:
|
|
||||||
if not _TRANSIENT_HISTORY_RE.search(text):
|
|
||||||
reasons.append(
|
|
||||||
"passed after transient failure investigation requires "
|
|
||||||
"documented earlier validation failure history (#396/#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_PASSED and _raw_head_failed(text):
|
|
||||||
reasons.append(
|
|
||||||
"validation status passed contradicts raw PR-head validation "
|
|
||||||
"failure; use a precise status (#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_BASELINE_EQUIVALENT and _merge_simulation_passed(
|
|
||||||
text, command_log
|
|
||||||
) and not _baseline_proof_complete(text, baseline_proof):
|
|
||||||
reasons.append(
|
|
||||||
"baseline-equivalent failure accepted is misleading when only "
|
|
||||||
"merge simulation resolved the failure; use "
|
|
||||||
"'raw-head failure resolved by merge simulation' (#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == STATUS_FAILED and _merge_simulation_passed(text, command_log):
|
|
||||||
reasons.append(
|
|
||||||
"validation status failed contradicts passing merge simulation; "
|
|
||||||
"report the precise resolution status (#406)"
|
|
||||||
)
|
|
||||||
|
|
||||||
block = bool(reasons)
|
|
||||||
return {
|
|
||||||
"block": block,
|
|
||||||
"proven": not block,
|
|
||||||
"status_claimed": status or None,
|
|
||||||
"raw_status_label": status_raw or None,
|
|
||||||
"raw_head_failed": _raw_head_failed(text),
|
|
||||||
"raw_head_passed": _raw_head_passed(text),
|
|
||||||
"merge_simulation_passed": _merge_simulation_passed(text, command_log),
|
|
||||||
"baseline_proof_complete": _baseline_proof_complete(text, baseline_proof),
|
|
||||||
"reasons": reasons,
|
|
||||||
"safe_next_action": (
|
|
||||||
"use a validation status that matches the proof path executed "
|
|
||||||
"(baseline worktree, merge simulation, or transient history)"
|
|
||||||
if reasons
|
|
||||||
else "proceed"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user