From 8a8cffbdf530c10094566b3920bf61748482a4ad Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 09:57:50 -0400
Subject: [PATCH 01/42] feat: gate reviewer mutations on canonical workflow
load proof (Closes #389)
Add gitea_load_review_workflow and in-process session proof required before
gitea_submit_pr_review, gitea_mark_final_review_decision, and gitea_merge_pr.
Capability and runtime context report workflow-load status; stale or missing
proof fails closed with recovery handoff text that forbids approve/merge replay.
---
gitea_mcp_server.py | 79 +++++++++++-
review_workflow_load.py | 198 +++++++++++++++++++++++++++++
tests/test_llm_agent_sha.py | 2 +
tests/test_mcp_server.py | 19 ++-
tests/test_permission_reports.py | 2 +
tests/test_review_workflow_load.py | 151 ++++++++++++++++++++++
6 files changed, 445 insertions(+), 6 deletions(-)
create mode 100644 review_workflow_load.py
create mode 100644 tests/test_review_workflow_load.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 9913535..02635c7 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -474,6 +474,7 @@ import role_session_router # noqa: E402
import role_namespace_gate # noqa: E402
import task_capability_map # noqa: E402
import review_proofs # noqa: E402
+import review_workflow_load # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
@@ -1636,6 +1637,7 @@ def init_review_decision_lock(remote: str | None, task: str | None):
"""Seed read-only-until-ready state for reviewer PR review tasks."""
if task != "review_pr":
return
+ review_workflow_load.clear_review_workflow_load()
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip()
session_lock = (
@@ -1661,6 +1663,11 @@ def init_review_decision_lock(remote: str | None, task: str | None):
})
+def _review_workflow_load_gate_reasons() -> list[str]:
+ """Fail closed when canonical review workflow was not loaded (#389)."""
+ return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
+
+
def check_review_decision_gate(
pr_number: int,
action: str,
@@ -1671,7 +1678,10 @@ def check_review_decision_gate(
repo: str | None = None,
) -> list[str]:
"""Fail closed unless validation completed and the final decision is ready."""
- reasons = []
+ reasons = list(_review_workflow_load_gate_reasons())
+ if reasons:
+ reasons.extend(review_workflow_load.recovery_handoff_without_replay())
+ return reasons
lock = _load_review_decision_lock()
if lock is None:
reasons.append(
@@ -2027,6 +2037,7 @@ def _evaluate_pr_review_submission(
"""Shared gate chain for live submit and dry-run review tools."""
verify_preflight_purity(remote)
action = (action or "").strip().lower()
+ workflow_blockers = _review_workflow_load_gate_reasons() if live else []
result = {
"requested_action": action,
"performed": False,
@@ -2042,6 +2053,10 @@ def _evaluate_pr_review_submission(
"reasons": [],
}
reasons = result["reasons"]
+ if workflow_blockers:
+ reasons.extend(workflow_blockers)
+ reasons.extend(review_workflow_load.recovery_handoff_without_replay())
+ return result
if action not in _REVIEW_ACTIONS:
reasons.append(
@@ -2226,6 +2241,13 @@ def gitea_mark_final_review_decision(
}
org = resolved_org
repo = resolved_repo
+ workflow_blockers = _review_workflow_load_gate_reasons()
+ if workflow_blockers:
+ return {
+ "marked_ready": False,
+ "reasons": workflow_blockers + (
+ review_workflow_load.recovery_handoff_without_replay()),
+ }
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
if hard_stop:
return {"marked_ready": False, "reasons": hard_stop}
@@ -2809,6 +2831,7 @@ def gitea_merge_pr(
available. Never secrets.
"""
verify_preflight_purity(remote)
+ workflow_blockers = _review_workflow_load_gate_reasons()
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -2826,6 +2849,10 @@ def gitea_merge_pr(
"reasons": [],
}
reasons = result["reasons"]
+ if workflow_blockers:
+ reasons.extend(workflow_blockers)
+ reasons.extend(review_workflow_load.recovery_handoff_without_replay())
+ return result
# Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS:
@@ -4175,6 +4202,8 @@ _PROJECT_SKILLS = {
"steps": [
"Resolve task first: gitea_resolve_task_capability(task='review_pr') "
"to confirm reviewer namespace and avoid author-profile blocks.",
+ "Load canonical workflow proof with gitea_load_review_workflow "
+ "before any review/merge mutation (#389).",
"Verify reviewer identity with gitea_whoami; the PR author "
"must be a different user.",
"Reconcile live queue state FIRST (do not trust prior handoffs): "
@@ -4978,6 +5007,8 @@ def gitea_get_runtime_context(
"preflight_workspace": preflight.get("preflight_workspace"),
"session_capabilities": session_capabilities,
"shell_health": native_mcp_preference.shell_health_status(),
+ "workflow_load_proof": review_workflow_load.workflow_load_status(
+ PROJECT_ROOT),
}
if reveal and h:
@@ -4986,6 +5017,42 @@ def gitea_get_runtime_context(
return result
+@mcp.tool()
+def gitea_load_review_workflow(
+ prompt_text: str | None = None,
+) -> dict:
+ """Load and record canonical review-merge workflow proof for this session (#389).
+
+ Read-only with respect to Gitea API; records in-process workflow source/hash
+ proof required before reviewer review or merge mutations.
+ """
+ try:
+ recorded = review_workflow_load.record_review_workflow_load(
+ PROJECT_ROOT, prompt_text=prompt_text)
+ except OSError as exc:
+ return {
+ "success": False,
+ "loaded": False,
+ "reasons": [str(exc)],
+ "recovery_handoff": review_workflow_load.recovery_handoff_without_replay(),
+ }
+ return {
+ "success": True,
+ "loaded": True,
+ "workflow_source": recorded["workflow_source"],
+ "task_mode": recorded["task_mode"],
+ "workflow_hash": recorded["workflow_hash"],
+ "workflow_version": recorded["workflow_version"],
+ "final_report_schema_path": recorded["final_report_schema_path"],
+ "final_report_schema_hash": recorded["final_report_schema_hash"],
+ "prompt_conflicts_with_workflow": recorded[
+ "prompt_conflicts_with_workflow"],
+ "prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [],
+ "workflow_load_proof_present": True,
+ "reasons": [],
+ }
+
+
@mcp.tool()
def gitea_list_profiles() -> dict:
"""Read-only: list all Gitea MCP profiles with redacted metadata.
@@ -6041,6 +6108,16 @@ def gitea_resolve_task_capability(
}
if reason_msg:
result["reason"] = reason_msg
+ if task in ("review_pr", "merge_pr"):
+ result["workflow_load_proof"] = review_workflow_load.workflow_load_status(
+ PROJECT_ROOT)
+ if not result["workflow_load_proof"].get("workflow_load_valid"):
+ guidance = (
+ "Call gitea_load_review_workflow before any reviewer review "
+ "or merge mutation."
+ )
+ if guidance not in task_role_guidance:
+ task_role_guidance.append(guidance)
role_session_router.sync_route_from_capability(result)
was_terminal = capability_stop_terminal.is_active()
terminal = capability_stop_terminal.sync_from_capability_result(result)
diff --git a/review_workflow_load.py b/review_workflow_load.py
new file mode 100644
index 0000000..070c29a
--- /dev/null
+++ b/review_workflow_load.py
@@ -0,0 +1,198 @@
+"""Canonical review-merge workflow load proof for reviewer mutations (#389)."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+from pathlib import Path
+
+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)
+ _REVIEW_WORKFLOW_LOAD = {
+ **meta,
+ "session_pid": os.getpid(),
+ "loaded": True,
+ }
+ 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
+
+
+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)
+ 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"),
+ "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."""
+ status = workflow_load_status(project_root)
+ if not status.get("workflow_load_proof_present"):
+ return list(status.get("reasons") or [])
+ 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.",
+ ]
\ No newline at end of file
diff --git a/tests/test_llm_agent_sha.py b/tests/test_llm_agent_sha.py
index 1a6ecff..e751567 100644
--- a/tests/test_llm_agent_sha.py
+++ b/tests/test_llm_agent_sha.py
@@ -22,6 +22,7 @@ from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
+import mcp_server # noqa: E402
from mcp_server import ( # noqa: E402
gitea_check_pr_eligibility,
gitea_merge_pr,
@@ -130,6 +131,7 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
+ mcp_server.gitea_load_review_workflow()
gitea_mark_final_review_decision(9, "approve", remote="prgs")
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
with patch.dict(os.environ, env, clear=True):
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 99d7d73..1928e2a 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -55,6 +55,12 @@ _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):
"""Mark a request_changes decision ready with the #332 duplicate-
suppression feedback fetch stubbed to 'no existing blocker'."""
@@ -512,6 +518,9 @@ class TestViewPR(unittest.TestCase):
class TestMergePR(unittest.TestCase):
"""Gated merge workflow (#16). gitea_merge_pr is the only merge path."""
+ def setUp(self):
+ mcp_server.gitea_load_review_workflow()
+
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {
"user": {"login": author},
@@ -984,8 +993,7 @@ class TestReviewPR(unittest.TestCase):
{"login": "jcwalker3"}, # /api/v1/user (submit eligibility)
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/1
]
- from mcp_server import init_review_decision_lock
- init_review_decision_lock("prgs", "review_pr")
+ _init_reviewer_session("prgs")
gitea_mark_final_review_decision(1, "approve", remote="prgs")
result = gitea_review_pr(
pr_number=1,
@@ -1643,7 +1651,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
}
def setUp(self):
- init_review_decision_lock("prgs", "review_pr")
+ _init_reviewer_session("prgs")
def _env(self):
return patch.dict(os.environ, {
@@ -1738,7 +1746,7 @@ class TestSubmitPrReview(unittest.TestCase):
"""Gated review-mutation tool (#15)."""
def setUp(self):
- init_review_decision_lock("prgs", "review_pr")
+ _init_reviewer_session("prgs")
gitea_mark_final_review_decision(8, "approve", remote="prgs")
def _pr(self, author, state="open", sha="abc123", mergeable=True):
@@ -2131,7 +2139,7 @@ class TestSubmitPrReview(unittest.TestCase):
os.remove(spoof_path)
def test_mark_final_decision_rejects_remote_mismatch(self):
- init_review_decision_lock("prgs", "review_pr")
+ _init_reviewer_session("prgs")
r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools")
self.assertFalse(r["marked_ready"])
self.assertTrue(any("does not match locked remote" in x for x in r["reasons"]))
@@ -2213,6 +2221,7 @@ if __name__ == "__main__":
class TestTrackerHygieneCleanup(unittest.TestCase):
def setUp(self):
+ mcp_server.gitea_load_review_workflow()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
patch("gitea_audit.audit_enabled", return_value=True).start()
diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py
index 5483ee1..b317a00 100644
--- a/tests/test_permission_reports.py
+++ b/tests/test_permission_reports.py
@@ -230,6 +230,7 @@ class TestEligibilityDenialReport(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
+ mcp_server.gitea_load_review_workflow()
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
@@ -272,6 +273,7 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
+ mcp_server.gitea_load_review_workflow()
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
diff --git a/tests/test_review_workflow_load.py b/tests/test_review_workflow_load.py
new file mode 100644
index 0000000..b611422
--- /dev/null
+++ b/tests/test_review_workflow_load.py
@@ -0,0 +1,151 @@
+"""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.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()
\ No newline at end of file
From db032ef93cb5dfbc388fe57a37dc6f1fee2e1874 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 11:32:25 -0400
Subject: [PATCH 02/42] feat: add precise validation status vocabulary for
reviewer reports (Closes #406)
Introduce validation_status_vocabulary with proof-path binding for baseline-
equivalent, merge-simulation-resolved, and transient-pass labels. Wire the
assessor into final_report_validator and document the taxonomy in the
review-merge workflow.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
final_report_validator.py | 30 +++
.../workflows/review-merge-pr.md | 22 ++
tests/test_validation_status_vocabulary.py | 198 +++++++++++++++++
validation_status_vocabulary.py | 205 ++++++++++++++++++
4 files changed, 455 insertions(+)
create mode 100644 tests/test_validation_status_vocabulary.py
create mode 100644 validation_status_vocabulary.py
diff --git a/final_report_validator.py b/final_report_validator.py
index 3fbac0f..9b99482 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -20,6 +20,7 @@ from review_proofs import (
assess_review_mutation_final_report,
assess_validation_report,
)
+from validation_status_vocabulary import assess_validation_status_vocabulary
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
@@ -598,6 +599,34 @@ 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]]:
text = report_text or ""
if "baseline worktree path" not in text.lower():
@@ -902,6 +931,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
+ _rule_reviewer_validation_status_vocabulary,
_rule_reviewer_main_checkout_baseline,
_rule_reviewer_main_checkout_path,
_rule_reviewer_already_landed_eligible,
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 5b0e10a..1516c7f 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -568,6 +568,28 @@ If the cause is unknown, do not erase the earlier failure with plain
`gitea_validate_review_final_report` rejects reports that omit known earlier
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
Do not run tests in the main checkout.
diff --git a/tests/test_validation_status_vocabulary.py b/tests/test_validation_status_vocabulary.py
new file mode 100644
index 0000000..f01f3e9
--- /dev/null
+++ b/tests/test_validation_status_vocabulary.py
@@ -0,0 +1,198 @@
+"""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()
\ No newline at end of file
diff --git a/validation_status_vocabulary.py b/validation_status_vocabulary.py
new file mode 100644
index 0000000..6f4e91a
--- /dev/null
+++ b/validation_status_vocabulary.py
@@ -0,0 +1,205 @@
+"""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"
+ ),
+ }
\ No newline at end of file
From 87beb44394d58d86d19fb018832ce798e38f4fa3 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 11:44:58 -0400
Subject: [PATCH 03/42] feat: add conflict-fix leases and stale-head protection
(Closes #399)
Introduce structured PR work leases so author conflict-fix pushes cannot
race reviewer validation, approval, or merge on a moving head SHA.
- pr_work_lease.py: parse/acquire leases, push and reviewer mutation gates
- gitea_acquire_conflict_fix_lease, gitea_assess_conflict_fix_push MCP tools
- Enforce reviewed head SHA on mark_final_review_decision, submit_pr_review, merge_pr
- Final-report rules and workflow sections 20A / 26B
- tests/test_pr_work_lease.py (14 cases)
---
final_report_validator.py | 41 ++
gitea_mcp_server.py | 223 +++++++-
pr_work_lease.py | 482 ++++++++++++++++++
.../workflows/review-merge-pr.md | 18 +
.../workflows/work-issue.md | 23 +
tests/test_pr_work_lease.py | 207 ++++++++
6 files changed, 993 insertions(+), 1 deletion(-)
create mode 100644 pr_work_lease.py
create mode 100644 tests/test_pr_work_lease.py
diff --git a/final_report_validator.py b/final_report_validator.py
index 3fbac0f..d8bd96f 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -490,6 +490,45 @@ def _rule_reviewer_validation_failure_history(
]
+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]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
@@ -909,6 +948,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_target_branch_freshness,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
+ _rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
@@ -930,6 +970,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_vague_mutations_none,
+ _rule_conflict_fix_push_proof,
],
"issue_filing": [
_rule_shared_controller_handoff,
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 0bc82e4..543e8f6 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -507,6 +507,7 @@ import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
import review_merge_state_machine # noqa: E402
+import pr_work_lease # noqa: E402
import native_mcp_preference # noqa: E402
@@ -2197,6 +2198,50 @@ def gitea_get_pr_review_feedback(
}
+def _list_pr_lease_comments(
+ pr_number: int,
+ *,
+ remote: str,
+ host: str | None,
+ org: str | None,
+ repo: str | None,
+ limit: int = 100,
+) -> list[dict]:
+ """Fetch PR/issue thread comments used for reviewer/conflict-fix leases."""
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
+ comments = api_request("GET", api, auth) or []
+ return list(comments[:limit])
+
+
+def _pr_work_lease_reviewer_block(
+ *,
+ pr_number: int,
+ reviewed_head_sha: str | None,
+ live_head_sha: str | None,
+ mutation: str,
+ remote: str,
+ host: str | None,
+ org: str | None,
+ repo: str | None,
+) -> dict:
+ comments = _list_pr_lease_comments(
+ pr_number,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ return pr_work_lease.assess_reviewer_mutation_blocked(
+ pr_number=pr_number,
+ comments=comments,
+ reviewed_head_sha=reviewed_head_sha,
+ live_head_sha=live_head_sha,
+ mutation=mutation,
+ )
+
+
def _evaluate_pr_review_submission(
pr_number: int,
action: str,
@@ -2281,6 +2326,11 @@ def _evaluate_pr_review_submission(
lock = _load_review_decision_lock() or {}
if live and lock.get("ready_expected_head_sha"):
pinned_sha = lock.get("ready_expected_head_sha")
+ if live and not pinned_sha:
+ reasons.append(
+ "reviewed head SHA required before live review mutation (fail closed, #399)"
+ )
+ return result
if pinned_sha and actual_sha and pinned_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
@@ -2290,6 +2340,21 @@ def _evaluate_pr_review_submission(
reasons.append("PR head SHA unavailable (fail closed)")
return result
+ lease_block = _pr_work_lease_reviewer_block(
+ pr_number=pr_number,
+ reviewed_head_sha=pinned_sha,
+ live_head_sha=actual_sha,
+ mutation=action,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ if lease_block.get("block"):
+ reasons.extend(lease_block.get("reasons") or [])
+ result["pr_work_lease"] = lease_block
+ return result
+
result["would_perform"] = True
if not live:
reasons.append(
@@ -2432,6 +2497,39 @@ def gitea_mark_final_review_decision(
f"{sorted(_REVIEW_ACTIONS)}"
],
}
+ if not (expected_head_sha or "").strip():
+ return {
+ "marked_ready": False,
+ "reasons": [
+ "expected_head_sha required before marking final review "
+ "decision (fail closed, #399)"
+ ],
+ }
+ elig = gitea_check_pr_eligibility(
+ pr_number=pr_number,
+ action="review",
+ remote=remote,
+ host=None,
+ org=org,
+ repo=repo,
+ )
+ live_head = elig.get("head_sha")
+ lease_block = _pr_work_lease_reviewer_block(
+ pr_number=pr_number,
+ reviewed_head_sha=expected_head_sha,
+ live_head_sha=live_head,
+ mutation="mark_ready",
+ remote=remote,
+ host=None,
+ org=org,
+ repo=repo,
+ )
+ if lease_block.get("block"):
+ return {
+ "marked_ready": False,
+ "reasons": lease_block.get("reasons") or [],
+ "pr_work_lease": lease_block,
+ }
if action == "request_changes":
# Duplicate request-changes suppression (#332): an unresolved
# REQUEST_CHANGES at the current head must not be duplicated.
@@ -3055,8 +3153,27 @@ def gitea_merge_pr(
result["permission_report"] = elig["permission_report"]
return result
- # Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
+ # Gate 4 — reviewed head SHA is mandatory and must match live PR head (#399).
actual_sha = result["head_sha"]
+ if not (expected_head_sha or "").strip():
+ reasons.append(
+ "expected_head_sha required before merge (fail closed, #399)"
+ )
+ return result
+ lease_block = _pr_work_lease_reviewer_block(
+ pr_number=pr_number,
+ reviewed_head_sha=expected_head_sha,
+ live_head_sha=actual_sha,
+ mutation="merge",
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ if lease_block.get("block"):
+ reasons.extend(lease_block.get("reasons") or [])
+ result["pr_work_lease"] = lease_block
+ return result
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
@@ -6084,6 +6201,110 @@ def gitea_post_heartbeat(
)
+@mcp.tool()
+def gitea_acquire_conflict_fix_lease(
+ pr_number: int,
+ branch: str,
+ worktree_path: str,
+ head_before: str,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Acquire a conflict-fix lease on a PR branch before pushing (#399)."""
+ blocked = _profile_permission_block(
+ task_capability_map.required_permission("comment_issue"))
+ if blocked:
+ return blocked
+ verify_preflight_purity(remote, worktree_path=worktree_path)
+ comments = _list_pr_lease_comments(
+ pr_number,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ reviewer_lease = pr_work_lease.find_active_reviewer_lease(
+ comments, pr_number=pr_number)
+ if reviewer_lease:
+ return {
+ "acquired": False,
+ "reasons": [
+ f"active reviewer lease on PR #{pr_number}; cannot acquire "
+ "conflict-fix lease (fail closed)"
+ ],
+ "active_reviewer_lease": reviewer_lease,
+ }
+ profile_name = get_profile().get("profile_name") or "unknown"
+ body = pr_work_lease.format_conflict_fix_lease_body(
+ pr_number=pr_number,
+ branch=branch,
+ worktree=worktree_path,
+ profile=profile_name,
+ head_before=head_before,
+ reviewer_active=bool(reviewer_lease),
+ )
+ posted = _post_structured_issue_comment(
+ issue_number=pr_number,
+ body=body,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ audit_op="conflict_fix_lease_acquire",
+ )
+ return {
+ "acquired": posted.get("success", False),
+ "pr_number": pr_number,
+ "branch": branch,
+ "worktree_path": worktree_path,
+ "head_before": head_before,
+ "comment_id": posted.get("comment_id"),
+ "active_reviewer_lease": reviewer_lease,
+ "reasons": [] if posted.get("success") else ["lease comment post failed"],
+ }
+
+
+@mcp.tool()
+def gitea_assess_conflict_fix_push(
+ pr_number: int,
+ branch_head_before: str,
+ branch_head_after: str,
+ worktree_path: str,
+ push_cwd: str,
+ is_fast_forward: bool = True,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Read-only pre-push gate for author conflict-fix sessions (#399)."""
+ read_block = _profile_operation_gate("gitea.read")
+ if read_block:
+ return {
+ "push_allowed": False,
+ "reasons": read_block,
+ "permission_report": _permission_block_report("gitea.read"),
+ }
+ comments = _list_pr_lease_comments(
+ pr_number,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ return pr_work_lease.assess_conflict_fix_push(
+ pr_number=pr_number,
+ comments=comments,
+ branch_head_before=branch_head_before,
+ branch_head_after=branch_head_after,
+ worktree_path=worktree_path,
+ push_cwd=push_cwd,
+ is_fast_forward=is_fast_forward,
+ )
+
+
@mcp.tool()
def gitea_reconcile_issue_claims(
state: str = "open",
diff --git a/pr_work_lease.py b/pr_work_lease.py
new file mode 100644
index 0000000..e2b3f21
--- /dev/null
+++ b/pr_work_lease.py
@@ -0,0 +1,482 @@
+"""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 = ""
+CONFLICT_FIX_LEASE_MARKER = ""
+
+_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,
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 5b0e10a..fe784ca 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -732,6 +732,24 @@ The final report must identify:
* whether same-PR merge continuation was allowed
* whether the run stopped as required
+## 26B. 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
Before merge, rerun fresh live checks:
diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md
index f450cd7..0ee51a1 100644
--- a/skills/llm-project-workflow/workflows/work-issue.md
+++ b/skills/llm-project-workflow/workflows/work-issue.md
@@ -577,6 +577,29 @@ After push, report:
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
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
diff --git a/tests/test_pr_work_lease.py b/tests/test_pr_work_lease.py
new file mode 100644
index 0000000..8dbcb33
--- /dev/null
+++ b/tests/test_pr_work_lease.py
@@ -0,0 +1,207 @@
+#!/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()
\ No newline at end of file
From cf057d78297ed185c478ace3dbbde3ea5898fde4 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 11:35:43 -0400
Subject: [PATCH 04/42] feat: move duplicate-work detection before author
mutations (Closes #400)
Extract issue_work_duplicate_gate for open PR, remote branch, and active
claim checks; wire it into lock_issue, commit_files recheck, and create_pr
recheck. Add gitea_assess_work_issue_duplicate read-only preflight, work-issue
report outcome validation, and workflow section 10A.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 213 +++++++++++++----
issue_work_duplicate_gate.py | 180 ++++++++++++++
review_proofs.py | 3 +
.../workflows/work-issue.md | 30 +++
tests/test_issue_work_duplicate_gate.py | 219 ++++++++++++++++++
tests/test_review_proofs.py | 1 +
6 files changed, 606 insertions(+), 40 deletions(-)
create mode 100644 issue_work_duplicate_gate.py
create mode 100644 tests/test_issue_work_duplicate_gate.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 0bc82e4..c4e4fdc 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -503,6 +503,7 @@ import issue_lock_worktree # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
+import issue_work_duplicate_gate # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
@@ -641,6 +642,103 @@ def _branch_entry_name(branch: dict | str) -> str:
return str(branch.get("name") or branch.get("ref") or "")
+def _collect_issue_duplicate_context(
+ h: str,
+ o: str,
+ r: str,
+ auth: str,
+ issue_number: int,
+) -> tuple[list[dict], list[str], dict]:
+ """Live open PRs, remote branch names, and claim state for one issue."""
+ base = repo_api_url(h, o, r)
+ open_prs = api_get_all(f"{base}/pulls?state=open", auth)
+ branches = api_get_all(f"{base}/branches", auth)
+ branch_names = [_branch_entry_name(b) for b in branches]
+ issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
+ comments = api_request(
+ "GET", f"{base}/issues/{issue_number}/comments", auth
+ ) or []
+ claim_entry = issue_claim_heartbeat.classify_issue_claim(
+ issue=issue,
+ comments=comments,
+ open_prs=open_prs,
+ branch_names=branch_names,
+ )
+ return open_prs, branch_names, claim_entry
+
+
+def _assess_issue_duplicate_gate(
+ issue_number: int,
+ *,
+ h: str,
+ o: str,
+ r: str,
+ auth: str,
+ locked_branch: str | None = None,
+ phase: str,
+) -> dict:
+ open_prs, branch_names, claim_entry = _collect_issue_duplicate_context(
+ h, o, r, auth, issue_number
+ )
+ return issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
+ issue_number,
+ open_prs=open_prs,
+ branch_names=branch_names,
+ claim_entry=claim_entry,
+ locked_branch=locked_branch,
+ phase=phase,
+ )
+
+
+def _duplicate_gate_block_response(gate: dict, **extra) -> dict:
+ out = {
+ "success": False,
+ "performed": False,
+ "reasons": list(gate.get("reasons") or []),
+ "duplicate_gate": gate,
+ "safe_next_action": gate.get("safe_next_action"),
+ }
+ out.update(extra)
+ return out
+
+
+def _enforce_locked_issue_duplicate_recheck(
+ remote: str,
+ phase: str,
+ *,
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict | None:
+ """Re-check duplicate-work gates for the locked issue (#400)."""
+ lock_data = _load_existing_issue_lock()
+ if not lock_data:
+ return None
+ issue_number = int(lock_data.get("issue_number") or 0)
+ locked_branch = lock_data.get("branch_name")
+ if not issue_number:
+ return None
+ h, o, r = _resolve(
+ remote or lock_data.get("remote") or "dadeschools",
+ host or lock_data.get("host"),
+ org or lock_data.get("org"),
+ repo or lock_data.get("repo"),
+ )
+ auth = _auth(h)
+ gate = _assess_issue_duplicate_gate(
+ issue_number,
+ h=h,
+ o=o,
+ r=r,
+ auth=auth,
+ locked_branch=locked_branch,
+ phase=phase,
+ )
+ if gate.get("block"):
+ return gate
+ return None
+
+
def _reveal_endpoints() -> bool:
"""Admin/debug opt-in (#120): include endpoint URLs and token source
names in tool output. Off by default so normal LLM-facing responses
@@ -1111,48 +1209,21 @@ def gitea_lock_issue(
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
)
- # 2. Check if the issue already has an open PR (reuse protection)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
- url = f"{repo_api_url(h, o, r)}/pulls?state=open"
-
- try:
- prs = api_get_all(url, auth)
- except Exception as e:
- raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
-
- for pr in prs:
- pr_head = pr.get("head", {}).get("ref", "")
- pr_title = pr.get("title", "")
- pr_body = pr.get("body", "")
-
- if expected_pattern in pr_head:
- raise ValueError(
- f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
- )
-
- patterns = [
- f"closes #{issue_number}",
- f"fixes #{issue_number}",
- ]
- text_to_check = f"{pr_title} {pr_body}".lower()
- if any(p in text_to_check for p in patterns):
- raise ValueError(
- f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
- )
-
- branch_url = f"{repo_api_url(h, o, r)}/branches"
- try:
- branches = api_get_all(branch_url, auth)
- except Exception as e:
- raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
- for branch in branches:
- name = _branch_entry_name(branch)
- if expected_pattern in name:
- raise ValueError(
- f"Issue #{issue_number} already has matching branch '{name}' "
- "(fail closed)"
- )
+ duplicate_gate = _assess_issue_duplicate_gate(
+ issue_number,
+ h=h,
+ o=o,
+ r=r,
+ auth=auth,
+ locked_branch=branch_name,
+ phase=issue_work_duplicate_gate.PHASE_LOCK,
+ )
+ if duplicate_gate.get("block"):
+ raise ValueError("; ".join(duplicate_gate.get("reasons") or [
+ f"duplicate work gate blocked issue #{issue_number} (fail closed)"
+ ]))
work_lease = _build_author_issue_work_lease(
issue_number=issue_number,
@@ -1198,6 +1269,39 @@ def gitea_lock_issue(
return result
+@mcp.tool()
+def gitea_assess_work_issue_duplicate(
+ issue_number: int,
+ branch_name: str | None = None,
+ phase: str = issue_work_duplicate_gate.PHASE_LOCK,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Read-only duplicate-work gate for author sessions before mutations (#400)."""
+ read_block = _profile_operation_gate("gitea.read")
+ if read_block:
+ return {
+ "success": False,
+ "performed": False,
+ "reasons": read_block,
+ "permission_report": _permission_block_report("gitea.read"),
+ }
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ gate = _assess_issue_duplicate_gate(
+ issue_number,
+ h=h,
+ o=o,
+ r=r,
+ auth=auth,
+ locked_branch=branch_name,
+ phase=phase,
+ )
+ return {"success": not gate.get("block"), **gate}
+
+
@mcp.tool()
def gitea_create_pr(
title: str,
@@ -1289,6 +1393,21 @@ def gitea_create_pr(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
+ duplicate_block = _enforce_locked_issue_duplicate_recheck(
+ remote,
+ issue_work_duplicate_gate.PHASE_CREATE_PR,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ if duplicate_block:
+ return _duplicate_gate_block_response(
+ duplicate_block,
+ number=None,
+ issue_number=locked_issue,
+ branch_name=locked_branch,
+ )
+
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
@@ -2893,6 +3012,20 @@ def gitea_commit_files(
if blocked:
return blocked
+ duplicate_block = _enforce_locked_issue_duplicate_recheck(
+ remote,
+ issue_work_duplicate_gate.PHASE_COMMIT,
+ host=host,
+ org=org,
+ repo=repo,
+ )
+ if duplicate_block:
+ return _duplicate_gate_block_response(
+ duplicate_block,
+ commit="",
+ branch="",
+ )
+
verify_preflight_purity(remote)
processed_files, source_proofs = _prepare_commit_payload_files(files)
diff --git a/issue_work_duplicate_gate.py b/issue_work_duplicate_gate.py
new file mode 100644
index 0000000..fc70437
--- /dev/null
+++ b/issue_work_duplicate_gate.py
@@ -0,0 +1,180 @@
+"""Early duplicate-work detection for author work-issue sessions (#400)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import issue_claim_heartbeat as claim_hb
+
+PHASE_LOCK = "lock_issue"
+PHASE_COMMIT = "commit"
+PHASE_PUSH = "push"
+PHASE_CREATE_PR = "create_pr"
+
+OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
+OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
+OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
+OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
+
+_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
+
+
+def _issue_pattern(issue_number: int) -> str:
+ return f"issue-{int(issue_number)}"
+
+
+def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
+ return claim_hb._linked_open_pr(issue_number, open_prs)
+
+
+def _matching_branches(
+ issue_number: int,
+ branch_names: list[str],
+ *,
+ locked_branch: str | None = None,
+) -> list[str]:
+ pattern = _issue_pattern(issue_number)
+ matches = [
+ name for name in (branch_names or [])
+ if pattern in (name or "").lower()
+ ]
+ if locked_branch:
+ locked = locked_branch.strip()
+ matches = [name for name in matches if name != locked]
+ return matches
+
+
+def assess_work_issue_duplicate_gate(
+ issue_number: int,
+ *,
+ open_prs: list[dict] | None = None,
+ branch_names: list[str] | None = None,
+ claim_entry: dict | None = None,
+ locked_branch: str | None = None,
+ phase: str = PHASE_LOCK,
+) -> dict[str, Any]:
+ """Fail closed when duplicate work is already in flight for an issue."""
+ reasons: list[str] = []
+ outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
+ prs = list(open_prs or [])
+ branches = list(branch_names or [])
+ pattern = _issue_pattern(issue_number)
+
+ linked = _linked_open_pr(issue_number, prs)
+ if linked:
+ reasons.append(
+ f"open PR #{linked.get('number')} already covers issue "
+ f"#{issue_number} (fail closed)"
+ )
+ outcome = OUTCOME_DUPLICATE_PR_PREVENTED
+
+ conflicting_branches = _matching_branches(
+ issue_number, branches, locked_branch=locked_branch
+ )
+ if conflicting_branches:
+ names = ", ".join(conflicting_branches[:5])
+ reasons.append(
+ f"remote branch(es) already match issue pattern '{pattern}': "
+ f"{names} (fail closed)"
+ )
+ if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
+ outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
+
+ entry = claim_entry or {}
+ if entry.get("linked_open_pr") and not linked:
+ reasons.append(
+ f"claim inventory reports open PR #{entry['linked_open_pr']} "
+ f"for issue #{issue_number} (fail closed)"
+ )
+ outcome = OUTCOME_DUPLICATE_PR_PREVENTED
+
+ status = (entry.get("status") or "").strip().lower()
+ if status in _ACTIVE_CLAIM_STATUSES and not linked:
+ heartbeat = entry.get("latest_heartbeat") or {}
+ claim_branch = (heartbeat.get("branch") or "").strip()
+ if locked_branch and claim_branch and claim_branch != locked_branch:
+ reasons.append(
+ f"active claim lease on branch '{claim_branch}' blocks "
+ f"work on '{locked_branch}' for issue #{issue_number} "
+ "(fail closed)"
+ )
+ if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
+ outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
+ elif not locked_branch and status == "active":
+ reasons.append(
+ f"issue #{issue_number} has an active claim lease "
+ "(fail closed)"
+ )
+ if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
+ outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
+
+ if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
+ if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
+ outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
+ elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
+ outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
+
+ block = bool(reasons)
+ return {
+ "block": block,
+ "performed": not block,
+ "issue_number": issue_number,
+ "phase": phase,
+ "outcome": outcome,
+ "linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
+ "conflicting_branches": conflicting_branches,
+ "claim_status": status or None,
+ "reasons": reasons,
+ "safe_next_action": (
+ "stop before mutating; preserve local work and produce a "
+ "reconciliation handoff if a concurrent PR appeared after push"
+ if block and phase == PHASE_CREATE_PR
+ else "stop before mutating; do not commit or push duplicate work"
+ if block
+ else "proceed"
+ ),
+ }
+
+
+def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
+ """Require explicit duplicate-work outcome wording in work-issue reports."""
+ text = (report_text or "").lower()
+ markers = {
+ OUTCOME_DUPLICATE_PR_PREVENTED: (
+ "duplicate pr prevented",
+ "duplicate_pr_prevented",
+ ),
+ OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
+ "duplicate branch prevented",
+ "duplicate_branch_prevented",
+ ),
+ OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
+ "duplicate commit prevented",
+ "duplicate_commit_prevented",
+ ),
+ OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
+ "duplicate work not prevented",
+ "duplicate_work_not_prevented",
+ "no duplicate work",
+ ),
+ }
+ matched = [
+ key for key, phrases in markers.items()
+ if any(phrase in text for phrase in phrases)
+ ]
+ if len(matched) != 1:
+ return {
+ "complete": False,
+ "downgraded": True,
+ "reasons": [
+ "work-issue report must state exactly one duplicate-work "
+ "outcome (duplicate PR/branch/commit prevented, or "
+ "duplicate work not prevented)"
+ ],
+ }
+ return {
+ "complete": True,
+ "downgraded": False,
+ "outcome": matched[0],
+ "reasons": [],
+ }
\ No newline at end of file
diff --git a/review_proofs.py b/review_proofs.py
index 20cf89b..b74ebb8 100644
--- a/review_proofs.py
+++ b/review_proofs.py
@@ -3624,9 +3624,12 @@ def assess_work_issue_mode_isolation(report_text: str) -> dict:
def assess_work_issue_final_report(report_text: str) -> dict:
"""#139: composite verifier for work-issue final reports."""
+ from issue_work_duplicate_gate import assess_work_issue_duplicate_report
+
checks = {
"workflow_source": assess_work_issue_workflow_source(report_text),
"mode_isolation": assess_work_issue_mode_isolation(report_text),
+ "duplicate_work_outcome": assess_work_issue_duplicate_report(report_text),
}
reasons = []
diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md
index f450cd7..c7bc8a7 100644
--- a/skills/llm-project-workflow/workflows/work-issue.md
+++ b/skills/llm-project-workflow/workflows/work-issue.md
@@ -297,6 +297,36 @@ Report:
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
+### 10A. Duplicate-work gate phases (#400)
+
+Before any file edits, prove duplicate-work clearance with
+`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
+gate). The gate checks live:
+
+* open PRs linked to the issue (head branch or Closes/Fixes reference),
+* remote branches matching `issue-`,
+* active claim leases from structured heartbeats.
+
+Re-check immediately before:
+
+* `gitea_commit_files` (commit),
+* branch push,
+* `gitea_create_pr` (PR creation).
+
+If a concurrent open PR appears after work begins:
+
+* before commit/push → stop and preserve local work without pushing,
+* after commit but before push → stop without pushing,
+* after push but before PR creation → stop and produce a reconciliation
+ handoff instead of opening a PR.
+
+Final reports must state exactly one duplicate-work outcome:
+
+* `duplicate PR prevented`
+* `duplicate branch prevented`
+* `duplicate commit prevented`
+* `duplicate work not prevented`
+
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py
new file mode 100644
index 0000000..02816ce
--- /dev/null
+++ b/tests/test_issue_work_duplicate_gate.py
@@ -0,0 +1,219 @@
+"""Tests for early duplicate-work detection (#400)."""
+import json
+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_work_duplicate_gate as dup_gate
+import mcp_server
+from issue_work_duplicate_gate import (
+ OUTCOME_DUPLICATE_BRANCH_PREVENTED,
+ OUTCOME_DUPLICATE_COMMIT_PREVENTED,
+ OUTCOME_DUPLICATE_PR_PREVENTED,
+ OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
+ PHASE_COMMIT,
+ PHASE_CREATE_PR,
+ PHASE_LOCK,
+ assess_work_issue_duplicate_gate,
+ assess_work_issue_duplicate_report,
+)
+
+
+class TestDuplicateGateAssessment(unittest.TestCase):
+ def test_clear_issue_passes(self):
+ result = assess_work_issue_duplicate_gate(
+ 400,
+ open_prs=[],
+ branch_names=["feat/other-issue-99"],
+ claim_entry={"status": "not_claimed"},
+ locked_branch="feat/issue-400-duplicate-work-preflight",
+ phase=PHASE_LOCK,
+ )
+ self.assertFalse(result["block"])
+ self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
+
+ def test_open_pr_blocks(self):
+ prs = [{
+ "number": 397,
+ "title": "feat: handoff",
+ "body": "Closes #395",
+ "head": {"ref": "feat/issue-395-proof-backed-review-handoff"},
+ }]
+ result = assess_work_issue_duplicate_gate(
+ 395,
+ open_prs=prs,
+ branch_names=[],
+ phase=PHASE_LOCK,
+ )
+ self.assertTrue(result["block"])
+ self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
+
+ def test_conflicting_remote_branch_blocks(self):
+ result = assess_work_issue_duplicate_gate(
+ 395,
+ open_prs=[],
+ branch_names=["feat/issue-395-proof-backed-handoff-claims"],
+ locked_branch="feat/issue-395-new-attempt",
+ phase=PHASE_LOCK,
+ )
+ self.assertTrue(result["block"])
+ self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED)
+
+ def test_active_claim_on_other_branch_blocks(self):
+ result = assess_work_issue_duplicate_gate(
+ 398,
+ open_prs=[],
+ branch_names=[],
+ claim_entry={
+ "status": "active",
+ "latest_heartbeat": {
+ "branch": "feat/issue-398-validation-cwd-proof",
+ },
+ },
+ locked_branch="feat/issue-398-other-branch",
+ phase=PHASE_LOCK,
+ )
+ self.assertTrue(result["block"])
+
+ def test_commit_phase_maps_to_commit_outcome(self):
+ prs = [{
+ "number": 411,
+ "title": "x",
+ "body": "Closes #398",
+ "head": {"ref": "feat/issue-398-validation-cwd-proof"},
+ }]
+ result = assess_work_issue_duplicate_gate(
+ 398,
+ open_prs=prs,
+ branch_names=[],
+ locked_branch="feat/issue-398-alt",
+ phase=PHASE_COMMIT,
+ )
+ self.assertTrue(result["block"])
+ self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED)
+
+ def test_stale_claim_does_not_block_by_status_alone(self):
+ result = assess_work_issue_duplicate_gate(
+ 400,
+ open_prs=[],
+ branch_names=[],
+ claim_entry={"status": "reclaimable", "reasons": ["stale"]},
+ locked_branch="feat/issue-400-duplicate-work-preflight",
+ phase=PHASE_LOCK,
+ )
+ self.assertFalse(result["block"])
+
+
+class TestDuplicateReportOutcome(unittest.TestCase):
+ def test_requires_exactly_one_outcome(self):
+ bad = assess_work_issue_duplicate_report("work finished")
+ self.assertFalse(bad["complete"])
+
+ good = assess_work_issue_duplicate_report(
+ "Duplicate work not prevented for issue #400."
+ )
+ self.assertTrue(good["complete"])
+ self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
+
+
+class TestMcpDuplicateRecheck(unittest.TestCase):
+ def setUp(self):
+ self._dir = tempfile.TemporaryDirectory()
+ self.lock_path = os.path.join(self._dir.name, "gitea_issue_lock.json")
+ self._lock_patch = patch.object(
+ mcp_server, "ISSUE_LOCK_FILE", self.lock_path
+ )
+ self._lock_patch.start()
+ self._remotes = patch.dict(mcp_server.REMOTES, {
+ "prgs": {"host": "gitea.example.com", "org": "Example-Org",
+ "repo": "Example-Repo"},
+ })
+ self._remotes.start()
+ mcp_server._IDENTITY_CACHE.clear()
+
+ def tearDown(self):
+ patch.stopall()
+ self._dir.cleanup()
+
+ def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
+ with open(self.lock_path, "w", encoding="utf-8") as fh:
+ json.dump({
+ "issue_number": issue_number,
+ "branch_name": branch,
+ "remote": "prgs",
+ }, fh)
+
+ @patch("mcp_server._assess_issue_duplicate_gate")
+ @patch("mcp_server.get_profile", return_value={
+ "profile_name": "test-author",
+ "allowed_operations": ["gitea.read", "gitea.repo.commit"],
+ "forbidden_operations": [],
+ "audit_label": "test-author",
+ })
+ @patch("mcp_server.get_auth_header", return_value="token x")
+ def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
+ self._write_lock()
+ mock_gate.return_value = {
+ "block": True,
+ "reasons": ["open PR #412 already covers issue #400"],
+ "outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED,
+ "safe_next_action": "stop",
+ }
+ mcp_server.record_preflight_check("whoami")
+ mcp_server.record_preflight_check("capability", resolved_role="author")
+ with patch(
+ "mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
+ return_value=(True, []),
+ ):
+ result = mcp_server.gitea_commit_files(
+ files=[{
+ "operation": "create",
+ "path": "a.txt",
+ "content_plain": "hi",
+ }],
+ message="test",
+ remote="prgs",
+ )
+ self.assertFalse(result["success"])
+ self.assertIn("duplicate_gate", result)
+
+ @patch("mcp_server._assess_issue_duplicate_gate")
+ @patch("mcp_server.get_profile", return_value={
+ "profile_name": "test-author",
+ "allowed_operations": ["gitea.read", "gitea.pr.create"],
+ "forbidden_operations": [],
+ "audit_label": "test-author",
+ })
+ def test_create_pr_returns_handoff_on_duplicate(self, _profile, mock_gate):
+ self._write_lock()
+ mock_gate.return_value = {
+ "block": True,
+ "reasons": ["open PR #412 already covers issue #400"],
+ "outcome": OUTCOME_DUPLICATE_PR_PREVENTED,
+ "safe_next_action": "reconciliation handoff",
+ }
+ mcp_server.record_preflight_check("whoami")
+ mcp_server.record_preflight_check("capability", resolved_role="author")
+ with patch(
+ "mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
+ return_value=(True, []),
+ ):
+ result = mcp_server.gitea_create_pr(
+ title="feat: x (Closes #400)",
+ head="feat/issue-400-x",
+ base="master",
+ body="Closes #400",
+ remote="prgs",
+ )
+ self.assertFalse(result["success"])
+ self.assertIsNone(result.get("number"))
+ self.assertIn("duplicate_gate", result)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_review_proofs.py b/tests/test_review_proofs.py
index f132fc2..03d3a6a 100644
--- a/tests/test_review_proofs.py
+++ b/tests/test_review_proofs.py
@@ -2346,6 +2346,7 @@ class TestWorkIssueFinalReport(unittest.TestCase):
"- Safe next action: open PR",
"- Next: open PR",
"- Safety statement: no review/merge",
+ "- Duplicate work outcome: duplicate work not prevented",
])
def test_complete_work_issue_report_earns_a(self):
From 0bad26230b1066fd35cec8c318ddc5638a0d3c14 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:08:53 -0400
Subject: [PATCH 05/42] fix: resolve conflicts for PR #413
Rebase onto current prgs/master and patch create_pr duplicate-gate test
with auth header mock to match post-rebase recheck path.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_issue_work_duplicate_gate.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py
index 02816ce..a041ef3 100644
--- a/tests/test_issue_work_duplicate_gate.py
+++ b/tests/test_issue_work_duplicate_gate.py
@@ -189,7 +189,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"forbidden_operations": [],
"audit_label": "test-author",
})
- def test_create_pr_returns_handoff_on_duplicate(self, _profile, mock_gate):
+ @patch("mcp_server.get_auth_header", return_value="token x")
+ def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
self._write_lock()
mock_gate.return_value = {
"block": True,
From 1320a55a7ea255375889f89fb102a13e74fc2573 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 11:28:34 -0400
Subject: [PATCH 06/42] feat: require validation cwd and HEAD proof in reviewer
reports (Closes #398)
Rebased onto current prgs/master; merged with #396 validation failure
history by keeping both validator rules and workflow handoff fields.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
final_report_validator.py | 37 +++
review_proofs.py | 7 +
reviewer_validation_cwd_proof.py | 233 ++++++++++++++++++
.../workflows/review-merge-pr.md | 23 ++
tests/test_llm_workflow_split.py | 6 +
tests/test_reviewer_validation_cwd_proof.py | 135 ++++++++++
6 files changed, 441 insertions(+)
create mode 100644 reviewer_validation_cwd_proof.py
create mode 100644 tests/test_reviewer_validation_cwd_proof.py
diff --git a/final_report_validator.py b/final_report_validator.py
index 3fbac0f..a67ecba 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -490,6 +490,42 @@ 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_validation_command(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
@@ -899,6 +935,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_validation_command,
_rule_reviewer_validation_failure_history,
+ _rule_reviewer_validation_cwd_proof,
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
diff --git a/review_proofs.py b/review_proofs.py
index 20cf89b..5bcd3c7 100644
--- a/review_proofs.py
+++ b/review_proofs.py
@@ -5512,6 +5512,13 @@ def assess_validation_failure_history_report(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):
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
from reviewer_already_landed_classification import (
diff --git a/reviewer_validation_cwd_proof.py b/reviewer_validation_cwd_proof.py
new file mode 100644
index 0000000..15cfb02
--- /dev/null
+++ b/reviewer_validation_cwd_proof.py
@@ -0,0 +1,233 @@
+"""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 , "
+ "cd && ..., 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"
+ ),
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 5b0e10a..5eda726 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -604,6 +604,28 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
## 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 ...`
+* `cd && ...` 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 working directory where validation ran.
@@ -1127,6 +1149,7 @@ Controller Handoff:
* Files reviewed:
* Validation:
* Validation failure history:
+* Validation cwd/HEAD proof:
* Official validation integrity status:
* Terminal review mutation:
* Review decision:
diff --git a/tests/test_llm_workflow_split.py b/tests/test_llm_workflow_split.py
index 44d80ce..a5e5e33 100644
--- a/tests/test_llm_workflow_split.py
+++ b/tests/test_llm_workflow_split.py
@@ -213,6 +213,12 @@ def test_validation_failure_history_verifier_exported():
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():
from review_proofs import assess_prior_blocker_skip_proof
diff --git a/tests/test_reviewer_validation_cwd_proof.py b/tests/test_reviewer_validation_cwd_proof.py
new file mode 100644
index 0000000..59b81fc
--- /dev/null
+++ b/tests/test_reviewer_validation_cwd_proof.py
@@ -0,0 +1,135 @@
+"""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()
\ No newline at end of file
From a8fcf0e01cede2b1f0d3013797f2f05a05b6b002 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:23:09 -0400
Subject: [PATCH 07/42] feat: add internal web UI server skeleton (Closes #426)
Starlette read-only MVP with shared layout, /health JSON liveness, and
route stubs for projects, prompts, runtime, audit, worktrees, and leases.
Includes scripts/run-webui, docs/webui-local-dev.md, and tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/webui-local-dev.md | 53 ++++++++++++++++
scripts/run-webui | 6 ++
tests/test_webui_skeleton.py | 58 +++++++++++++++++
webui/__init__.py | 5 ++
webui/__main__.py | 19 ++++++
webui/app.py | 117 +++++++++++++++++++++++++++++++++++
webui/layout.py | 102 ++++++++++++++++++++++++++++++
7 files changed, 360 insertions(+)
create mode 100644 docs/webui-local-dev.md
create mode 100755 scripts/run-webui
create mode 100644 tests/test_webui_skeleton.py
create mode 100644 webui/__init__.py
create mode 100644 webui/__main__.py
create mode 100644 webui/app.py
create mode 100644 webui/layout.py
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
new file mode 100644
index 0000000..48a540c
--- /dev/null
+++ b/docs/webui-local-dev.md
@@ -0,0 +1,53 @@
+# Internal web UI — local development (#426)
+
+Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
+MCP capability gates, and `skills/llm-project-workflow/` remain the source of
+truth; this UI only provides route stubs and layout.
+
+## Prerequisites
+
+- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
+- No secrets in repo, config, or client bundle
+
+## Start the server
+
+From the repository root (or an issue worktree):
+
+```bash
+./scripts/run-webui
+```
+
+Or directly:
+
+```bash
+python3 -m webui
+```
+
+Optional environment variables:
+
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
+| `WEBUI_PORT` | `8765` | Listen port |
+
+## Routes (MVP)
+
+| Path | Description |
+|------|-------------|
+| `/` | Home / operator overview |
+| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
+| `/projects` | Stub — registry (#427) |
+| `/prompts` | Stub — prompt library (#428) |
+| `/runtime` | Stub — MCP runtime health (#430) |
+| `/audit` | Stub — report audit paste (#431) |
+| `/worktrees` | Stub — hygiene dashboard (#432) |
+| `/leases` | Stub — lease visibility (#433) |
+
+All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
+`read-only-mvp`.
+
+## Tests
+
+```bash
+pytest tests/test_webui_skeleton.py -q
+```
\ No newline at end of file
diff --git a/scripts/run-webui b/scripts/run-webui
new file mode 100755
index 0000000..fe9f58c
--- /dev/null
+++ b/scripts/run-webui
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$script_dir/.." && pwd)"
+cd "$repo_root"
+exec python3 -m webui "$@"
\ No newline at end of file
diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py
new file mode 100644
index 0000000..89471af
--- /dev/null
+++ b/tests/test_webui_skeleton.py
@@ -0,0 +1,58 @@
+"""Tests for internal web UI skeleton (#426)."""
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+
+
+class TestWebuiSkeleton(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_health_returns_json(self):
+ response = self.client.get("/health")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["status"], "ok")
+ self.assertEqual(data["service"], "mcp-control-plane-webui")
+ self.assertEqual(data["mode"], "read-only-mvp")
+ self.assertIn("timestamp", data)
+
+ def test_home_renders(self):
+ response = self.client.get("/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Operator console", response.text)
+ self.assertIn("Read-only MVP", response.text)
+
+ def test_route_stubs_render(self):
+ for path in ("/projects", "/prompts", "/runtime", "/audit"):
+ with self.subTest(path=path):
+ response = self.client.get(path)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("child issue", response.text.lower())
+
+ def test_extra_stub_routes(self):
+ for path in ("/worktrees", "/leases"):
+ with self.subTest(path=path):
+ self.assertEqual(self.client.get(path).status_code, 200)
+
+ def test_post_is_rejected(self):
+ response = self.client.post("/health")
+ self.assertEqual(response.status_code, 405)
+ self.assertEqual(response.json()["error"], "read-only-mvp")
+
+ def test_nav_links_on_all_pages(self):
+ for path in ("/", "/projects", "/prompts", "/runtime", "/audit"):
+ with self.subTest(path=path):
+ text = self.client.get(path).text
+ for href in ("/projects", "/prompts", "/runtime", "/audit"):
+ self.assertIn(f'href="{href}"', text)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/webui/__init__.py b/webui/__init__.py
new file mode 100644
index 0000000..56829f0
--- /dev/null
+++ b/webui/__init__.py
@@ -0,0 +1,5 @@
+"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
+
+from webui.app import create_app
+
+__all__ = ["create_app"]
\ No newline at end of file
diff --git a/webui/__main__.py b/webui/__main__.py
new file mode 100644
index 0000000..c55b05e
--- /dev/null
+++ b/webui/__main__.py
@@ -0,0 +1,19 @@
+"""Run the internal web UI: ``python -m webui``."""
+
+from __future__ import annotations
+
+import os
+
+import uvicorn
+
+from webui.app import create_app
+
+
+def main() -> None:
+ host = os.environ.get("WEBUI_HOST", "127.0.0.1")
+ port = int(os.environ.get("WEBUI_PORT", "8765"))
+ uvicorn.run(create_app(), host=host, port=port, log_level="info")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/webui/app.py b/webui/app.py
new file mode 100644
index 0000000..aa0c10f
--- /dev/null
+++ b/webui/app.py
@@ -0,0 +1,117 @@
+"""Starlette application for the internal read-only web UI (#426)."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import HTMLResponse, JSONResponse, Response
+from starlette.routing import Route
+
+from webui.layout import render_page
+
+_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
+
+
+def _stub_page(title: str, description: str) -> HTMLResponse:
+ body = (
+ f"{title}
"
+ f'{description}
'
+ "
Implementation tracked in a child issue of #425.
"
+ )
+ return HTMLResponse(render_page(title=title, body_html=body))
+
+
+async def home(_request: Request) -> HTMLResponse:
+ body = (
+ "Operator console
"
+ "Local entry point for MCP Control Plane operational views.
"
+ ""
+ "- Projects — registry and onboarding (#427)
"
+ "- Prompts — canonical workflow prompt library (#428)
"
+ "- Runtime — MCP health and stale-runtime detection (#430)
"
+ "- Audit — final-report paste and validator preview (#431)
"
+ "- Worktrees — branch hygiene dashboard (#432)
"
+ "- Leases — collision and lease visibility (#433)
"
+ "
"
+ )
+ return HTMLResponse(render_page(title="Home", body_html=body))
+
+
+async def health(_request: Request) -> JSONResponse:
+ return JSONResponse({
+ "status": "ok",
+ "service": "mcp-control-plane-webui",
+ "mode": "read-only-mvp",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ })
+
+
+async def projects(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Projects",
+ "Project registry and onboarding model will list configured repos and profiles.",
+ )
+
+
+async def prompts(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Prompts",
+ "Prompt library will surface canonical workflows from skills/llm-project-workflow/.",
+ )
+
+
+async def runtime(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Runtime",
+ "Runtime health will report MCP profile, preflight, and stale-server signals.",
+ )
+
+
+async def audit(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Audit",
+ "Report audit will accept pasted final reports and run validator previews.",
+ )
+
+
+async def worktrees(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Worktrees",
+ "Worktree hygiene will summarize branches/ session folders and cleanup risk.",
+ )
+
+
+async def leases(_request: Request) -> HTMLResponse:
+ return _stub_page(
+ "Leases",
+ "Lease visibility will show active issue and reviewer PR leases.",
+ )
+
+
+async def method_not_allowed(request: Request, _exc: Exception) -> Response:
+ if request.method not in _READ_ONLY_METHODS:
+ return JSONResponse(
+ {"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
+ status_code=405,
+ )
+ return JSONResponse({"error": "not_found"}, status_code=404)
+
+
+def create_app() -> Starlette:
+ """Build the read-only MVP Starlette app."""
+ return Starlette(
+ debug=False,
+ routes=[
+ Route("/", home, methods=["GET"]),
+ Route("/health", health, methods=["GET"]),
+ Route("/projects", projects, methods=["GET"]),
+ Route("/prompts", prompts, methods=["GET"]),
+ Route("/runtime", runtime, methods=["GET"]),
+ Route("/audit", audit, methods=["GET"]),
+ Route("/worktrees", worktrees, methods=["GET"]),
+ Route("/leases", leases, methods=["GET"]),
+ ],
+ exception_handlers={405: method_not_allowed},
+ )
\ No newline at end of file
diff --git a/webui/layout.py b/webui/layout.py
new file mode 100644
index 0000000..0aa2a87
--- /dev/null
+++ b/webui/layout.py
@@ -0,0 +1,102 @@
+"""Shared HTML layout for the internal web UI."""
+
+from __future__ import annotations
+
+NAV_ITEMS = (
+ ("/", "Home"),
+ ("/projects", "Projects"),
+ ("/prompts", "Prompts"),
+ ("/runtime", "Runtime"),
+ ("/audit", "Audit"),
+ ("/worktrees", "Worktrees"),
+ ("/leases", "Leases"),
+)
+
+MVP_NOTICE = (
+ "Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
+ "source of truth. No mutation endpoints."
+)
+
+
+def render_page(*, title: str, body_html: str) -> str:
+ nav_links = "".join(
+ f'{label}' for href, label in NAV_ITEMS
+ )
+ return f"""
+
+
+
+
+ {title} · MCP Control Plane
+
+
+
+
+ MCP Control Plane
+
+
+
+ {MVP_NOTICE}
+ {body_html}
+
+
+"""
\ No newline at end of file
From 5512ba7710c3c11f2f3587f80aec284b3d2cbede Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:24:47 -0400
Subject: [PATCH 08/42] test: seed workflow load proof in reviewer mutation
paths (Closes #389)
---
tests/test_audit.py | 4 ++++
tests/test_permission_reports.py | 3 +++
tests/test_pr_queue_inventory.py | 2 ++
tests/test_terminal_review_hard_stop.py | 5 +++++
4 files changed, 14 insertions(+)
diff --git a/tests/test_audit.py b/tests/test_audit.py
index adb9242..768f0d1 100644
--- a/tests/test_audit.py
+++ b/tests/test_audit.py
@@ -157,6 +157,7 @@ class _AuditWiringBase(unittest.TestCase):
def tearDown(self):
mcp_server._IDENTITY_CACHE.clear()
+ mcp_server.review_workflow_load.clear_review_workflow_load()
self._dir.cleanup()
def _env(self, **extra):
@@ -304,6 +305,7 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
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",
expected_head_sha="abc123", remote="prgs")
self.assertTrue(r["performed"])
@@ -323,6 +325,7 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
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")
self.assertFalse(r["performed"])
recs = self._records()
@@ -345,6 +348,7 @@ class TestGatedToolAudit(_AuditWiringBase):
with patch.dict(os.environ, env, clear=True):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
+ mcp_server.gitea_load_review_workflow()
gitea_mark_final_review_decision(8, "approve", remote="prgs")
r = gitea_submit_pr_review(pr_number=8, action="approve",
body="LGTM", remote="prgs",
diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py
index b317a00..c560b7f 100644
--- a/tests/test_permission_reports.py
+++ b/tests/test_permission_reports.py
@@ -99,6 +99,7 @@ class PermissionReportBase(unittest.TestCase):
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
+ mcp_server.review_workflow_load.clear_review_workflow_load()
gitea_config._active_profile_override = None
self._dir.cleanup()
@@ -249,6 +250,8 @@ class TestEligibilityDenialReport(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
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")):
res = mcp_server.gitea_merge_pr(
pr_number=42, confirmation="MERGE PR 42",
diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py
index aeb8bc4..49d2b4d 100644
--- a/tests/test_pr_queue_inventory.py
+++ b/tests/test_pr_queue_inventory.py
@@ -11,6 +11,7 @@ from mcp_server import (
gitea_view_pr,
gitea_review_pr,
gitea_check_pr_eligibility,
+ gitea_load_review_workflow,
)
import gitea_config
@@ -157,6 +158,7 @@ class TestPRQueueInventory(unittest.TestCase):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
+ gitea_load_review_workflow()
gitea_mark_final_review_decision(1, "approve", remote="prgs")
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
self.assertTrue(result["success"])
diff --git a/tests/test_terminal_review_hard_stop.py b/tests/test_terminal_review_hard_stop.py
index bd5109b..3f80fad 100644
--- a/tests/test_terminal_review_hard_stop.py
+++ b/tests/test_terminal_review_hard_stop.py
@@ -35,6 +35,7 @@ def _lock(mutations=None, correction=False):
def _seed(mutations=None, correction=False):
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,
@@ -46,6 +47,7 @@ RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
class TestTerminalHardStopReasons(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
+ mcp_server.review_workflow_load.clear_review_workflow_load()
def test_no_lock_no_reasons(self):
mcp_server._save_review_decision_lock(None)
@@ -91,6 +93,7 @@ class TestTerminalHardStopReasons(unittest.TestCase):
class TestMergeHardStopWiring(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
+ mcp_server.review_workflow_load.clear_review_workflow_load()
def test_merge_blocked_after_request_changes(self):
_seed([RC_A])
@@ -112,6 +115,7 @@ class TestMergeHardStopWiring(unittest.TestCase):
class TestMarkFinalHardStopWiring(unittest.TestCase):
def tearDown(self):
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):
_seed([RC_A])
@@ -134,6 +138,7 @@ def _feedback(blocking, stale=False, success=True):
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
def tearDown(self):
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):
_seed()
From a9265efa82215135a7dd4c4efc42f2de23fb9723 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 14:38:10 -0400
Subject: [PATCH 09/42] feat: harden #274 branches-only guard for
gitea_create_issue
Follow-up on the branches-only worktree guard (#274):
- verify_preflight_purity: validate resolved worktree exists, is a directory,
and belongs to the target repository before author mutations.
- gitea_create_issue: add worktree_path for explicit branches/ workspace proof.
- tests/test_create_issue_workspace_guard.py: six regression cases including
stable-control-checkout rejection with explicit PROJECT_ROOT simulation.
Closes #274
---
gitea_mcp_server.py | 48 ++++++-
tests/test_create_issue_workspace_guard.py | 145 +++++++++++++++++++++
2 files changed, 189 insertions(+), 4 deletions(-)
create mode 100644 tests/test_create_issue_workspace_guard.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 0bc82e4..beb6084 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -440,10 +440,48 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
- if worktree_path:
- dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
+ workspace = author_mutation_worktree.resolve_mutation_workspace(
+ worktree_path,
+ PROJECT_ROOT,
+ active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
+ author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
+ )
+ real_workspace = os.path.realpath(workspace)
+ real_root = os.path.realpath(PROJECT_ROOT)
+
+ if real_workspace != real_root:
+ if not _preflight_in_test_mode():
+ if not os.path.exists(real_workspace):
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)"
+ )
+ if not os.path.isdir(real_workspace):
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)"
+ )
+ try:
+ res = subprocess.run(
+ ["git", "-C", real_workspace, "rev-parse", "--git-common-dir"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ common_dir = os.path.realpath(res.stdout.strip())
+ expected_dir = os.path.realpath(os.path.join(real_root, ".git"))
+ if common_dir != expected_dir:
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)"
+ )
+ except Exception as e:
+ if isinstance(e, RuntimeError):
+ raise e
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)"
+ )
+
+ dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
if dirty_files:
- details = _preflight_workspace_details(worktree_path, dirty_files)
+ details = _preflight_workspace_details(workspace, dirty_files)
raise RuntimeError(
"Pre-flight order violation: Active task workspace has tracked "
"file edits before mutation (fail closed). "
@@ -972,6 +1010,7 @@ def gitea_create_issue(
repo: str | None = None,
allow_duplicate_override: bool = False,
split_from_issue: int | None = None,
+ worktree_path: str | None = None,
) -> dict:
"""Create a new issue on a Gitea repository.
@@ -984,6 +1023,7 @@ def gitea_create_issue(
repo: Override the repository name.
allow_duplicate_override: Operator-approved split after duplicate found.
split_from_issue: Existing duplicate issue number when overriding.
+ worktree_path: Optional path to verify branches-only guard.
Returns:
dict with 'number' of the created issue ('url' only with the reveal opt-in).
@@ -1010,7 +1050,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, worktree_path=worktree_path)
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
diff --git a/tests/test_create_issue_workspace_guard.py b/tests/test_create_issue_workspace_guard.py
new file mode 100644
index 0000000..2cd9f41
--- /dev/null
+++ b/tests/test_create_issue_workspace_guard.py
@@ -0,0 +1,145 @@
+import os
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+# Ensure we import from the repo root
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import gitea_mcp_server as srv
+
+FAKE_AUTH = {"Authorization": "token test-token"}
+# Stable control checkout (parent of branches/), not the MCP server worktree root.
+CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
+PROJECT_ROOT = srv.PROJECT_ROOT
+
+
+class TestCreateIssueWorkspaceGuard(unittest.TestCase):
+
+ def setUp(self):
+ # Reset preflight flags
+ srv._preflight_whoami_called = True
+ srv._preflight_capability_called = True
+ srv._preflight_resolved_role = "author"
+ srv._preflight_whoami_violation = False
+ srv._preflight_capability_violation = False
+
+ # Disable early return in verify_preflight_purity for testing
+ 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._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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
+ def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
+ # Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
+ # path is the stable control checkout (not under branches/), mutation must fail.
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body text")
+ self.assertIn("stable control checkout", str(ctx.exception))
+
+ @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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @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.isdir", return_value=True)
+ @patch("subprocess.run")
+ def test_create_issue_valid_worktree_succeeds(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 PROJECT_ROOT/.git
+ mock_res = MagicMock()
+ mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
+ mock_run.return_value = mock_res
+
+ mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
+
+ # Provide a valid branches path under the control checkout root
+ valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
+ res = srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=valid_path
+ )
+
+ self.assertEqual(res["number"], 42)
+ 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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
+ def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
+ # Path under branches/ but doesn't exist
+ missing_path = os.path.join(
+ CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
+ )
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=missing_path
+ )
+ self.assertIn("does not exist (fail closed)", str(ctx.exception))
+
+ @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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @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.isdir", return_value=True)
+ @patch("subprocess.run")
+ 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")
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=wrong_repo_path
+ )
+ 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._profile_permission_block", return_value=None)
+ @patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
+ def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
+ srv._preflight_whoami_called = False
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body")
+ self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
+
+ @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)
+ def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
+ srv._preflight_capability_called = False
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body")
+ self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
+
+
+if __name__ == "__main__":
+ unittest.main()
From 81fcdb09fd88fdeda702d176fbfb0b74e59dc6e5 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 14:38:10 -0400
Subject: [PATCH 10/42] feat: thread worktree_path through gitea_create_issue
(#450)
Hardens #274 branches-only guard for gitea_create_issue:
- verify_preflight_purity: validate resolved worktree exists, is a directory,
and belongs to the target repository before author mutations.
- gitea_create_issue: add worktree_path threaded into preflight guard.
- tests/test_create_issue_workspace_guard.py: stable-control-checkout rejection
and invalid-path fail-closed cases (PROJECT_ROOT simulated as control checkout).
Preserves WIP commit 8530109 implementation; test fix completes stable-checkout path.
Closes #450
---
gitea_mcp_server.py | 48 ++++++-
tests/test_create_issue_workspace_guard.py | 145 +++++++++++++++++++++
2 files changed, 189 insertions(+), 4 deletions(-)
create mode 100644 tests/test_create_issue_workspace_guard.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 0bc82e4..beb6084 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -440,10 +440,48 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
- if worktree_path:
- dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
+ workspace = author_mutation_worktree.resolve_mutation_workspace(
+ worktree_path,
+ PROJECT_ROOT,
+ active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
+ author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
+ )
+ real_workspace = os.path.realpath(workspace)
+ real_root = os.path.realpath(PROJECT_ROOT)
+
+ if real_workspace != real_root:
+ if not _preflight_in_test_mode():
+ if not os.path.exists(real_workspace):
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)"
+ )
+ if not os.path.isdir(real_workspace):
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)"
+ )
+ try:
+ res = subprocess.run(
+ ["git", "-C", real_workspace, "rev-parse", "--git-common-dir"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ common_dir = os.path.realpath(res.stdout.strip())
+ expected_dir = os.path.realpath(os.path.join(real_root, ".git"))
+ if common_dir != expected_dir:
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)"
+ )
+ except Exception as e:
+ if isinstance(e, RuntimeError):
+ raise e
+ raise RuntimeError(
+ f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)"
+ )
+
+ dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
if dirty_files:
- details = _preflight_workspace_details(worktree_path, dirty_files)
+ details = _preflight_workspace_details(workspace, dirty_files)
raise RuntimeError(
"Pre-flight order violation: Active task workspace has tracked "
"file edits before mutation (fail closed). "
@@ -972,6 +1010,7 @@ def gitea_create_issue(
repo: str | None = None,
allow_duplicate_override: bool = False,
split_from_issue: int | None = None,
+ worktree_path: str | None = None,
) -> dict:
"""Create a new issue on a Gitea repository.
@@ -984,6 +1023,7 @@ def gitea_create_issue(
repo: Override the repository name.
allow_duplicate_override: Operator-approved split after duplicate found.
split_from_issue: Existing duplicate issue number when overriding.
+ worktree_path: Optional path to verify branches-only guard.
Returns:
dict with 'number' of the created issue ('url' only with the reveal opt-in).
@@ -1010,7 +1050,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, worktree_path=worktree_path)
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
diff --git a/tests/test_create_issue_workspace_guard.py b/tests/test_create_issue_workspace_guard.py
new file mode 100644
index 0000000..2cd9f41
--- /dev/null
+++ b/tests/test_create_issue_workspace_guard.py
@@ -0,0 +1,145 @@
+import os
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+# Ensure we import from the repo root
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import gitea_mcp_server as srv
+
+FAKE_AUTH = {"Authorization": "token test-token"}
+# Stable control checkout (parent of branches/), not the MCP server worktree root.
+CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
+PROJECT_ROOT = srv.PROJECT_ROOT
+
+
+class TestCreateIssueWorkspaceGuard(unittest.TestCase):
+
+ def setUp(self):
+ # Reset preflight flags
+ srv._preflight_whoami_called = True
+ srv._preflight_capability_called = True
+ srv._preflight_resolved_role = "author"
+ srv._preflight_whoami_violation = False
+ srv._preflight_capability_violation = False
+
+ # Disable early return in verify_preflight_purity for testing
+ 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._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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
+ def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
+ # Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
+ # path is the stable control checkout (not under branches/), mutation must fail.
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body text")
+ self.assertIn("stable control checkout", str(ctx.exception))
+
+ @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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @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.isdir", return_value=True)
+ @patch("subprocess.run")
+ def test_create_issue_valid_worktree_succeeds(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 PROJECT_ROOT/.git
+ mock_res = MagicMock()
+ mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
+ mock_run.return_value = mock_res
+
+ mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
+
+ # Provide a valid branches path under the control checkout root
+ valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
+ res = srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=valid_path
+ )
+
+ self.assertEqual(res["number"], 42)
+ 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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
+ def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
+ # Path under branches/ but doesn't exist
+ missing_path = os.path.join(
+ CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
+ )
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=missing_path
+ )
+ self.assertIn("does not exist (fail closed)", str(ctx.exception))
+
+ @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_request")
+ @patch("gitea_mcp_server.api_get_all", return_value=[])
+ @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.isdir", return_value=True)
+ @patch("subprocess.run")
+ 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")
+
+ with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=wrong_repo_path
+ )
+ 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._profile_permission_block", return_value=None)
+ @patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
+ def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
+ srv._preflight_whoami_called = False
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body")
+ self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
+
+ @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)
+ def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
+ srv._preflight_capability_called = False
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(title="Test issue", body="body")
+ self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
+
+
+if __name__ == "__main__":
+ unittest.main()
From 16dcf65825b560b7044c97dd7e95705aeebcf3f3 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:27:55 -0400
Subject: [PATCH 11/42] feat: add web UI project registry and onboarding (#427)
Load projects from versioned webui/data/projects.registry.json with profile
mappings, workflow/schema paths, and read-only onboarding checklist UI.
Seeds Gitea-Tools; exposes /projects, /projects/{id}, and /api/projects.
Closes #427
---
docs/webui-local-dev.md | 18 ++-
tests/test_webui_project_registry.py | 111 +++++++++++++++
tests/test_webui_skeleton.py | 7 +-
webui/app.py | 34 ++++-
webui/data/projects.registry.json | 49 +++++++
webui/layout.py | 28 ++++
webui/project_registry.py | 196 +++++++++++++++++++++++++++
webui/project_views.py | 93 +++++++++++++
8 files changed, 529 insertions(+), 7 deletions(-)
create mode 100644 tests/test_webui_project_registry.py
create mode 100644 webui/data/projects.registry.json
create mode 100644 webui/project_registry.py
create mode 100644 webui/project_views.py
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 48a540c..011b71a 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -36,7 +36,9 @@ Optional environment variables:
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
-| `/projects` | Stub — registry (#427) |
+| `/projects` | Project registry list (#427) |
+| `/projects/{id}` | Project detail + onboarding checklist |
+| `/api/projects` | JSON registry export |
| `/prompts` | Stub — prompt library (#428) |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
@@ -46,8 +48,20 @@ Optional environment variables:
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`.
+## Project registry (#427)
+
+Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
+
+Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
+copy outside git. The registry stores repo identity, remotes, profile names,
+workflow/schema path references, and onboarding checklist steps — never tokens
+or credentials.
+
+Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
+`prgs-reviewer`, and `prgs-reconciler` profiles.
+
## Tests
```bash
-pytest tests/test_webui_skeleton.py -q
+pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q
```
\ No newline at end of file
diff --git a/tests/test_webui_project_registry.py b/tests/test_webui_project_registry.py
new file mode 100644
index 0000000..f16774a
--- /dev/null
+++ b/tests/test_webui_project_registry.py
@@ -0,0 +1,111 @@
+"""Tests for web UI project registry (#427)."""
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+from webui.project_registry import (
+ default_registry_path,
+ load_registry,
+ project_to_dict,
+)
+
+
+class TestProjectRegistryLoader(unittest.TestCase):
+ def test_default_registry_loads_gitea_tools(self):
+ registry = load_registry()
+ self.assertEqual(registry.version, 1)
+ self.assertEqual(len(registry.projects), 1)
+ project = registry.projects[0]
+ self.assertEqual(project.id, "gitea-tools")
+ self.assertEqual(project.repo_name, "Gitea-Tools")
+ self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
+ self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
+ self.assertEqual(project.profiles["author"], "prgs-author")
+ self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
+ self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
+ self.assertIn("skill", project.workflow_paths)
+ self.assertGreaterEqual(len(project.onboarding_checklist), 4)
+
+ def test_registry_rejects_credential_keys(self):
+ payload = {
+ "version": 1,
+ "projects": [
+ {
+ "id": "bad",
+ "repo_name": "Bad",
+ "gitea_owner": "Org",
+ "remote_host": "https://gitea.example.invalid",
+ "default_branch": "main",
+ "local_checkout_path": ".",
+ "profiles": {
+ "author": "a",
+ "reviewer": "r",
+ "reconciler": "c",
+ },
+ "workflow_paths": {"skill": "skills/x.md"},
+ "api_token": "secret",
+ }
+ ],
+ }
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
+ json.dump(payload, handle)
+ path = Path(handle.name)
+ try:
+ with self.assertRaises(ValueError):
+ load_registry(path)
+ finally:
+ path.unlink(missing_ok=True)
+
+ def test_default_registry_path_points_at_packaged_data(self):
+ path = default_registry_path()
+ self.assertTrue(path.name == "projects.registry.json")
+ self.assertTrue(path.parent.name == "data")
+
+
+class TestProjectRegistryRoutes(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_projects_page_lists_gitea_tools(self):
+ response = self.client.get("/projects")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Gitea-Tools", response.text)
+ self.assertIn("Scaled-Tech-Consulting", response.text)
+ self.assertIn("prgs-author", response.text)
+ self.assertNotIn("child issue", response.text.lower())
+
+ def test_project_detail_renders_checklist(self):
+ response = self.client.get("/projects/gitea-tools")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Onboarding checklist", response.text)
+ self.assertIn("Configure execution profiles", response.text)
+ self.assertIn("branches/", response.text)
+
+ def test_project_detail_404(self):
+ response = self.client.get("/projects/unknown-repo")
+ self.assertEqual(response.status_code, 404)
+
+ def test_api_projects_json(self):
+ response = self.client.get("/api/projects")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["version"], 1)
+ self.assertEqual(len(data["projects"]), 1)
+ self.assertEqual(data["projects"][0]["id"], "gitea-tools")
+ self.assertIn("onboarding_checklist", data["projects"][0])
+
+ def test_project_to_dict_is_json_safe(self):
+ registry = load_registry()
+ encoded = json.dumps(project_to_dict(registry.projects[0]))
+ self.assertIn("gitea-tools", encoded)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py
index 89471af..7e139c6 100644
--- a/tests/test_webui_skeleton.py
+++ b/tests/test_webui_skeleton.py
@@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Read-only MVP", response.text)
def test_route_stubs_render(self):
- for path in ("/projects", "/prompts", "/runtime", "/audit"):
+ for path in ("/prompts", "/runtime", "/audit"):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
+ def test_projects_is_implemented(self):
+ response = self.client.get("/projects")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Gitea-Tools", response.text)
+
def test_extra_stub_routes(self):
for path in ("/worktrees", "/leases"):
with self.subTest(path=path):
diff --git a/webui/app.py b/webui/app.py
index aa0c10f..4f1cba9 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -10,6 +10,8 @@ from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
from webui.layout import render_page
+from webui.project_registry import find_project, load_registry, registry_to_dict
+from webui.project_views import render_project_detail, render_projects_list
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -49,10 +51,32 @@ async def health(_request: Request) -> JSONResponse:
async def projects(_request: Request) -> HTMLResponse:
- return _stub_page(
- "Projects",
- "Project registry and onboarding model will list configured repos and profiles.",
- )
+ registry = load_registry()
+ return HTMLResponse(render_projects_list(registry))
+
+
+async def project_detail(request: Request) -> HTMLResponse:
+ project_id = request.path_params["project_id"]
+ registry = load_registry()
+ project = find_project(registry, project_id)
+ if project is None:
+ return HTMLResponse(
+ render_page(
+ title="Project not found",
+ body_html=(
+ "Project not found
"
+ f"No registry entry for {project_id}.
"
+ '← All projects
'
+ ),
+ ),
+ status_code=404,
+ )
+ return HTMLResponse(render_project_detail(project))
+
+
+async def api_projects(_request: Request) -> JSONResponse:
+ registry = load_registry()
+ return JSONResponse(registry_to_dict(registry))
async def prompts(_request: Request) -> HTMLResponse:
@@ -107,6 +131,8 @@ def create_app() -> Starlette:
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
+ Route("/projects/{project_id}", project_detail, methods=["GET"]),
+ Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
diff --git a/webui/data/projects.registry.json b/webui/data/projects.registry.json
new file mode 100644
index 0000000..cd6c852
--- /dev/null
+++ b/webui/data/projects.registry.json
@@ -0,0 +1,49 @@
+{
+ "version": 1,
+ "projects": [
+ {
+ "id": "gitea-tools",
+ "repo_name": "Gitea-Tools",
+ "gitea_owner": "Scaled-Tech-Consulting",
+ "remote_host": "https://gitea.prgs.cc",
+ "default_branch": "master",
+ "local_checkout_path": ".",
+ "profiles": {
+ "author": "prgs-author",
+ "reviewer": "prgs-reviewer",
+ "reconciler": "prgs-reconciler"
+ },
+ "workflow_paths": {
+ "skill": "skills/llm-project-workflow/SKILL.md",
+ "work_issue": "skills/llm-project-workflow/workflows/work-issue.md",
+ "review_merge": "skills/llm-project-workflow/workflows/review-merge-pr.md"
+ },
+ "schema_paths": {
+ "mcp_config_v2": "gitea-mcp.v2-contexts.example.json",
+ "mcp_config_v1": "gitea-mcp.example.json"
+ },
+ "onboarding_checklist": [
+ {
+ "id": "profiles",
+ "title": "Configure execution profiles",
+ "description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
+ },
+ {
+ "id": "mcp_config",
+ "title": "Wire MCP v2 contexts",
+ "description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
+ },
+ {
+ "id": "wiki_gate",
+ "title": "Wiki publication readiness",
+ "description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
+ },
+ {
+ "id": "branches_layout",
+ "title": "Isolate work under branches/",
+ "description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/webui/layout.py b/webui/layout.py
index 0aa2a87..fc75efb 100644
--- a/webui/layout.py
+++ b/webui/layout.py
@@ -87,6 +87,34 @@ def render_page(*, title: str, body_html: str) -> str:
padding-left: 0.85rem;
margin: 1rem 0;
}}
+ .meta {{ font-size: 0.85rem; }}
+ table.registry, table.detail {{
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1rem 0;
+ font-size: 0.9rem;
+ }}
+ table.registry th, table.registry td,
+ table.detail th, table.detail td {{
+ text-align: left;
+ padding: 0.45rem 0.6rem;
+ border-bottom: 1px solid var(--border);
+ }}
+ table.registry th, table.detail th {{
+ color: var(--muted);
+ font-weight: 500;
+ }}
+ code {{
+ font-family: ui-monospace, monospace;
+ font-size: 0.85em;
+ color: var(--text);
+ }}
+ ol.checklist {{
+ padding-left: 1.25rem;
+ margin: 0.5rem 0 1.5rem;
+ }}
+ ol.checklist li {{ margin-bottom: 0.85rem; }}
+ ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
diff --git a/webui/project_registry.py b/webui/project_registry.py
new file mode 100644
index 0000000..2c5e889
--- /dev/null
+++ b/webui/project_registry.py
@@ -0,0 +1,196 @@
+"""Load and validate the web UI project registry (#427)."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+_FORBIDDEN_EXACT_KEYS = frozenset({
+ "token",
+ "password",
+ "secret",
+ "credential",
+ "auth",
+ "api_key",
+ "api-key",
+})
+_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
+_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
+
+
+def _is_forbidden_key(key: str) -> bool:
+ lowered = key.lower()
+ if lowered in _FORBIDDEN_EXACT_KEYS:
+ return True
+ return (
+ lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
+ or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
+ )
+
+_REQUIRED_PROJECT_FIELDS = (
+ "id",
+ "repo_name",
+ "gitea_owner",
+ "remote_host",
+ "default_branch",
+ "local_checkout_path",
+ "profiles",
+ "workflow_paths",
+)
+
+_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
+
+
+@dataclass(frozen=True)
+class OnboardingStep:
+ id: str
+ title: str
+ description: str
+
+
+@dataclass(frozen=True)
+class ProjectRecord:
+ id: str
+ repo_name: str
+ gitea_owner: str
+ remote_host: str
+ default_branch: str
+ local_checkout_path: str
+ profiles: dict[str, str]
+ workflow_paths: dict[str, str]
+ schema_paths: dict[str, str]
+ onboarding_checklist: tuple[OnboardingStep, ...]
+
+
+@dataclass(frozen=True)
+class ProjectRegistry:
+ version: int
+ projects: tuple[ProjectRecord, ...]
+ source_path: Path
+
+
+def default_registry_path() -> Path:
+ override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
+ if override:
+ return Path(override).expanduser().resolve()
+ return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
+
+
+def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
+ if isinstance(obj, dict):
+ for key, value in obj.items():
+ key_path = f"{path}.{key}" if path else key
+ if _is_forbidden_key(key):
+ raise ValueError(f"registry must not store credentials ({key_path})")
+ _reject_credential_keys(value, path=key_path)
+ elif isinstance(obj, list):
+ for index, item in enumerate(obj):
+ _reject_credential_keys(item, path=f"{path}[{index}]")
+
+
+def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
+ if not raw:
+ return ()
+ steps: list[OnboardingStep] = []
+ for item in raw:
+ steps.append(
+ OnboardingStep(
+ id=str(item["id"]),
+ title=str(item["title"]),
+ description=str(item["description"]),
+ )
+ )
+ return tuple(steps)
+
+
+def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
+ missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
+ if missing:
+ raise ValueError(f"project missing required fields: {', '.join(missing)}")
+
+ profiles = raw["profiles"]
+ if not isinstance(profiles, dict):
+ raise ValueError("profiles must be an object")
+ for role in _REQUIRED_PROFILE_ROLES:
+ if role not in profiles or not profiles[role]:
+ raise ValueError(f"profiles.{role} is required")
+
+ workflow_paths = raw["workflow_paths"]
+ if not isinstance(workflow_paths, dict) or not workflow_paths:
+ raise ValueError("workflow_paths must be a non-empty object")
+
+ schema_paths = raw.get("schema_paths") or {}
+ if not isinstance(schema_paths, dict):
+ raise ValueError("schema_paths must be an object when present")
+
+ return ProjectRecord(
+ id=str(raw["id"]),
+ repo_name=str(raw["repo_name"]),
+ gitea_owner=str(raw["gitea_owner"]),
+ remote_host=str(raw["remote_host"]),
+ default_branch=str(raw["default_branch"]),
+ local_checkout_path=str(raw["local_checkout_path"]),
+ profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
+ workflow_paths={key: str(value) for key, value in workflow_paths.items()},
+ schema_paths={key: str(value) for key, value in schema_paths.items()},
+ onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
+ )
+
+
+def load_registry(path: Path | None = None) -> ProjectRegistry:
+ """Load the versioned project registry from disk."""
+ source = (path or default_registry_path()).resolve()
+ raw_text = source.read_text(encoding="utf-8")
+ payload = json.loads(raw_text)
+ if not isinstance(payload, dict):
+ raise ValueError("registry root must be an object")
+
+ version = payload.get("version")
+ if version != 1:
+ raise ValueError(f"unsupported registry version: {version!r}")
+
+ _reject_credential_keys(payload)
+
+ projects_raw = payload.get("projects")
+ if not isinstance(projects_raw, list) or not projects_raw:
+ raise ValueError("projects must be a non-empty array")
+
+ projects = tuple(_parse_project(item) for item in projects_raw)
+ return ProjectRegistry(version=version, projects=projects, source_path=source)
+
+
+def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
+ """Serialize a project for JSON API responses."""
+ return {
+ "id": project.id,
+ "repo_name": project.repo_name,
+ "gitea_owner": project.gitea_owner,
+ "remote_host": project.remote_host,
+ "default_branch": project.default_branch,
+ "local_checkout_path": project.local_checkout_path,
+ "profiles": dict(project.profiles),
+ "workflow_paths": dict(project.workflow_paths),
+ "schema_paths": dict(project.schema_paths),
+ "onboarding_checklist": [
+ {"id": step.id, "title": step.title, "description": step.description}
+ for step in project.onboarding_checklist
+ ],
+ }
+
+
+def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
+ return {
+ "version": registry.version,
+ "source_path": str(registry.source_path),
+ "projects": [project_to_dict(project) for project in registry.projects],
+ }
+
+
+def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
+ for project in registry.projects:
+ if project.id == project_id:
+ return project
+ return None
\ No newline at end of file
diff --git a/webui/project_views.py b/webui/project_views.py
new file mode 100644
index 0000000..c5076ef
--- /dev/null
+++ b/webui/project_views.py
@@ -0,0 +1,93 @@
+"""HTML views for project registry pages (#427)."""
+
+from __future__ import annotations
+
+import html
+
+from webui.layout import render_page
+from webui.project_registry import ProjectRecord, ProjectRegistry
+
+
+def _escape(text: str) -> str:
+ return html.escape(text, quote=True)
+
+
+def render_projects_list(registry: ProjectRegistry) -> str:
+ rows = []
+ for project in registry.projects:
+ rows.append(
+ ""
+ f"| {_escape(project.repo_name)} | "
+ f"{_escape(project.gitea_owner)} | "
+ f"{_escape(project.remote_host)} | "
+ f"{_escape(project.default_branch)} | "
+ f"{_escape(project.profiles['author'])} | "
+ "
"
+ )
+ table = (
+ ""
+ ""
+ "| Repository | Owner | Remote | "
+ "Branch | Author profile | "
+ "
"
+ f"{''.join(rows)}
"
+ )
+ body = (
+ "Projects
"
+ "Configured repositories managed by the MCP Control Plane.
"
+ f"Registry: {_escape(str(registry.source_path))} "
+ f"(version {registry.version})
"
+ f"{table}"
+ "JSON API
"
+ )
+ return render_page(title="Projects", body_html=body)
+
+
+def render_project_detail(project: ProjectRecord) -> str:
+ profile_rows = "".join(
+ f"| {_escape(role)} | {_escape(name)} |
"
+ for role, name in project.profiles.items()
+ )
+ workflow_rows = "".join(
+ f"| {_escape(key)} | {_escape(path)} |
"
+ for key, path in project.workflow_paths.items()
+ )
+ schema_rows = "".join(
+ f"| {_escape(key)} | {_escape(path)} |
"
+ for key, path in project.schema_paths.items()
+ )
+ checklist_items = []
+ for index, step in enumerate(project.onboarding_checklist, start=1):
+ checklist_items.append(
+ ""
+ f"{index}. {_escape(step.title)}"
+ f"{_escape(step.description)}
"
+ ""
+ )
+ checklist_html = (
+ "" + "".join(checklist_items) + "
"
+ if checklist_items
+ else "No onboarding steps defined.
"
+ )
+ body = (
+ f"{_escape(project.repo_name)}
"
+ "← All projects
"
+ "Identity
"
+ ""
+ f"| Registry id | {_escape(project.id)} |
"
+ f"| Gitea owner | {_escape(project.gitea_owner)} |
"
+ f"| Remote host | {_escape(project.remote_host)} |
"
+ f"| Default branch | {_escape(project.default_branch)} |
"
+ f"| Local checkout | {_escape(project.local_checkout_path)} |
"
+ "
"
+ "Profiles
"
+ f""
+ "Workflow paths
"
+ f""
+ "Schema paths
"
+ f""
+ "Onboarding checklist
"
+ "Read-only MVP — complete these steps outside the UI.
"
+ f"{checklist_html}"
+ )
+ return render_page(title=project.repo_name, body_html=body)
\ No newline at end of file
From 6670c72e65f89606d7ca86e3cd613a0a78797b73 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:31:40 -0400
Subject: [PATCH 12/42] feat: add canonical workflow prompt library to web UI
(Closes #428)
Expose short copy/paste operator prompts on /prompts derived from canonical
workflow files with workflow path and SHA-256 hash. Adds JSON export at
/api/prompts, copy buttons, and tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/webui-local-dev.md | 13 +-
tests/test_webui_prompt_library.py | 81 ++++++++++++
tests/test_webui_skeleton.py | 7 +-
webui/app.py | 12 +-
webui/layout.py | 33 ++++-
webui/prompt_library.py | 192 +++++++++++++++++++++++++++++
webui/prompt_views.py | 80 ++++++++++++
7 files changed, 410 insertions(+), 8 deletions(-)
create mode 100644 tests/test_webui_prompt_library.py
create mode 100644 webui/prompt_library.py
create mode 100644 webui/prompt_views.py
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 011b71a..4b94aed 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -39,7 +39,8 @@ Optional environment variables:
| `/projects` | Project registry list (#427) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
-| `/prompts` | Stub — prompt library (#428) |
+| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
+| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
@@ -60,8 +61,16 @@ or credentials.
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
`prgs-reviewer`, and `prgs-reconciler` profiles.
+## Prompt library (#428)
+
+Prompts are generated at load time from canonical workflow files under
+`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
+`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
+copy/paste starters; canonical workflow files remain the only full policy
+source.
+
## Tests
```bash
-pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q
+pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q
```
\ No newline at end of file
diff --git a/tests/test_webui_prompt_library.py b/tests/test_webui_prompt_library.py
new file mode 100644
index 0000000..442800d
--- /dev/null
+++ b/tests/test_webui_prompt_library.py
@@ -0,0 +1,81 @@
+"""Tests for web UI prompt library (#428)."""
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+from webui.prompt_library import load_prompt_library, prompt_to_dict
+
+
+class TestWebuiPromptLibrary(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_library_has_required_prompts(self):
+ entries = load_prompt_library()
+ slugs = {entry.slug for entry in entries}
+ self.assertEqual(
+ slugs,
+ {
+ "review-pr",
+ "work-issue",
+ "create-issue",
+ "comment-issue",
+ "cleanup",
+ "audit",
+ "onboarding",
+ },
+ )
+
+ def test_workflow_entries_include_hash_and_task_mode(self):
+ review = next(e for e in load_prompt_library() if e.slug == "review-pr")
+ self.assertEqual(
+ review.workflow_path,
+ "skills/llm-project-workflow/workflows/review-merge-pr.md",
+ )
+ self.assertEqual(review.task_mode, "review-merge-pr")
+ self.assertIsNotNone(review.workflow_hash)
+ self.assertGreater(len(review.workflow_hash), 12)
+ self.assertIn("eligible open PR", review.prompt_text)
+
+ def test_comment_issue_defers_to_workflow(self):
+ comment = next(e for e in load_prompt_library() if e.slug == "comment-issue")
+ self.assertIn("comment_issue", comment.prompt_text)
+ self.assertIn("create-issue", comment.workflow_path)
+ self.assertIn("§16", comment.source_note)
+
+ def test_prompts_page_renders_cards_and_copy_buttons(self):
+ response = self.client.get("/prompts")
+ self.assertEqual(response.status_code, 200)
+ text = response.text
+ self.assertIn("Prompt library", text)
+ self.assertIn("review-merge-pr.md", text)
+ self.assertIn("Copy prompt", text)
+ self.assertIn('data-copy-target="prompt-review-pr"', text)
+ self.assertNotIn("child issue", text.lower())
+
+ def test_api_prompts_json(self):
+ response = self.client.get("/api/prompts")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["count"], 7)
+ self.assertEqual(len(data["prompts"]), 7)
+ first = data["prompts"][0]
+ self.assertIn("slug", first)
+ self.assertIn("prompt_text", first)
+ self.assertIn("workflow_path", first)
+ self.assertIn("workflow_hash", first)
+
+ def test_prompt_to_dict_round_trip(self):
+ entry = load_prompt_library()[0]
+ payload = prompt_to_dict(entry)
+ self.assertEqual(payload["slug"], entry.slug)
+ self.assertEqual(payload["prompt_text"], entry.prompt_text)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py
index 7e139c6..937b095 100644
--- a/tests/test_webui_skeleton.py
+++ b/tests/test_webui_skeleton.py
@@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Read-only MVP", response.text)
def test_route_stubs_render(self):
- for path in ("/prompts", "/runtime", "/audit"):
+ for path in ("/runtime", "/audit"):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
+ def test_prompts_is_implemented(self):
+ response = self.client.get("/prompts")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Prompt library", response.text)
+
def test_projects_is_implemented(self):
response = self.client.get("/projects")
self.assertEqual(response.status_code, 200)
diff --git a/webui/app.py b/webui/app.py
index 4f1cba9..dc99cdb 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -12,6 +12,8 @@ from starlette.routing import Route
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
+from webui.prompt_library import library_to_dict
+from webui.prompt_views import render_prompts_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -80,10 +82,11 @@ async def api_projects(_request: Request) -> JSONResponse:
async def prompts(_request: Request) -> HTMLResponse:
- return _stub_page(
- "Prompts",
- "Prompt library will surface canonical workflows from skills/llm-project-workflow/.",
- )
+ return HTMLResponse(render_prompts_page())
+
+
+async def api_prompts(_request: Request) -> JSONResponse:
+ return JSONResponse(library_to_dict())
async def runtime(_request: Request) -> HTMLResponse:
@@ -134,6 +137,7 @@ def create_app() -> Starlette:
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
+ Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]),
diff --git a/webui/layout.py b/webui/layout.py
index fc75efb..d948204 100644
--- a/webui/layout.py
+++ b/webui/layout.py
@@ -18,7 +18,7 @@ MVP_NOTICE = (
)
-def render_page(*, title: str, body_html: str) -> str:
+def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
nav_links = "".join(
f'{label}' for href, label in NAV_ITEMS
)
@@ -115,7 +115,38 @@ def render_page(*, title: str, body_html: str) -> str:
}}
ol.checklist li {{ margin-bottom: 0.85rem; }}
ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
+ .prompt-card {{
+ margin: 1.25rem 0 1.75rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ }}
+ .prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }}
+ pre.prompt-text {{
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0.75rem 0;
+ padding: 0.75rem 0.85rem;
+ border-radius: 6px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ font-size: 0.9rem;
+ color: var(--text);
+ }}
+ .copy-btn {{
+ background: var(--accent);
+ color: #0b1219;
+ border: none;
+ border-radius: 6px;
+ padding: 0.4rem 0.85rem;
+ font-size: 0.85rem;
+ cursor: pointer;
+ }}
+ .copy-btn:hover {{ filter: brightness(1.08); }}
+ .muted {{ color: var(--muted); }}
+ {extra_head}
diff --git a/webui/prompt_library.py b/webui/prompt_library.py
new file mode 100644
index 0000000..5d2e768
--- /dev/null
+++ b/webui/prompt_library.py
@@ -0,0 +1,192 @@
+"""Canonical workflow prompt library for the internal web UI (#428)."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows")
+
+_DEFAULT_PROMPT_RE = re.compile(
+ r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)",
+ re.DOTALL,
+)
+_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE)
+
+
+@dataclass(frozen=True)
+class PromptEntry:
+ slug: str
+ label: str
+ prompt_text: str
+ workflow_path: str
+ task_mode: str | None
+ workflow_hash: str | None
+ source_note: str
+
+
+def _repo_root() -> Path:
+ override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
+ if override:
+ return Path(override).resolve()
+ return Path(__file__).resolve().parent.parent
+
+
+def _workflow_file(path: str) -> Path:
+ return _repo_root() / path
+
+
+def _sha256_hex(content: str) -> str:
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
+
+
+def _read_workflow(path: str) -> tuple[str, str]:
+ file_path = _workflow_file(path)
+ text = file_path.read_text(encoding="utf-8")
+ return text, _sha256_hex(text)
+
+
+def _extract_default_prompt(markdown: str) -> str | None:
+ match = _DEFAULT_PROMPT_RE.search(markdown)
+ if not match:
+ return None
+ lines = [line.strip() for line in match.group(1).splitlines()]
+ return " ".join(line for line in lines if line)
+
+
+def _extract_task_mode(markdown: str) -> str | None:
+ match = _FRONTMATTER_TASK_MODE_RE.search(markdown)
+ return match.group(1) if match else None
+
+
+def _entry_from_workflow(
+ *,
+ slug: str,
+ label: str,
+ workflow_path: str,
+ prompt_override: str | None = None,
+ source_note: str = "",
+) -> PromptEntry:
+ markdown, digest = _read_workflow(workflow_path)
+ prompt_text = prompt_override or _extract_default_prompt(markdown)
+ if not prompt_text:
+ raise ValueError(f"No default task prompt found in {workflow_path}")
+ return PromptEntry(
+ slug=slug,
+ label=label,
+ prompt_text=prompt_text,
+ workflow_path=workflow_path,
+ task_mode=_extract_task_mode(markdown),
+ workflow_hash=digest,
+ source_note=source_note,
+ )
+
+
+def _static_entry(
+ *,
+ slug: str,
+ label: str,
+ prompt_text: str,
+ workflow_path: str,
+ source_note: str,
+) -> PromptEntry:
+ path = _workflow_file(workflow_path)
+ digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None
+ markdown = path.read_text(encoding="utf-8") if path.is_file() else ""
+ return PromptEntry(
+ slug=slug,
+ label=label,
+ prompt_text=prompt_text,
+ workflow_path=workflow_path,
+ task_mode=_extract_task_mode(markdown) if markdown else None,
+ workflow_hash=digest,
+ source_note=source_note,
+ )
+
+
+def load_prompt_library() -> tuple[PromptEntry, ...]:
+ """Load operator prompts derived from canonical workflows."""
+ entries = (
+ _entry_from_workflow(
+ slug="review-pr",
+ label="Review PR",
+ workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"),
+ ),
+ _entry_from_workflow(
+ slug="work-issue",
+ label="Work issue",
+ workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"),
+ ),
+ _entry_from_workflow(
+ slug="create-issue",
+ label="Create issue",
+ workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
+ ),
+ _static_entry(
+ slug="comment-issue",
+ label="Comment issue",
+ workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
+ prompt_text=(
+ "Comment on the target Gitea issue only if exact comment_issue "
+ "capability is proven. Load the canonical create-issue workflow "
+ "first and follow §16 (comment-on-existing issue rule). Include "
+ "specific evidence; do not duplicate existing comments."
+ ),
+ source_note="Derived from create-issue.md §16; full policy remains in the workflow file.",
+ ),
+ _static_entry(
+ slug="cleanup",
+ label="PR queue cleanup",
+ workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"),
+ prompt_text=(
+ "Run PR-only queue cleanup: build a complete open-PR inventory "
+ "with pagination proof, select exactly one eligible PR, dispatch "
+ "one canonical review for that PR, then stop after any terminal "
+ "review mutation. Load pr-queue-cleanup.md and review-merge-pr.md "
+ "before any mutation."
+ ),
+ source_note="Derived from pr-queue-cleanup.md; composes with review-merge-pr.md.",
+ ),
+ _entry_from_workflow(
+ slug="audit",
+ label="Reconciliation audit",
+ workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"),
+ ),
+ _static_entry(
+ slug="onboarding",
+ label="Project onboarding",
+ workflow_path="docs/webui-local-dev.md",
+ prompt_text=(
+ "Start a new MCP Control Plane session: call mcp_get_control_plane_guide, "
+ "prove identity and task capability, then complete the onboarding checklist "
+ "for this project at /projects. Keep Gitea, MCP tools, and canonical "
+ "workflows as the source of truth."
+ ),
+ source_note="Operator onboarding; checklist details live in the project registry UI.",
+ ),
+ )
+ return entries
+
+
+def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
+ return {
+ "slug": entry.slug,
+ "label": entry.label,
+ "prompt_text": entry.prompt_text,
+ "workflow_path": entry.workflow_path,
+ "task_mode": entry.task_mode,
+ "workflow_hash": entry.workflow_hash,
+ "source_note": entry.source_note,
+ }
+
+
+def library_to_dict() -> dict[str, Any]:
+ entries = load_prompt_library()
+ return {
+ "count": len(entries),
+ "prompts": [prompt_to_dict(entry) for entry in entries],
+ }
\ No newline at end of file
diff --git a/webui/prompt_views.py b/webui/prompt_views.py
new file mode 100644
index 0000000..1a6b22c
--- /dev/null
+++ b/webui/prompt_views.py
@@ -0,0 +1,80 @@
+"""HTML views for the prompt library (#428)."""
+
+from __future__ import annotations
+
+import html
+
+from webui.prompt_library import PromptEntry, load_prompt_library
+
+
+def _escape(text: str) -> str:
+ return html.escape(text, quote=True)
+
+
+def render_prompts_page() -> str:
+ entries = load_prompt_library()
+ cards: list[str] = []
+ for entry in entries:
+ cards.append(_render_prompt_card(entry))
+ body = (
+ "Prompt library
"
+ "Short copy/paste task prompts derived from canonical workflows. "
+ "Full policy remains in the cited workflow files — not duplicated here.
"
+ f"{''.join(cards)}"
+ f"{PROMPT_PAGE_SCRIPT}"
+ )
+ from webui.layout import render_page
+
+ return render_page(title="Prompts", body_html=body)
+
+
+def _render_prompt_card(entry: PromptEntry) -> str:
+ hash_short = (
+ f"{_escape(entry.workflow_hash[:12])}"
+ if entry.workflow_hash
+ else "n/a"
+ )
+ task_mode = (
+ f"{_escape(entry.task_mode)}"
+ if entry.task_mode
+ else "n/a"
+ )
+ note = (
+ f'{_escape(entry.source_note)}
'
+ if entry.source_note
+ else ""
+ )
+ prompt_id = f"prompt-{entry.slug}"
+ return (
+ f''
+ f"{_escape(entry.label)}
"
+ f'Workflow: {_escape(entry.workflow_path)} · '
+ f"task_mode: {task_mode} · sha256: {hash_short}
"
+ f'{_escape(entry.prompt_text)}'
+ f'"
+ f"{note}"
+ ""
+ )
+
+
+PROMPT_PAGE_SCRIPT = """
+
+"""
\ No newline at end of file
From f845864889a2b9d9f236abde873c3b90dff9c041 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:32:05 -0400
Subject: [PATCH 13/42] feat: add web UI prompt library from canonical
workflows (#428)
Surface short copy/paste operator prompts derived from canonical workflow
files with workflow path and SHA-256 citations. Adds /prompts, /prompts/{id},
and /api/prompts with one-click copy.
Closes #428
---
tests/test_webui_prompt_library.py | 113 ++++++++++++++++-------------
webui/app.py | 23 +++++-
webui/prompt_library.py | 35 +++++----
webui/prompt_views.py | 43 ++++++-----
4 files changed, 127 insertions(+), 87 deletions(-)
diff --git a/tests/test_webui_prompt_library.py b/tests/test_webui_prompt_library.py
index 442800d..3538c2d 100644
--- a/tests/test_webui_prompt_library.py
+++ b/tests/test_webui_prompt_library.py
@@ -1,4 +1,5 @@
"""Tests for web UI prompt library (#428)."""
+import json
import sys
import unittest
from pathlib import Path
@@ -8,73 +9,81 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
-from webui.prompt_library import load_prompt_library, prompt_to_dict
+from webui.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
+
+REQUIRED_PROMPT_SLUGS = frozenset({
+ "review-pr",
+ "work-issue",
+ "create-issue",
+ "comment-issue",
+ "cleanup",
+ "audit",
+ "onboarding",
+})
-class TestWebuiPromptLibrary(unittest.TestCase):
+class TestPromptLibraryLoader(unittest.TestCase):
+ def test_library_loads_required_prompts(self):
+ entries = load_prompt_library()
+ slugs = {entry.slug for entry in entries}
+ self.assertEqual(slugs, REQUIRED_PROMPT_SLUGS)
+
+ def test_workflow_hashes_present(self):
+ review = find_prompt("review-pr")
+ self.assertIsNotNone(review)
+ assert review is not None
+ self.assertTrue(review.workflow_hash)
+ self.assertEqual(len(review.workflow_hash), 64)
+
+ def test_prompt_text_is_short(self):
+ for entry in load_prompt_library():
+ self.assertLess(len(entry.prompt_text), 400)
+ self.assertNotIn("Do not improvise around the gates", entry.prompt_text)
+
+
+class TestPromptLibraryRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
- def test_library_has_required_prompts(self):
- entries = load_prompt_library()
- slugs = {entry.slug for entry in entries}
- self.assertEqual(
- slugs,
- {
- "review-pr",
- "work-issue",
- "create-issue",
- "comment-issue",
- "cleanup",
- "audit",
- "onboarding",
- },
- )
-
- def test_workflow_entries_include_hash_and_task_mode(self):
- review = next(e for e in load_prompt_library() if e.slug == "review-pr")
- self.assertEqual(
- review.workflow_path,
- "skills/llm-project-workflow/workflows/review-merge-pr.md",
- )
- self.assertEqual(review.task_mode, "review-merge-pr")
- self.assertIsNotNone(review.workflow_hash)
- self.assertGreater(len(review.workflow_hash), 12)
- self.assertIn("eligible open PR", review.prompt_text)
-
- def test_comment_issue_defers_to_workflow(self):
- comment = next(e for e in load_prompt_library() if e.slug == "comment-issue")
- self.assertIn("comment_issue", comment.prompt_text)
- self.assertIn("create-issue", comment.workflow_path)
- self.assertIn("§16", comment.source_note)
-
- def test_prompts_page_renders_cards_and_copy_buttons(self):
+ def test_prompts_page_lists_all_entries(self):
response = self.client.get("/prompts")
self.assertEqual(response.status_code, 200)
- text = response.text
- self.assertIn("Prompt library", text)
- self.assertIn("review-merge-pr.md", text)
- self.assertIn("Copy prompt", text)
- self.assertIn('data-copy-target="prompt-review-pr"', text)
- self.assertNotIn("child issue", text.lower())
+ for label in (
+ "Review PR",
+ "Work issue",
+ "Create issue",
+ "Comment on issue",
+ "Post-merge cleanup",
+ "Reconciliation audit",
+ "Project onboarding",
+ ):
+ self.assertIn(label, response.text)
+ self.assertIn("Copy prompt", response.text)
+ self.assertIn("sha256:", response.text)
+ self.assertNotIn("child issue", response.text.lower())
+
+ def test_prompt_detail_route(self):
+ response = self.client.get("/prompts/work-issue")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("work-issue.md", response.text)
+ self.assertIn("Copy prompt", response.text)
+
+ def test_prompt_detail_404(self):
+ self.assertEqual(self.client.get("/prompts/missing").status_code, 404)
def test_api_prompts_json(self):
response = self.client.get("/api/prompts")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["count"], 7)
- self.assertEqual(len(data["prompts"]), 7)
- first = data["prompts"][0]
- self.assertIn("slug", first)
- self.assertIn("prompt_text", first)
- self.assertIn("workflow_path", first)
- self.assertIn("workflow_hash", first)
+ review = next(item for item in data["prompts"] if item["slug"] == "review-pr")
+ self.assertIn("workflow_hash", review)
+ self.assertIn("review-merge-pr.md", review["workflow_path"])
- def test_prompt_to_dict_round_trip(self):
+ def test_prompt_to_dict_roundtrip(self):
entry = load_prompt_library()[0]
- payload = prompt_to_dict(entry)
- self.assertEqual(payload["slug"], entry.slug)
- self.assertEqual(payload["prompt_text"], entry.prompt_text)
+ encoded = json.dumps(prompt_to_dict(entry))
+ self.assertIn("workflow_path", encoded)
if __name__ == "__main__":
diff --git a/webui/app.py b/webui/app.py
index dc99cdb..d1e1c38 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -12,8 +12,8 @@ from starlette.routing import Route
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
-from webui.prompt_library import library_to_dict
-from webui.prompt_views import render_prompts_page
+from webui.prompt_library import find_prompt, library_to_dict
+from webui.prompt_views import render_prompt_detail, render_prompts_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -85,6 +85,24 @@ async def prompts(_request: Request) -> HTMLResponse:
return HTMLResponse(render_prompts_page())
+async def prompt_detail(request: Request) -> HTMLResponse:
+ prompt_id = request.path_params["prompt_id"]
+ prompt = find_prompt(prompt_id)
+ if prompt is None:
+ return HTMLResponse(
+ render_page(
+ title="Prompt not found",
+ body_html=(
+ "Prompt not found
"
+ f"No library entry for {prompt_id}.
"
+ '← All prompts
'
+ ),
+ ),
+ status_code=404,
+ )
+ return HTMLResponse(render_prompt_detail(prompt))
+
+
async def api_prompts(_request: Request) -> JSONResponse:
return JSONResponse(library_to_dict())
@@ -137,6 +155,7 @@ def create_app() -> Starlette:
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
+ Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
diff --git a/webui/prompt_library.py b/webui/prompt_library.py
index 5d2e768..e5ec1d7 100644
--- a/webui/prompt_library.py
+++ b/webui/prompt_library.py
@@ -128,7 +128,7 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
),
_static_entry(
slug="comment-issue",
- label="Comment issue",
+ label="Comment on issue",
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
prompt_text=(
"Comment on the target Gitea issue only if exact comment_issue "
@@ -140,16 +140,14 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
),
_static_entry(
slug="cleanup",
- label="PR queue cleanup",
- workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"),
+ label="Post-merge cleanup",
+ workflow_path="skills/llm-project-workflow/templates/worktree-cleanup.md",
prompt_text=(
- "Run PR-only queue cleanup: build a complete open-PR inventory "
- "with pagination proof, select exactly one eligible PR, dispatch "
- "one canonical review for that PR, then stop after any terminal "
- "review mutation. Load pr-queue-cleanup.md and review-merge-pr.md "
- "before any mutation."
+ "Task: clean up branch/worktree for PR # / issue # after merge. "
+ "Confirm the merge on remote master before any deletion; never "
+ "force-remove a dirty worktree."
),
- source_note="Derived from pr-queue-cleanup.md; composes with review-merge-pr.md.",
+ source_note="Full cleanup steps live in templates/worktree-cleanup.md.",
),
_entry_from_workflow(
slug="audit",
@@ -159,19 +157,26 @@ def load_prompt_library() -> tuple[PromptEntry, ...]:
_static_entry(
slug="onboarding",
label="Project onboarding",
- workflow_path="docs/webui-local-dev.md",
+ workflow_path="skills/llm-project-workflow/SKILL.md",
prompt_text=(
- "Start a new MCP Control Plane session: call mcp_get_control_plane_guide, "
- "prove identity and task capability, then complete the onboarding checklist "
- "for this project at /projects. Keep Gitea, MCP tools, and canonical "
- "workflows as the source of truth."
+ "Onboard this repository into the MCP Control Plane: prove identity "
+ "and task capability, configure author/reviewer/reconciler profiles "
+ "in separate namespaces, then complete the checklist at /projects. "
+ "Canonical router: skills/llm-project-workflow/SKILL.md."
),
- source_note="Operator onboarding; checklist details live in the project registry UI.",
+ source_note="Checklist details live in webui/data/projects.registry.json and /projects.",
),
)
return entries
+def find_prompt(slug: str) -> PromptEntry | None:
+ for entry in load_prompt_library():
+ if entry.slug == slug:
+ return entry
+ return None
+
+
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
return {
"slug": entry.slug,
diff --git a/webui/prompt_views.py b/webui/prompt_views.py
index 1a6b22c..fe6d687 100644
--- a/webui/prompt_views.py
+++ b/webui/prompt_views.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import html
+from webui.layout import render_page
from webui.prompt_library import PromptEntry, load_prompt_library
@@ -11,23 +12,6 @@ def _escape(text: str) -> str:
return html.escape(text, quote=True)
-def render_prompts_page() -> str:
- entries = load_prompt_library()
- cards: list[str] = []
- for entry in entries:
- cards.append(_render_prompt_card(entry))
- body = (
- "Prompt library
"
- "Short copy/paste task prompts derived from canonical workflows. "
- "Full policy remains in the cited workflow files — not duplicated here.
"
- f"{''.join(cards)}"
- f"{PROMPT_PAGE_SCRIPT}"
- )
- from webui.layout import render_page
-
- return render_page(title="Prompts", body_html=body)
-
-
def _render_prompt_card(entry: PromptEntry) -> str:
hash_short = (
f"{_escape(entry.workflow_hash[:12])}"
@@ -77,4 +61,27 @@ document.querySelectorAll('.copy-btn').forEach((btn) => {
});
});
-"""
\ No newline at end of file
+"""
+
+
+def render_prompts_page() -> str:
+ entries = load_prompt_library()
+ cards = "".join(_render_prompt_card(entry) for entry in entries)
+ body = (
+ "Prompt library
"
+ "Short copy/paste task prompts derived from canonical workflows. "
+ "Full policy remains in the cited workflow files — not duplicated here.
"
+ f"{cards}"
+ "JSON API
"
+ f"{PROMPT_PAGE_SCRIPT}"
+ )
+ return render_page(title="Prompts", body_html=body)
+
+
+def render_prompt_detail(entry: PromptEntry) -> str:
+ body = (
+ f"← All prompts
"
+ f"{_render_prompt_card(entry)}"
+ f"{PROMPT_PAGE_SCRIPT}"
+ )
+ return render_page(title=entry.label, body_html=body)
\ No newline at end of file
From c91e3642de07ad374961d9277fcd492e8f6c775f Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:55:21 -0400
Subject: [PATCH 14/42] feat: add live PR/issue queue dashboard to web UI
(Closes #429)
Adds read-only /queue and /api/queue routes that load open PRs and issues
from the default registry project via gitea_auth, surface pagination proof,
and classify items with claimed/blocked/in-review/duplicate badges.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/webui-local-dev.md | 15 +-
tests/test_webui_queue_dashboard.py | 138 ++++++++++
webui/app.py | 14 +
webui/layout.py | 15 ++
webui/queue_loader.py | 385 ++++++++++++++++++++++++++++
webui/queue_views.py | 110 ++++++++
6 files changed, 676 insertions(+), 1 deletion(-)
create mode 100644 tests/test_webui_queue_dashboard.py
create mode 100644 webui/queue_loader.py
create mode 100644 webui/queue_views.py
diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md
index 4b94aed..0a5b935 100644
--- a/docs/webui-local-dev.md
+++ b/docs/webui-local-dev.md
@@ -36,6 +36,8 @@ Optional environment variables:
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
+| `/queue` | Live PR and issue queue dashboard (#429) |
+| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
@@ -69,8 +71,19 @@ Prompts are generated at load time from canonical workflow files under
copy/paste starters; canonical workflow files remain the only full policy
source.
+## Live queue dashboard (#429)
+
+`/queue` loads open PRs and issues for the default registry project (seed:
+**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
+credentials. The UI surfaces pagination proof (returned count, pages fetched,
+`has_more`, `inventory_complete`) and classification badges (`claimed`,
+`blocked`, `in-review`, `duplicate`) when evidence exists.
+
+If credentials are missing or the fetch fails, the page shows an explicit error
+instead of an empty queue (fail closed).
+
## Tests
```bash
-pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q
+pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
```
\ No newline at end of file
diff --git a/tests/test_webui_queue_dashboard.py b/tests/test_webui_queue_dashboard.py
new file mode 100644
index 0000000..f4671ac
--- /dev/null
+++ b/tests/test_webui_queue_dashboard.py
@@ -0,0 +1,138 @@
+"""Tests for web UI live queue dashboard (#429)."""
+import json
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient
+
+from webui.app import create_app
+from webui.queue_loader import (
+ PaginationMeta,
+ load_queue_snapshot,
+ snapshot_to_dict,
+)
+
+
+def _page(items: list[dict], *, final: bool = True) -> tuple[list[dict], PaginationMeta]:
+ return items, PaginationMeta(
+ page=1,
+ per_page=50,
+ returned_count=len(items),
+ has_more=not final,
+ is_final_page=final,
+ inventory_complete=final,
+ pages_fetched=1,
+ )
+
+
+SAMPLE_PRS = [
+ {
+ "number": 10,
+ "title": "feat: example",
+ "mergeable": True,
+ "body": "Closes #5",
+ "head": {"ref": "feat/x", "sha": "abc123def456"},
+ "base": {"ref": "master"},
+ },
+ {
+ "number": 9,
+ "title": "fix: conflict",
+ "mergeable": False,
+ "body": "Closes #5",
+ "head": {"ref": "feat/y", "sha": "deadbeef0000"},
+ "base": {"ref": "master"},
+ },
+]
+
+SAMPLE_ISSUES = [
+ {
+ "number": 5,
+ "title": "Tracked issue",
+ "state": "open",
+ "labels": [{"name": "status:in-progress"}],
+ "assignee": {"login": "jcwalker3"},
+ },
+ {
+ "number": 4,
+ "title": "Idle issue",
+ "state": "open",
+ "labels": [],
+ "assignee": None,
+ },
+]
+
+
+class TestQueueLoader(unittest.TestCase):
+ def test_classifies_claimed_duplicate_and_blocked(self):
+ snapshot = load_queue_snapshot(
+ fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
+ fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ )
+ self.assertIsNone(snapshot.fetch_error)
+ pr_badges = {p.number: p.badges for p in snapshot.prs}
+ self.assertIn("blocked", pr_badges[9])
+ self.assertIn("duplicate", pr_badges[9])
+ issue_badges = {i.number: i.badges for i in snapshot.issues}
+ self.assertIn("claimed", issue_badges[5])
+ self.assertIn("duplicate", issue_badges[5])
+
+ def test_fail_closed_without_credentials(self):
+ with patch("webui.queue_loader.get_auth_header", return_value=None):
+ snapshot = load_queue_snapshot()
+ self.assertIsNotNone(snapshot.fetch_error)
+ self.assertIn("credentials unavailable", snapshot.fetch_error.lower())
+ self.assertEqual(snapshot.prs, ())
+ self.assertEqual(snapshot.issues, ())
+
+ def test_snapshot_json_includes_pagination(self):
+ snapshot = load_queue_snapshot(
+ fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS, final=False),
+ fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ )
+ payload = snapshot_to_dict(snapshot)
+ self.assertFalse(payload["pagination"]["prs"]["inventory_complete"])
+ self.assertTrue(payload["pagination"]["prs"]["has_more"])
+ self.assertTrue(payload["pagination"]["issues"]["inventory_complete"])
+
+
+class TestQueueRoutes(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(create_app())
+
+ def test_queue_page_renders_with_mocked_loader(self):
+ snapshot = load_queue_snapshot(
+ fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
+ fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ )
+ with patch("webui.app.load_queue_snapshot", return_value=snapshot):
+ response = self.client.get("/queue")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Live queue", response.text)
+ self.assertIn("feat: example", response.text)
+ self.assertIn("Tracked issue", response.text)
+ self.assertIn("pagination", response.text.lower())
+
+ def test_api_queue_json(self):
+ snapshot = load_queue_snapshot(
+ fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
+ fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ )
+ with patch("webui.app.load_queue_snapshot", return_value=snapshot):
+ response = self.client.get("/api/queue")
+ self.assertEqual(response.status_code, 200)
+ payload = json.loads(response.text)
+ self.assertEqual(len(payload["prs"]), 2)
+ self.assertEqual(len(payload["issues"]), 2)
+
+ def test_nav_includes_queue_link(self):
+ response = self.client.get("/")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('href="/queue"', response.text)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/webui/app.py b/webui/app.py
index d1e1c38..a97d1ad 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
+from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
+from webui.queue_views import render_queue_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -32,6 +34,7 @@ async def home(_request: Request) -> HTMLResponse:
"Operator console
"
"Local entry point for MCP Control Plane operational views.
"
""
+ "- Queue — live PR and issue dashboard (#429)
"
"- Projects — registry and onboarding (#427)
"
"- Prompts — canonical workflow prompt library (#428)
"
"- Runtime — MCP health and stale-runtime detection (#430)
"
@@ -52,6 +55,15 @@ async def health(_request: Request) -> JSONResponse:
})
+async def queue(_request: Request) -> HTMLResponse:
+ snapshot = load_queue_snapshot()
+ return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
+
+
+async def api_queue(_request: Request) -> JSONResponse:
+ return JSONResponse(snapshot_to_dict(load_queue_snapshot()))
+
+
async def projects(_request: Request) -> HTMLResponse:
registry = load_registry()
return HTMLResponse(render_projects_list(registry))
@@ -151,6 +163,8 @@ def create_app() -> Starlette:
routes=[
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
+ Route("/queue", queue, methods=["GET"]),
+ Route("/api/queue", api_queue, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
diff --git a/webui/layout.py b/webui/layout.py
index d948204..a506c2c 100644
--- a/webui/layout.py
+++ b/webui/layout.py
@@ -4,6 +4,7 @@ from __future__ import annotations
NAV_ITEMS = (
("/", "Home"),
+ ("/queue", "Queue"),
("/projects", "Projects"),
("/prompts", "Prompts"),
("/runtime", "Runtime"),
@@ -145,6 +146,20 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
}}
.copy-btn:hover {{ filter: brightness(1.08); }}
.muted {{ color: var(--muted); }}
+ .badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: 0.5rem; }}
+ .badge {{
+ font-size: 0.72rem;
+ padding: 0.1rem 0.45rem;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ text-transform: lowercase;
+ }}
+ .badge-claimed {{ color: #8fd19e; border-color: #3d6b4a; }}
+ .badge-blocked {{ color: #f0a8a8; border-color: #7a3b3b; }}
+ .badge-in-review {{ color: #9ec8f0; border-color: #3d5f7a; }}
+ .badge-duplicate {{ color: #e0c27a; border-color: #6b5730; }}
+ .badge-stale {{ color: #c9b8e8; border-color: #5a4a78; }}
{extra_head}
diff --git a/webui/queue_loader.py b/webui/queue_loader.py
new file mode 100644
index 0000000..57ebeeb
--- /dev/null
+++ b/webui/queue_loader.py
@@ -0,0 +1,385 @@
+"""Load live Gitea PR/issue queue state for the web UI dashboard (#429)."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Callable
+from urllib.parse import urlparse
+
+from gitea_auth import api_fetch_page, api_get_all, get_auth_header, repo_api_url
+
+from webui.project_registry import ProjectRecord, load_registry
+
+_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.I)
+_ISSUE_REF_RE = re.compile(r"#(\d+)")
+_STALE_DAYS = 14
+
+
+@dataclass(frozen=True)
+class PaginationMeta:
+ page: int
+ per_page: int
+ returned_count: int
+ has_more: bool
+ is_final_page: bool
+ inventory_complete: bool
+ pages_fetched: int
+
+
+@dataclass(frozen=True)
+class QueueItem:
+ number: int
+ title: str
+ badges: tuple[str, ...]
+ extra: dict[str, str]
+
+
+@dataclass(frozen=True)
+class QueueSnapshot:
+ project_id: str
+ repo_label: str
+ prs: tuple[QueueItem, ...]
+ issues: tuple[QueueItem, ...]
+ pr_pagination: PaginationMeta | None
+ issue_pagination: PaginationMeta | None
+ fetch_error: str | None = None
+
+
+def _host_from_url(remote_host: str) -> str:
+ parsed = urlparse(remote_host.strip())
+ return parsed.netloc or remote_host.strip().rstrip("/")
+
+
+def _extract_linked_issue(title: str | None, body: str | None = None) -> int | None:
+ for text in (title, body):
+ if not text:
+ continue
+ match = _CLOSES_RE.search(text)
+ if match:
+ return int(match.group(1))
+ if body:
+ refs = _ISSUE_REF_RE.findall(body)
+ if refs:
+ return int(refs[0])
+ return None
+
+
+def _is_stale(updated_at: str | None) -> bool:
+ if not updated_at:
+ return False
+ try:
+ normalized = updated_at.replace("Z", "+00:00")
+ parsed = datetime.fromisoformat(normalized)
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ age = datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)
+ return age.days >= _STALE_DAYS
+ except ValueError:
+ return False
+
+
+def _classify_pr(
+ pr: dict,
+ *,
+ issue_claimed: set[int],
+ issue_to_prs: dict[int, list[int]],
+) -> tuple[str, ...]:
+ badges: list[str] = []
+ mergeable = pr.get("mergeable")
+ if mergeable is False:
+ badges.append("blocked")
+ linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
+ if linked is not None:
+ if linked in issue_claimed:
+ badges.append("claimed")
+ if len(issue_to_prs.get(linked, [])) > 1:
+ badges.append("duplicate")
+ if _is_stale(pr.get("updated_at")):
+ badges.append("stale")
+ if mergeable is True and "blocked" not in badges:
+ badges.append("in-review")
+ if not badges:
+ badges.append("open")
+ return tuple(dict.fromkeys(badges))
+
+
+def _classify_issue(
+ issue: dict,
+ *,
+ linked_prs: list[int],
+) -> tuple[str, ...]:
+ badges: list[str] = []
+ labels = [lb.get("name", "") for lb in issue.get("labels", [])]
+ if "status:in-progress" in labels:
+ badges.append("claimed")
+ if len(linked_prs) > 1:
+ badges.append("duplicate")
+ elif linked_prs and "claimed" not in badges:
+ badges.append("in-review")
+ if _is_stale(issue.get("updated_at")):
+ badges.append("stale")
+ if not badges:
+ badges.append("open")
+ return tuple(dict.fromkeys(badges))
+
+
+def _format_pr_item(pr: dict, badges: tuple[str, ...]) -> QueueItem:
+ head = pr.get("head") or {}
+ base = pr.get("base") or {}
+ mergeable = pr.get("mergeable")
+ merge_label = (
+ "mergeable" if mergeable is True else "conflicted" if mergeable is False else "unknown"
+ )
+ linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
+ return QueueItem(
+ number=int(pr["number"]),
+ title=str(pr.get("title") or ""),
+ badges=badges,
+ extra={
+ "branch": f"{head.get('ref', '?')} → {base.get('ref', '?')}",
+ "head_sha": str(head.get("sha") or "")[:12],
+ "mergeable": merge_label,
+ "linked_issue": str(linked) if linked is not None else "",
+ },
+ )
+
+
+def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
+ labels = ", ".join(lb.get("name", "") for lb in issue.get("labels", []))
+ assignee = (issue.get("assignee") or {}).get("login", "")
+ return QueueItem(
+ number=int(issue["number"]),
+ title=str(issue.get("title") or ""),
+ badges=badges,
+ extra={
+ "labels": labels or "—",
+ "assignee": assignee or "unassigned",
+ "state": str(issue.get("state") or ""),
+ },
+ )
+
+
+def _pagination_from_pages(
+ *,
+ per_page: int,
+ pages_fetched: int,
+ returned_count: int,
+ is_final_page: bool,
+) -> PaginationMeta:
+ return PaginationMeta(
+ page=1,
+ per_page=per_page,
+ returned_count=returned_count,
+ has_more=not is_final_page,
+ is_final_page=is_final_page,
+ inventory_complete=is_final_page,
+ pages_fetched=pages_fetched,
+ )
+
+
+def _fetch_prs(
+ host: str,
+ org: str,
+ repo: str,
+ auth: str,
+ *,
+ per_page: int = 50,
+) -> tuple[list[dict], PaginationMeta]:
+ url = f"{repo_api_url(host, org, repo)}/pulls?state=open"
+ all_raw: list[dict] = []
+ pages_fetched = 0
+ is_final = False
+ page = 1
+ while pages_fetched < 20:
+ raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
+ pages_fetched += 1
+ all_raw.extend(raw_page)
+ is_final = bool(meta["is_final_page"])
+ if is_final:
+ break
+ page += 1
+ pagination = _pagination_from_pages(
+ per_page=per_page,
+ pages_fetched=pages_fetched,
+ returned_count=len(all_raw),
+ is_final_page=is_final,
+ )
+ return all_raw, pagination
+
+
+def _fetch_issues(
+ host: str,
+ org: str,
+ repo: str,
+ auth: str,
+ *,
+ per_page: int = 50,
+) -> tuple[list[dict], PaginationMeta]:
+ url = f"{repo_api_url(host, org, repo)}/issues?state=open&type=issues"
+ all_raw: list[dict] = []
+ page = 1
+ pages_fetched = 0
+ is_final = False
+ while pages_fetched < 20:
+ raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
+ pages_fetched += 1
+ all_raw.extend(raw_page)
+ is_final = bool(meta["is_final_page"])
+ if is_final:
+ break
+ page += 1
+ pagination = _pagination_from_pages(
+ per_page=per_page,
+ pages_fetched=pages_fetched,
+ returned_count=len(all_raw),
+ is_final_page=is_final,
+ )
+ return all_raw, pagination
+
+
+def load_queue_snapshot(
+ project_id: str | None = None,
+ *,
+ fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
+ fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
+) -> QueueSnapshot:
+ """Load open PR and issue queues for a registry project (default: first entry)."""
+ registry = load_registry()
+ project: ProjectRecord | None = None
+ if project_id:
+ for entry in registry.projects:
+ if entry.id == project_id:
+ project = entry
+ break
+ else:
+ project = registry.projects[0] if registry.projects else None
+
+ if project is None:
+ return QueueSnapshot(
+ project_id=project_id or "",
+ repo_label="",
+ prs=(),
+ issues=(),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error="project not found in registry",
+ )
+
+ host = _host_from_url(project.remote_host)
+ pr_fetch = fetch_prs or _fetch_prs
+ issue_fetch = fetch_issues or _fetch_issues
+ using_live_fetch = fetch_prs is None or fetch_issues is None
+ auth = get_auth_header(host) if using_live_fetch else "test-auth"
+ if using_live_fetch and not auth:
+ return QueueSnapshot(
+ project_id=project.id,
+ repo_label=f"{project.gitea_owner}/{project.repo_name}",
+ prs=(),
+ issues=(),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error=(
+ f"Gitea credentials unavailable for {host}; "
+ "queue cannot be loaded (fail closed — not showing empty queue)"
+ ),
+ )
+
+ try:
+ raw_prs, pr_pagination = pr_fetch(
+ host, project.gitea_owner, project.repo_name, auth
+ )
+ raw_issues, issue_pagination = issue_fetch(
+ host, project.gitea_owner, project.repo_name, auth
+ )
+ except Exception as exc: # noqa: BLE001 — surface operator-visible fetch errors
+ return QueueSnapshot(
+ project_id=project.id,
+ repo_label=f"{project.gitea_owner}/{project.repo_name}",
+ prs=(),
+ issues=(),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error=f"Gitea fetch failed: {exc}",
+ )
+
+ issue_to_prs: dict[int, list[int]] = {}
+ for pr in raw_prs:
+ linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
+ if linked is not None:
+ issue_to_prs.setdefault(linked, []).append(int(pr["number"]))
+
+ issue_claimed = {
+ int(i["number"])
+ for i in raw_issues
+ if any(lb.get("name") == "status:in-progress" for lb in i.get("labels", []))
+ }
+
+ pr_items = tuple(
+ _format_pr_item(
+ pr,
+ _classify_pr(
+ pr,
+ issue_claimed=issue_claimed,
+ issue_to_prs=issue_to_prs,
+ ),
+ )
+ for pr in sorted(raw_prs, key=lambda p: int(p["number"]), reverse=True)
+ )
+ issue_items = tuple(
+ _format_issue_item(
+ issue,
+ _classify_issue(
+ issue,
+ linked_prs=issue_to_prs.get(int(issue["number"]), []),
+ ),
+ )
+ for issue in sorted(raw_issues, key=lambda i: int(i["number"]), reverse=True)
+ )
+
+ return QueueSnapshot(
+ project_id=project.id,
+ repo_label=f"{project.gitea_owner}/{project.repo_name}",
+ prs=pr_items,
+ issues=issue_items,
+ pr_pagination=pr_pagination,
+ issue_pagination=issue_pagination,
+ )
+
+
+def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
+ """JSON-serializable export for /api/queue."""
+
+ def _page(meta: PaginationMeta | None) -> dict[str, Any] | None:
+ if meta is None:
+ return None
+ return {
+ "page": meta.page,
+ "per_page": meta.per_page,
+ "returned_count": meta.returned_count,
+ "has_more": meta.has_more,
+ "is_final_page": meta.is_final_page,
+ "inventory_complete": meta.inventory_complete,
+ "pages_fetched": meta.pages_fetched,
+ }
+
+ def _item(item: QueueItem) -> dict[str, Any]:
+ return {
+ "number": item.number,
+ "title": item.title,
+ "badges": list(item.badges),
+ **item.extra,
+ }
+
+ return {
+ "project_id": snapshot.project_id,
+ "repo": snapshot.repo_label,
+ "fetch_error": snapshot.fetch_error,
+ "prs": [_item(p) for p in snapshot.prs],
+ "issues": [_item(i) for i in snapshot.issues],
+ "pagination": {
+ "prs": _page(snapshot.pr_pagination),
+ "issues": _page(snapshot.issue_pagination),
+ },
+ }
\ No newline at end of file
diff --git a/webui/queue_views.py b/webui/queue_views.py
new file mode 100644
index 0000000..8294b8e
--- /dev/null
+++ b/webui/queue_views.py
@@ -0,0 +1,110 @@
+"""HTML views for the live Gitea queue dashboard (#429)."""
+
+from __future__ import annotations
+
+import html
+
+from webui.queue_loader import PaginationMeta, QueueItem, QueueSnapshot
+
+
+def _badge_html(badges: tuple[str, ...]) -> str:
+ if not badges:
+ return ""
+ chips = "".join(
+ f'{html.escape(b)}'
+ for b in badges
+ )
+ return f'{chips}'
+
+
+def _pagination_html(label: str, meta: PaginationMeta | None) -> str:
+ if meta is None:
+ return (
+ f"{html.escape(label)} pagination: "
+ "unavailable
"
+ )
+ status = "complete" if meta.inventory_complete else "partial"
+ more = "yes" if meta.has_more else "no"
+ return (
+ f"{html.escape(label)} pagination ({status}): "
+ f"returned {meta.returned_count} · per_page {meta.per_page} · "
+ f"pages_fetched {meta.pages_fetched} · has_more {more} · "
+ f"final_page {'yes' if meta.is_final_page else 'no'}
"
+ )
+
+
+def _queue_table(
+ *,
+ title: str,
+ items: tuple[QueueItem, ...],
+ columns: tuple[tuple[str, str], ...],
+) -> str:
+ if not items:
+ return f"{html.escape(title)}
No open items.
"
+
+ headers = "".join(f"{html.escape(label)} | " for _, label in columns)
+ rows = []
+ for item in items:
+ cells = []
+ for key, _ in columns:
+ if key == "number":
+ cells.append(f"#{item.number} | ")
+ elif key == "title":
+ cells.append(
+ f"{html.escape(item.title)}{_badge_html(item.badges)} | "
+ )
+ else:
+ cells.append(f"{html.escape(item.extra.get(key, ''))} | ")
+ rows.append("" + "".join(cells) + "
")
+
+ body = (
+ f"{html.escape(title)}
"
+ f"{headers}
"
+ f"{''.join(rows)}
"
+ )
+ return body
+
+
+def render_queue_page(snapshot: QueueSnapshot) -> str:
+ error_block = ""
+ if snapshot.fetch_error:
+ error_block = (
+ f'Queue unavailable: '
+ f"{html.escape(snapshot.fetch_error)}
"
+ )
+
+ pr_section = _queue_table(
+ title="Open pull requests",
+ items=snapshot.prs,
+ columns=(
+ ("number", "#"),
+ ("title", "Title"),
+ ("branch", "Branch"),
+ ("head_sha", "Head"),
+ ("mergeable", "Mergeable"),
+ ("linked_issue", "Linked issue"),
+ ),
+ )
+ issue_section = _queue_table(
+ title="Open issues",
+ items=snapshot.issues,
+ columns=(
+ ("number", "#"),
+ ("title", "Title"),
+ ("labels", "Labels"),
+ ("assignee", "Assignee"),
+ ("state", "State"),
+ ),
+ )
+
+ return (
+ "Live queue
"
+ f"Repository: {html.escape(snapshot.repo_label)} "
+ f"· project {html.escape(snapshot.project_id)}
"
+ f"{error_block}"
+ f"{_pagination_html('PR', snapshot.pr_pagination)}"
+ f"{pr_section}"
+ f"{_pagination_html('Issue', snapshot.issue_pagination)}"
+ f"{issue_section}"
+ "Read-only MVP — claims, reviews, and merges stay in Gitea/MCP tools.
"
+ )
\ No newline at end of file
From f5370a94d3739908dbb6acbd50996568ccb8dafd Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:55:58 -0400
Subject: [PATCH 15/42] test: harden queue dashboard classification and route
coverage (#429)
Expand queue dashboard tests for stale/duplicate badges, title-linked
issues, and nav coverage; drop unused import in queue_loader.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_webui_queue_dashboard.py | 252 ++++++++++++++++++----------
tests/test_webui_skeleton.py | 9 +-
webui/queue_loader.py | 2 +-
3 files changed, 172 insertions(+), 91 deletions(-)
diff --git a/tests/test_webui_queue_dashboard.py b/tests/test_webui_queue_dashboard.py
index f4671ac..cc9ea97 100644
--- a/tests/test_webui_queue_dashboard.py
+++ b/tests/test_webui_queue_dashboard.py
@@ -1,9 +1,9 @@
"""Tests for web UI live queue dashboard (#429)."""
-import json
import sys
import unittest
+from datetime import datetime, timedelta, timezone
from pathlib import Path
-from unittest.mock import patch
+from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -12,126 +12,202 @@ from starlette.testclient import TestClient
from webui.app import create_app
from webui.queue_loader import (
PaginationMeta,
+ _classify_issue,
+ _classify_pr,
+ _extract_linked_issue,
load_queue_snapshot,
snapshot_to_dict,
)
+_RECENT = datetime.now(timezone.utc).isoformat()
+_STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
-def _page(items: list[dict], *, final: bool = True) -> tuple[list[dict], PaginationMeta]:
- return items, PaginationMeta(
+_SAMPLE_PRS = [
+ {
+ "number": 100,
+ "title": "feat: queue dashboard (Closes #429)",
+ "body": "",
+ "mergeable": True,
+ "updated_at": _RECENT,
+ "head": {"ref": "feat/issue-429", "sha": "abc123def456"},
+ "base": {"ref": "master"},
+ },
+ {
+ "number": 99,
+ "title": "fix: conflict",
+ "body": "Closes #50",
+ "mergeable": False,
+ "updated_at": _STALE,
+ "head": {"ref": "fix/issue-50", "sha": "deadbeef0001"},
+ "base": {"ref": "master"},
+ },
+ {
+ "number": 98,
+ "title": "feat: duplicate A (Closes #60)",
+ "body": "",
+ "mergeable": True,
+ "updated_at": _RECENT,
+ "head": {"ref": "feat/issue-60-a", "sha": "111111111111"},
+ "base": {"ref": "master"},
+ },
+ {
+ "number": 97,
+ "title": "feat: duplicate B (Closes #60)",
+ "body": "",
+ "mergeable": True,
+ "updated_at": _RECENT,
+ "head": {"ref": "feat/issue-60-b", "sha": "222222222222"},
+ "base": {"ref": "master"},
+ },
+]
+
+_SAMPLE_ISSUES = [
+ {
+ "number": 429,
+ "title": "Web UI queue dashboard",
+ "state": "open",
+ "labels": [{"name": "status:in-progress"}],
+ "assignee": None,
+ "updated_at": _RECENT,
+ },
+ {
+ "number": 60,
+ "title": "Duplicate PR target",
+ "state": "open",
+ "labels": [],
+ "assignee": None,
+ "updated_at": _RECENT,
+ },
+ {
+ "number": 50,
+ "title": "Blocked PR target",
+ "state": "open",
+ "labels": [],
+ "assignee": None,
+ "updated_at": _STALE,
+ },
+]
+
+
+def _mock_pagination(count: int) -> PaginationMeta:
+ return PaginationMeta(
page=1,
per_page=50,
- returned_count=len(items),
- has_more=not final,
- is_final_page=final,
- inventory_complete=final,
+ returned_count=count,
+ has_more=False,
+ is_final_page=True,
+ inventory_complete=True,
pages_fetched=1,
)
-SAMPLE_PRS = [
- {
- "number": 10,
- "title": "feat: example",
- "mergeable": True,
- "body": "Closes #5",
- "head": {"ref": "feat/x", "sha": "abc123def456"},
- "base": {"ref": "master"},
- },
- {
- "number": 9,
- "title": "fix: conflict",
- "mergeable": False,
- "body": "Closes #5",
- "head": {"ref": "feat/y", "sha": "deadbeef0000"},
- "base": {"ref": "master"},
- },
-]
+def _mock_fetch_prs(*_args, **_kwargs):
+ return list(_SAMPLE_PRS), _mock_pagination(len(_SAMPLE_PRS))
-SAMPLE_ISSUES = [
- {
- "number": 5,
- "title": "Tracked issue",
- "state": "open",
- "labels": [{"name": "status:in-progress"}],
- "assignee": {"login": "jcwalker3"},
- },
- {
- "number": 4,
- "title": "Idle issue",
- "state": "open",
- "labels": [],
- "assignee": None,
- },
-]
+
+def _mock_fetch_issues(*_args, **_kwargs):
+ return list(_SAMPLE_ISSUES), _mock_pagination(len(_SAMPLE_ISSUES))
+
+
+class TestQueueClassification(unittest.TestCase):
+ def test_extract_linked_issue_from_title(self):
+ self.assertEqual(
+ _extract_linked_issue("feat: X (Closes #429)", ""),
+ 429,
+ )
+
+ def test_classify_pr_blocked_and_stale(self):
+ pr = _SAMPLE_PRS[1]
+ badges = _classify_pr(
+ pr,
+ issue_claimed=set(),
+ issue_to_prs={50: [99]},
+ )
+ self.assertIn("blocked", badges)
+ self.assertIn("stale", badges)
+
+ def test_classify_pr_duplicate(self):
+ badges = _classify_pr(
+ _SAMPLE_PRS[2],
+ issue_claimed=set(),
+ issue_to_prs={60: [98, 97]},
+ )
+ self.assertIn("duplicate", badges)
+ self.assertIn("in-review", badges)
+
+ def test_classify_issue_claimed_and_duplicate(self):
+ claimed = _classify_issue(_SAMPLE_ISSUES[0], linked_prs=[])
+ self.assertIn("claimed", claimed)
+ duplicate = _classify_issue(_SAMPLE_ISSUES[1], linked_prs=[98, 97])
+ self.assertIn("duplicate", duplicate)
class TestQueueLoader(unittest.TestCase):
- def test_classifies_claimed_duplicate_and_blocked(self):
+ def test_snapshot_with_mock_fetch(self):
snapshot = load_queue_snapshot(
- fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
- fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ fetch_prs=_mock_fetch_prs,
+ fetch_issues=_mock_fetch_issues,
)
+ self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertIsNone(snapshot.fetch_error)
- pr_badges = {p.number: p.badges for p in snapshot.prs}
- self.assertIn("blocked", pr_badges[9])
- self.assertIn("duplicate", pr_badges[9])
- issue_badges = {i.number: i.badges for i in snapshot.issues}
- self.assertIn("claimed", issue_badges[5])
- self.assertIn("duplicate", issue_badges[5])
+ self.assertEqual(len(snapshot.prs), 4)
+ self.assertEqual(len(snapshot.issues), 3)
+ self.assertTrue(snapshot.pr_pagination.inventory_complete)
+ self.assertTrue(snapshot.issue_pagination.inventory_complete)
- def test_fail_closed_without_credentials(self):
- with patch("webui.queue_loader.get_auth_header", return_value=None):
- snapshot = load_queue_snapshot()
- self.assertIsNotNone(snapshot.fetch_error)
- self.assertIn("credentials unavailable", snapshot.fetch_error.lower())
- self.assertEqual(snapshot.prs, ())
- self.assertEqual(snapshot.issues, ())
+ pr100 = next(p for p in snapshot.prs if p.number == 100)
+ self.assertEqual(pr100.extra["linked_issue"], "429")
+ self.assertIn("claimed", pr100.badges)
- def test_snapshot_json_includes_pagination(self):
+ def test_snapshot_dict_export(self):
snapshot = load_queue_snapshot(
- fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS, final=False),
- fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ fetch_prs=_mock_fetch_prs,
+ fetch_issues=_mock_fetch_issues,
)
- payload = snapshot_to_dict(snapshot)
- self.assertFalse(payload["pagination"]["prs"]["inventory_complete"])
- self.assertTrue(payload["pagination"]["prs"]["has_more"])
- self.assertTrue(payload["pagination"]["issues"]["inventory_complete"])
+ data = snapshot_to_dict(snapshot)
+ self.assertEqual(data["project_id"], "gitea-tools")
+ self.assertIsNone(data["fetch_error"])
+ self.assertEqual(len(data["prs"]), 4)
+ self.assertTrue(data["pagination"]["prs"]["inventory_complete"])
class TestQueueRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
-
- def test_queue_page_renders_with_mocked_loader(self):
- snapshot = load_queue_snapshot(
- fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
- fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
+ self._patch = mock.patch(
+ "webui.app.load_queue_snapshot",
+ return_value=load_queue_snapshot(
+ fetch_prs=_mock_fetch_prs,
+ fetch_issues=_mock_fetch_issues,
+ ),
)
- with patch("webui.app.load_queue_snapshot", return_value=snapshot):
- response = self.client.get("/queue")
+ self._patch.start()
+
+ def tearDown(self):
+ self._patch.stop()
+
+ def test_queue_page_renders_tables_and_pagination(self):
+ response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Live queue", response.text)
- self.assertIn("feat: example", response.text)
- self.assertIn("Tracked issue", response.text)
- self.assertIn("pagination", response.text.lower())
+ self.assertIn("Open pull requests", response.text)
+ self.assertIn("Open issues", response.text)
+ self.assertIn("pages_fetched", response.text)
+ self.assertIn("Gitea-Tools", response.text)
+ self.assertNotIn("child issue", response.text.lower())
def test_api_queue_json(self):
- snapshot = load_queue_snapshot(
- fetch_prs=lambda *_a, **_k: _page(SAMPLE_PRS),
- fetch_issues=lambda *_a, **_k: _page(SAMPLE_ISSUES),
- )
- with patch("webui.app.load_queue_snapshot", return_value=snapshot):
- response = self.client.get("/api/queue")
+ response = self.client.get("/api/queue")
self.assertEqual(response.status_code, 200)
- payload = json.loads(response.text)
- self.assertEqual(len(payload["prs"]), 2)
- self.assertEqual(len(payload["issues"]), 2)
+ data = response.json()
+ self.assertEqual(data["repo"], "Scaled-Tech-Consulting/Gitea-Tools")
+ self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
- def test_nav_includes_queue_link(self):
- response = self.client.get("/")
- self.assertEqual(response.status_code, 200)
- self.assertIn('href="/queue"', response.text)
+ def test_queue_fail_closed_without_credentials(self):
+ with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
+ snapshot = load_queue_snapshot()
+ self.assertIsNotNone(snapshot.fetch_error)
+ self.assertEqual(len(snapshot.prs), 0)
if __name__ == "__main__":
diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py
index 937b095..5dd23a1 100644
--- a/tests/test_webui_skeleton.py
+++ b/tests/test_webui_skeleton.py
@@ -56,11 +56,16 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
+ def test_queue_route_renders(self):
+ response = self.client.get("/queue")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Live queue", response.text)
+
def test_nav_links_on_all_pages(self):
- for path in ("/", "/projects", "/prompts", "/runtime", "/audit"):
+ for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
with self.subTest(path=path):
text = self.client.get(path).text
- for href in ("/projects", "/prompts", "/runtime", "/audit"):
+ for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
self.assertIn(f'href="{href}"', text)
diff --git a/webui/queue_loader.py b/webui/queue_loader.py
index 57ebeeb..a70e58d 100644
--- a/webui/queue_loader.py
+++ b/webui/queue_loader.py
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
from typing import Any, Callable
from urllib.parse import urlparse
-from gitea_auth import api_fetch_page, api_get_all, get_auth_header, repo_api_url
+from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
from webui.project_registry import ProjectRecord, load_registry
From 22a1c4c2df5efe5e6286c8ee8251d03ee946e6fd Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 15:53:10 -0400
Subject: [PATCH 16/42] fix(webui): suppress empty queue copy on fetch failure
(#458)
When Gitea credentials are missing or the queue fetch fails closed,
show "Not loaded" instead of "No open items." and keep pagination
marked unavailable. Successful empty inventories still show the empty
state with complete pagination proof.
Closes #458
---
tests/test_webui_queue_dashboard.py | 74 +++++++++++++++++++++++++++++
webui/queue_views.py | 9 ++++
2 files changed, 83 insertions(+)
diff --git a/tests/test_webui_queue_dashboard.py b/tests/test_webui_queue_dashboard.py
index cc9ea97..cbdcc16 100644
--- a/tests/test_webui_queue_dashboard.py
+++ b/tests/test_webui_queue_dashboard.py
@@ -12,12 +12,14 @@ from starlette.testclient import TestClient
from webui.app import create_app
from webui.queue_loader import (
PaginationMeta,
+ QueueSnapshot,
_classify_issue,
_classify_pr,
_extract_linked_issue,
load_queue_snapshot,
snapshot_to_dict,
)
+from webui.queue_views import render_queue_page
_RECENT = datetime.now(timezone.utc).isoformat()
_STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
@@ -210,5 +212,77 @@ class TestQueueRoutes(unittest.TestCase):
self.assertEqual(len(snapshot.prs), 0)
+def _empty_pagination() -> PaginationMeta:
+ return PaginationMeta(
+ page=1,
+ per_page=50,
+ returned_count=0,
+ has_more=False,
+ is_final_page=True,
+ inventory_complete=True,
+ pages_fetched=1,
+ )
+
+
+def _empty_fetch(*_args, **_kwargs):
+ return [], _empty_pagination()
+
+
+class TestQueueFailClosedUx(unittest.TestCase):
+ """Regression tests for #458 fail-closed empty-state copy."""
+
+ def test_fail_closed_view_suppresses_empty_queue_copy(self):
+ snapshot = QueueSnapshot(
+ project_id="gitea-tools",
+ repo_label="Scaled-Tech-Consulting/Gitea-Tools",
+ prs=(),
+ issues=(),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
+ )
+ html = render_queue_page(snapshot)
+ self.assertIn("Queue unavailable", html)
+ self.assertIn("Not loaded", html)
+ self.assertNotIn("No open items.", html)
+ self.assertIn("pagination: unavailable", html)
+
+ def test_fail_closed_route_does_not_show_empty_queue_copy(self):
+ client = TestClient(create_app())
+ snapshot = load_queue_snapshot(
+ fetch_prs=_empty_fetch,
+ fetch_issues=_empty_fetch,
+ )
+ snapshot = QueueSnapshot(
+ project_id=snapshot.project_id,
+ repo_label=snapshot.repo_label,
+ prs=(),
+ issues=(),
+ pr_pagination=None,
+ issue_pagination=None,
+ fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
+ )
+ with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
+ response = client.get("/queue")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Queue unavailable", response.text)
+ self.assertNotIn("No open items.", response.text)
+
+ def test_successful_empty_inventory_shows_empty_copy(self):
+ snapshot = load_queue_snapshot(
+ fetch_prs=_empty_fetch,
+ fetch_issues=_empty_fetch,
+ )
+ self.assertIsNone(snapshot.fetch_error)
+ self.assertEqual(len(snapshot.prs), 0)
+ self.assertEqual(len(snapshot.issues), 0)
+ self.assertTrue(snapshot.pr_pagination.inventory_complete)
+
+ html = render_queue_page(snapshot)
+ self.assertNotIn("Queue unavailable", html)
+ self.assertEqual(html.count("No open items."), 2)
+ self.assertIn("pagination (complete)", html)
+
+
if __name__ == "__main__":
unittest.main()
\ No newline at end of file
diff --git a/webui/queue_views.py b/webui/queue_views.py
index 8294b8e..e5e6dd7 100644
--- a/webui/queue_views.py
+++ b/webui/queue_views.py
@@ -38,7 +38,13 @@ def _queue_table(
title: str,
items: tuple[QueueItem, ...],
columns: tuple[tuple[str, str], ...],
+ fetch_failed: bool = False,
) -> str:
+ if fetch_failed:
+ return (
+ f"{html.escape(title)}
"
+ "Not loaded — queue fetch did not complete.
"
+ )
if not items:
return f"{html.escape(title)}
No open items.
"
@@ -73,9 +79,11 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
f"{html.escape(snapshot.fetch_error)}
"
)
+ fetch_failed = bool(snapshot.fetch_error)
pr_section = _queue_table(
title="Open pull requests",
items=snapshot.prs,
+ fetch_failed=fetch_failed,
columns=(
("number", "#"),
("title", "Title"),
@@ -88,6 +96,7 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
issue_section = _queue_table(
title="Open issues",
items=snapshot.issues,
+ fetch_failed=fetch_failed,
columns=(
("number", "#"),
("title", "Title"),
From b33844d74a1c3c35d279eb545f77c3ebee7000a6 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 16:11:23 -0400
Subject: [PATCH 17/42] feat: align mutation guard with runtime_context
workspace resolution (Closes #460)
Share canonical workspace/repo-root resolution between gitea_get_runtime_context
and verify_preflight_purity so valid branches/ worktrees are not rejected when
the MCP process root differs from the stable control checkout. Fix relative
git-common-dir resolution and add regression tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
author_mutation_worktree.py | 127 +++++++++++++++++
gitea_mcp_server.py | 98 +++++++------
tests/test_create_issue_workspace_guard.py | 32 +++--
tests/test_workspace_guard_alignment.py | 153 +++++++++++++++++++++
4 files changed, 348 insertions(+), 62 deletions(-)
create mode 100644 tests/test_workspace_guard_alignment.py
diff --git a/author_mutation_worktree.py b/author_mutation_worktree.py
index 04b6933..29dc05d 100644
--- a/author_mutation_worktree.py
+++ b/author_mutation_worktree.py
@@ -7,8 +7,11 @@ project's ``branches/`` directory, never from the stable control checkout.
from __future__ import annotations
import os
+import subprocess
BASE_BRANCHES = frozenset({"master", "main", "dev"})
+ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
+AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
def _normalize_path(path: str) -> str:
@@ -48,6 +51,130 @@ def resolve_mutation_workspace(
return os.path.realpath(project_root)
+def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
+ """Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
+ raw = (common_dir or "").strip()
+ if not raw:
+ return raw
+ if os.path.isabs(raw):
+ return os.path.realpath(raw)
+ return os.path.realpath(os.path.join(workspace_path, raw))
+
+
+def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
+ """Return the stable repository root for *workspace_path* via git metadata (#460)."""
+ path = (workspace_path or "").strip()
+ fallback = os.path.realpath(fallback_project_root)
+ if not path:
+ return fallback
+ try:
+ res = subprocess.run(
+ ["git", "-C", path, "rev-parse", "--git-common-dir"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ common = _realpath_git_common_dir(path, res.stdout)
+ except Exception:
+ return fallback
+ if common.endswith(f"{os.sep}.git"):
+ return os.path.dirname(common)
+ if os.path.basename(common) == ".git":
+ return os.path.dirname(common)
+ return fallback
+
+
+def resolve_author_mutation_context(
+ worktree_path: str | None,
+ process_project_root: str,
+ *,
+ active_worktree_env: str | None = None,
+ author_worktree_env: str | None = None,
+) -> dict:
+ """Shared workspace resolution for runtime_context and mutation guards (#460)."""
+ workspace = resolve_mutation_workspace(
+ worktree_path,
+ process_project_root,
+ active_worktree_env=active_worktree_env,
+ author_worktree_env=author_worktree_env,
+ )
+ process_root = os.path.realpath(process_project_root)
+ # Canonical repository identity comes from the MCP process checkout (#460),
+ # not from the declared task workspace being validated.
+ canonical_root = resolve_canonical_repo_root(process_root, process_root)
+ return {
+ "workspace_path": workspace,
+ "process_project_root": process_root,
+ "canonical_repo_root": canonical_root,
+ "roots_aligned": canonical_root == process_root,
+ }
+
+
+def assess_workspace_repo_membership(
+ *,
+ workspace_path: str,
+ canonical_repo_root: str,
+) -> dict:
+ """Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
+ workspace = os.path.realpath(workspace_path)
+ root = os.path.realpath(canonical_repo_root)
+ reasons: list[str] = []
+
+ if not os.path.exists(workspace):
+ reasons.append(f"worktree path '{workspace}' does not exist")
+ return _membership_assessment(False, reasons, workspace, root, None)
+
+ if not os.path.isdir(workspace):
+ reasons.append(f"worktree path '{workspace}' is not a directory")
+ return _membership_assessment(False, reasons, workspace, root, None)
+
+ try:
+ res = subprocess.run(
+ ["git", "-C", workspace, "rev-parse", "--git-common-dir"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ common_dir = _realpath_git_common_dir(workspace, res.stdout)
+ except Exception:
+ reasons.append(f"worktree '{workspace}' is not a valid git repository")
+ return _membership_assessment(False, reasons, workspace, root, None)
+
+ expected_dir = os.path.realpath(os.path.join(root, ".git"))
+ if common_dir != expected_dir:
+ reasons.append(
+ f"worktree '{workspace}' does not belong to the target repository '{root}'"
+ )
+ return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
+
+
+def _membership_assessment(
+ proven: bool,
+ reasons: list[str],
+ workspace: str,
+ root: str,
+ common_dir: str | None,
+) -> dict:
+ return {
+ "proven": proven,
+ "block": not proven,
+ "reasons": reasons,
+ "workspace_path": workspace,
+ "canonical_repo_root": root,
+ "git_common_dir": common_dir,
+ }
+
+
+def format_workspace_repo_membership_error(assessment: dict) -> str:
+ workspace = assessment.get("workspace_path") or "(unknown)"
+ root = assessment.get("canonical_repo_root") or "(unknown)"
+ reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
+ return (
+ f"Branches-only mutation guard (#274): {reasons} (fail closed). "
+ f"canonical repository root: {root}; workspace: {workspace}."
+ )
+
+
def assess_author_mutation_worktree(
*,
workspace_path: str,
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index beb6084..5fd8c48 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -190,14 +190,22 @@ def _ensure_process_start_porcelain() -> str:
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
"""Resolve the workspace root inspected by pre-flight guards."""
- path = (worktree_path or "").strip()
- if not path:
- path = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip()
- if not path:
- path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
- if not path:
- path = PROJECT_ROOT
- return os.path.realpath(os.path.abspath(path))
+ return author_mutation_worktree.resolve_mutation_workspace(
+ worktree_path,
+ PROJECT_ROOT,
+ active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
+ author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
+ )
+
+
+def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
+ """Canonical workspace + repository root for runtime_context and guards (#460)."""
+ return author_mutation_worktree.resolve_author_mutation_context(
+ worktree_path,
+ PROJECT_ROOT,
+ active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
+ author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
+ )
def _get_git_root(path: str) -> str | None:
@@ -267,21 +275,31 @@ def _format_preflight_files(files: list[str]) -> str:
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
- workspace = _resolve_preflight_workspace_path(worktree_path)
+ ctx = _resolve_author_mutation_context(worktree_path)
+ workspace = ctx["workspace_path"]
inspected_root = _get_git_root(workspace)
- control_root = os.path.realpath(PROJECT_ROOT)
+ process_root = ctx["process_project_root"]
+ canonical_root = ctx["canonical_repo_root"]
active_root = os.path.realpath(inspected_root or workspace)
- if active_root == control_root:
+ if active_root == canonical_root:
dirty_scope = "control checkout"
else:
dirty_scope = "active task workspace"
- return {
- "mcp_server_process_root": control_root,
+ details = {
+ "mcp_server_process_root": process_root,
+ "canonical_repository_root": canonical_root,
"active_task_workspace_root": active_root,
"inspected_git_root": inspected_root,
"dirty_files": list(dirty_files),
"dirty_scope": dirty_scope,
+ "workspace_roots_aligned": ctx["roots_aligned"],
}
+ if not ctx["roots_aligned"]:
+ details["workspace_root_mismatch"] = (
+ "runtime_context and mutation guard use canonical repository root "
+ f"'{canonical_root}' instead of MCP process root '{process_root}'"
+ )
+ return details
def _format_preflight_workspace_details(details: dict) -> str:
@@ -402,16 +420,12 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
"""#274: author mutations must run from a branches/ session worktree."""
if _preflight_resolved_role == "reviewer":
return
- workspace = author_mutation_worktree.resolve_mutation_workspace(
- worktree_path,
- PROJECT_ROOT,
- active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
- author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
- )
+ ctx = _resolve_author_mutation_context(worktree_path)
+ workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
assessment = author_mutation_worktree.assess_author_mutation_worktree(
workspace_path=workspace,
- project_root=PROJECT_ROOT,
+ project_root=ctx["canonical_repo_root"],
current_branch=git_state.get("current_branch"),
)
if assessment["block"]:
@@ -440,43 +454,23 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
- workspace = author_mutation_worktree.resolve_mutation_workspace(
- worktree_path,
- PROJECT_ROOT,
- active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
- author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
- )
+ ctx = _resolve_author_mutation_context(worktree_path)
+ workspace = ctx["workspace_path"]
+ canonical_root = ctx["canonical_repo_root"]
+ process_root = ctx["process_project_root"]
real_workspace = os.path.realpath(workspace)
- real_root = os.path.realpath(PROJECT_ROOT)
- if real_workspace != real_root:
+ if real_workspace != process_root:
if not _preflight_in_test_mode():
- if not os.path.exists(real_workspace):
+ membership = author_mutation_worktree.assess_workspace_repo_membership(
+ workspace_path=workspace,
+ canonical_repo_root=canonical_root,
+ )
+ if membership["block"]:
raise RuntimeError(
- f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)"
- )
- if not os.path.isdir(real_workspace):
- raise RuntimeError(
- f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)"
- )
- try:
- res = subprocess.run(
- ["git", "-C", real_workspace, "rev-parse", "--git-common-dir"],
- capture_output=True,
- text=True,
- check=True,
- )
- common_dir = os.path.realpath(res.stdout.strip())
- expected_dir = os.path.realpath(os.path.join(real_root, ".git"))
- if common_dir != expected_dir:
- raise RuntimeError(
- f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)"
+ author_mutation_worktree.format_workspace_repo_membership_error(
+ membership
)
- except Exception as e:
- if isinstance(e, RuntimeError):
- raise e
- raise RuntimeError(
- f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)"
)
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
diff --git a/tests/test_create_issue_workspace_guard.py b/tests/test_create_issue_workspace_guard.py
index 2cd9f41..8d1d1f6 100644
--- a/tests/test_create_issue_workspace_guard.py
+++ b/tests/test_create_issue_workspace_guard.py
@@ -106,20 +106,32 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
@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.isdir", return_value=True)
+ @patch("author_mutation_worktree.subprocess.run")
@patch("subprocess.run")
- 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
-
+ 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):
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 self.assertRaises(RuntimeError) as ctx:
- srv.gitea_create_issue(
- title="Test issue", body="body", worktree_path=wrong_repo_path
- )
+ with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
+ with self.assertRaises(RuntimeError) as ctx:
+ srv.gitea_create_issue(
+ title="Test issue", body="body", worktree_path=wrong_repo_path
+ )
self.assertIn("does not belong to the target repository", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
diff --git a/tests/test_workspace_guard_alignment.py b/tests/test_workspace_guard_alignment.py
new file mode 100644
index 0000000..ddcb67f
--- /dev/null
+++ b/tests/test_workspace_guard_alignment.py
@@ -0,0 +1,153 @@
+"""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()
\ No newline at end of file
From 795f54404798a1eb70be74419a6260153e423652 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 16:18:42 -0400
Subject: [PATCH 18/42] feat: block manual issue-lock seeding and require lock
disclosure (#447)
Add lock_provenance metadata on gitea_lock_issue writes and fail closed at
gitea_create_pr when provenance is missing. Final-report validation now
requires explicit External-state mutations disclosure for issue-lock
read/write/delete and blocks mixed author PR creation with reviewer approval.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/llm-workflow-runbooks.md | 15 +
final_report_validator.py | 62 ++++
gitea_mcp_server.py | 13 +
issue_lock_provenance.py | 269 ++++++++++++++++++
.../workflows/work-issue.md | 6 +
tests/test_commit_payloads.py | 14 +
tests/test_issue_lock_provenance.py | 130 +++++++++
tests/test_mcp_server.py | 36 +++
8 files changed, 545 insertions(+)
create mode 100644 issue_lock_provenance.py
create mode 100644 tests/test_issue_lock_provenance.py
diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md
index f3d82e3..09b4d5c 100644
--- a/docs/llm-workflow-runbooks.md
+++ b/docs/llm-workflow-runbooks.md
@@ -281,6 +281,21 @@ same-issue/same-operation lease blocks duplicate work. An expired lease still
blocks takeover until a recovery review records why the prior work is abandoned,
completed, or unsafe to continue.
+**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
+`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
+shared state and manual writes can clobber another session's live lease. Use
+`sanctioned recovery` instead:
+
+1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
+2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
+3. Operator override only when explicitly authorized — record
+ `External-state mutations` and `operator override proof` in the final report.
+
+`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
+metadata. Final-report validation blocks handoffs that hide lock read/write/delete
+under `External-state mutations: none` or mix author PR creation with reviewer
+approval in one run. See also #438 (global lock redesign).
+
Remote branches matching the issue number are also treated as active work unless
the recovery review proves the branch is abandoned or superseded. Never delete
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
diff --git a/final_report_validator.py b/final_report_validator.py
index 3fbac0f..bbc4b6c 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -11,6 +11,7 @@ import inspect
import re
from typing import Any, Callable
+import issue_lock_provenance
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -870,6 +871,54 @@ def _rule_reviewer_mutation_ledger(
)
+def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
+ result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
+ if result.get("proven"):
+ return []
+ return _findings_from_reasons(
+ "shared.issue_lock_external_state",
+ result.get("reasons") or [],
+ field="External-state mutations",
+ severity="block",
+ safe_next_action=(
+ "disclose gitea_issue_lock.json read/write/delete under "
+ "External-state mutations; never claim none after lock seeding"
+ ),
+ )
+
+
+def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
+ result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
+ if result.get("proven"):
+ return []
+ return _findings_from_reasons(
+ "shared.manual_lock_pr_override",
+ result.get("reasons") or [],
+ field="External-state mutations",
+ severity="block",
+ safe_next_action=(
+ "use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
+ "if operator override was authorized, cite override proof"
+ ),
+ )
+
+
+def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
+ result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
+ if result.get("proven"):
+ return []
+ return _findings_from_reasons(
+ "shared.author_reviewer_same_run",
+ result.get("reasons") or [],
+ field="Review mutations",
+ severity="block",
+ safe_next_action=(
+ "split author PR creation and reviewer approval across separate "
+ "sessions and handoffs"
+ ),
+ )
+
+
def _rule_reviewer_review_mutation(
report_text: str,
*,
@@ -889,10 +938,17 @@ def _rule_reviewer_review_mutation(
)
+_SHARED_ISSUE_LOCK_RULES = (
+ _rule_shared_issue_lock_external_state,
+ _rule_shared_manual_lock_pr_override,
+ _rule_shared_author_reviewer_same_run,
+)
+
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
@@ -913,6 +969,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
@@ -924,25 +981,30 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"issue_filing": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
+ *_SHARED_ISSUE_LOCK_RULES,
],
}
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index beb6084..25eb9f3 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -538,6 +538,7 @@ import task_capability_map # noqa: E402
import review_proofs # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
+import issue_lock_provenance # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
@@ -1208,6 +1209,10 @@ def gitea_lock_issue(
"repo": r,
"worktree_path": resolved_worktree,
"work_lease": work_lease,
+ "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
+ tool="gitea_lock_issue",
+ claimant=work_lease.get("claimant"),
+ ),
}
try:
@@ -1298,6 +1303,14 @@ def gitea_create_pr(
except Exception as e:
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
+ lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
+ lock_data
+ )
+ if lock_provenance_check["block"]:
+ raise RuntimeError(
+ issue_lock_provenance.format_lock_provenance_error(lock_provenance_check)
+ )
+
locked_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name")
locked_worktree = lock_data.get("worktree_path")
diff --git a/issue_lock_provenance.py b/issue_lock_provenance.py
new file mode 100644
index 0000000..87ee38f
--- /dev/null
+++ b/issue_lock_provenance.py
@@ -0,0 +1,269 @@
+"""Issue-lock provenance and external-state disclosure (#447).
+
+Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
+#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
+blocked at PR creation unless explicit operator override proof is recorded.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from datetime import datetime, timezone
+
+ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
+
+SOURCE_LOCK_ISSUE = "gitea_lock_issue"
+SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
+SOURCE_OPERATOR_OVERRIDE = "operator_override"
+
+SANCTIONED_LOCK_SOURCES = frozenset({
+ SOURCE_LOCK_ISSUE,
+ SOURCE_LOCK_ADOPTION,
+ SOURCE_OPERATOR_OVERRIDE,
+})
+
+_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
+
+_ISSUE_LOCK_PATH_RE = re.compile(
+ r"(?:/tmp/)?gitea_issue_lock\.json",
+ re.IGNORECASE,
+)
+_LOCK_SEED_RE = re.compile(
+ r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
+ r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
+ re.IGNORECASE | re.DOTALL,
+)
+_LOCK_REMOVE_RE = re.compile(
+ r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
+ re.IGNORECASE | re.DOTALL,
+)
+_LOCK_READ_RE = re.compile(
+ r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
+ re.IGNORECASE | re.DOTALL,
+)
+_EXTERNAL_NONE_RE = re.compile(
+ r"external[- ]state mutations\s*:\s*none\b",
+ re.IGNORECASE,
+)
+_EXTERNAL_FIELD_RE = re.compile(
+ r"external[- ]state mutations\s*:\s*(.+)$",
+ re.IGNORECASE | re.MULTILINE,
+)
+_CLEANUP_ONLY_RE = re.compile(
+ r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
+ re.IGNORECASE,
+)
+_PR_CREATED_RE = re.compile(
+ r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
+ r"PR\s+creation\s+(?:succeeded|complete))",
+ re.IGNORECASE,
+)
+_REVIEW_APPROVE_RE = re.compile(
+ r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
+ r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
+ re.IGNORECASE,
+)
+_OVERRIDE_PROOF_RE = re.compile(
+ r"operator[- ]override\s+proof\s*:\s*(.+)$",
+ re.IGNORECASE | re.MULTILINE,
+)
+
+
+def _utc_now_iso() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def build_sanctioned_lock_provenance(
+ *,
+ tool: str,
+ source: str = SOURCE_LOCK_ISSUE,
+ claimant: dict | None = None,
+ adoption: dict | None = None,
+) -> dict:
+ """Return provenance metadata stored with a sanctioned lock write."""
+ entry = {
+ "source": source,
+ "written_at": _utc_now_iso(),
+ "written_by_tool": tool,
+ "lock_file_path": ISSUE_LOCK_FILE,
+ }
+ if claimant:
+ entry["claimant"] = claimant
+ if adoption:
+ entry["adoption"] = adoption
+ return entry
+
+
+def operator_override_requested() -> bool:
+ return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
+ "1",
+ "true",
+ "yes",
+ }
+
+
+def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
+ text = (reason or "").strip()
+ if not text:
+ raise ValueError(
+ "operator override requires a non-empty override reason (fail closed)"
+ )
+ entry = build_sanctioned_lock_provenance(
+ tool="operator_override",
+ source=SOURCE_OPERATOR_OVERRIDE,
+ claimant=claimant,
+ )
+ entry["override_reason"] = text
+ return entry
+
+
+def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
+ """Fail closed when lock file lacks sanctioned provenance (#447)."""
+ data = lock_data if isinstance(lock_data, dict) else {}
+ reasons: list[str] = []
+ provenance = data.get("lock_provenance")
+ if not isinstance(provenance, dict):
+ reasons.append(
+ "issue lock file lacks sanctioned lock_provenance; manual seeding is "
+ "not a normal recovery path — call gitea_lock_issue or use #442 adoption"
+ )
+ return _provenance_result(False, reasons, provenance)
+
+ source = str(provenance.get("source") or "").strip()
+ if source not in SANCTIONED_LOCK_SOURCES:
+ reasons.append(
+ f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
+ )
+
+ if source == SOURCE_OPERATOR_OVERRIDE and not str(
+ provenance.get("override_reason") or ""
+ ).strip():
+ reasons.append(
+ "operator_override lock provenance requires override_reason proof"
+ )
+
+ if not data.get("work_lease"):
+ reasons.append("issue lock file missing work_lease metadata")
+
+ if not str(provenance.get("written_by_tool") or "").strip():
+ reasons.append("issue lock provenance missing written_by_tool")
+
+ proven = not reasons
+ return _provenance_result(proven, reasons, provenance)
+
+
+def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
+ return {
+ "proven": proven,
+ "block": not proven,
+ "reasons": reasons,
+ "lock_provenance": provenance,
+ }
+
+
+def format_lock_provenance_error(assessment: dict) -> str:
+ reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
+ return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
+
+
+def _lock_activity_detected(text: str) -> dict[str, bool]:
+ body = text or ""
+ return {
+ "seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
+ "remove": bool(_LOCK_REMOVE_RE.search(body)),
+ "read": bool(_LOCK_READ_RE.search(body)),
+ }
+
+
+def _external_state_discloses_lock(text: str) -> bool:
+ match = _EXTERNAL_FIELD_RE.search(text or "")
+ if not match:
+ return False
+ value = (match.group(1) or "").strip().lower()
+ if value in {"", "none", "n/a"}:
+ return False
+ return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
+
+
+def assess_issue_lock_external_state_report(report_text: str) -> dict:
+ """Require explicit external-state disclosure for issue-lock mutations (#447)."""
+ text = report_text or ""
+ activity = _lock_activity_detected(text)
+ if not any(activity.values()):
+ return {"proven": True, "block": False, "reasons": [], "activity": activity}
+
+ reasons: list[str] = []
+ disclosed = _external_state_discloses_lock(text)
+
+ if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
+ reasons.append(
+ "report mentions seeding/restoring gitea_issue_lock.json but claims "
+ "External-state mutations: none"
+ )
+ elif activity["seed_or_restore"] and not disclosed:
+ reasons.append(
+ "report mentions issue-lock file activity but External-state mutations "
+ "does not disclose read/write of gitea_issue_lock.json"
+ )
+
+ if activity["remove"]:
+ if _EXTERNAL_NONE_RE.search(text):
+ reasons.append(
+ "report mentions removing gitea_issue_lock.json but claims "
+ "External-state mutations: none"
+ )
+ elif not disclosed and _CLEANUP_ONLY_RE.search(text):
+ reasons.append(
+ "report removes issue lock but classifies it as cleanup only; "
+ "record under External-state mutations"
+ )
+ elif not disclosed:
+ reasons.append(
+ "report mentions deleting issue lock without External-state "
+ "mutation disclosure"
+ )
+
+ proven = not reasons
+ return {
+ "proven": proven,
+ "block": not proven,
+ "reasons": reasons,
+ "activity": activity,
+ }
+
+
+def assess_manual_lock_pr_without_override(report_text: str) -> dict:
+ """Block reports that created a PR via manual lock seed without override proof."""
+ text = report_text or ""
+ seeded = bool(_LOCK_SEED_RE.search(text))
+ created = bool(_PR_CREATED_RE.search(text))
+ if not (seeded and created):
+ return {"proven": True, "block": False, "reasons": []}
+
+ if _OVERRIDE_PROOF_RE.search(text):
+ return {"proven": True, "block": False, "reasons": []}
+
+ return {
+ "proven": False,
+ "block": True,
+ "reasons": [
+ "report created/opened a PR after manual issue-lock seeding without "
+ "operator override proof"
+ ],
+ }
+
+
+def assess_author_reviewer_same_run_report(report_text: str) -> dict:
+ """Reviewer handoff must not create and approve the same PR in one run (#447)."""
+ text = report_text or ""
+ if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
+ return {"proven": True, "block": False, "reasons": []}
+ return {
+ "proven": False,
+ "block": True,
+ "reasons": [
+ "report mixes author-side PR creation and reviewer approval in one "
+ "final handoff; split author and reviewer sessions"
+ ],
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md
index f450cd7..8f7a227 100644
--- a/skills/llm-project-workflow/workflows/work-issue.md
+++ b/skills/llm-project-workflow/workflows/work-issue.md
@@ -717,6 +717,12 @@ Use only precise categories:
* External-state mutations:
* Read-only diagnostics:
+Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
+external-state mutation. Never claim `External-state mutations: none` after
+seeding, restoring, or removing that file. Manual lock seeding is not a normal
+recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
+instead. Link broader redesign: #438.
+
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
diff --git a/tests/test_commit_payloads.py b/tests/test_commit_payloads.py
index e8e4123..eb712a0 100644
--- a/tests/test_commit_payloads.py
+++ b/tests/test_commit_payloads.py
@@ -67,6 +67,15 @@ class TestCommitPayloads(unittest.TestCase):
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
self.lock_file_path = "/tmp/gitea_issue_lock.json"
+ import issue_lock_provenance
+
+ work_lease = {
+ "operation_type": "author_issue_work",
+ "issue_number": 263,
+ "branch": "feat/issue-263-native-commit-payloads",
+ "claimant": {"username": "test-user", "profile": "test-author"},
+ "expires_at": "2999-01-01T00:00:00Z",
+ }
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
@@ -74,6 +83,11 @@ class TestCommitPayloads(unittest.TestCase):
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
+ "work_lease": work_lease,
+ "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
+ tool="gitea_lock_issue",
+ claimant=work_lease.get("claimant"),
+ ),
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
diff --git a/tests/test_issue_lock_provenance.py b/tests/test_issue_lock_provenance.py
new file mode 100644
index 0000000..3e64090
--- /dev/null
+++ b/tests/test_issue_lock_provenance.py
@@ -0,0 +1,130 @@
+"""Tests for issue-lock provenance and external-state disclosure (#447)."""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import issue_lock_provenance as ilp # noqa: E402
+from final_report_validator import assess_final_report_validator # noqa: E402
+
+
+def _sanctioned_lock(**overrides):
+ work_lease = {
+ "operation_type": "author_issue_work",
+ "issue_number": 447,
+ "branch": "feat/issue-447-lock-provenance",
+ "claimant": {"username": "jcwalker3", "profile": "prgs-author"},
+ "expires_at": "2999-01-01T00:00:00Z",
+ }
+ data = {
+ "issue_number": 447,
+ "branch_name": "feat/issue-447-lock-provenance",
+ "work_lease": work_lease,
+ "lock_provenance": ilp.build_sanctioned_lock_provenance(
+ tool="gitea_lock_issue",
+ claimant=work_lease["claimant"],
+ ),
+ }
+ data.update(overrides)
+ return data
+
+
+class TestLockProvenanceForCreatePr(unittest.TestCase):
+ def test_sanctioned_lock_passes(self):
+ result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
+ self.assertTrue(result["proven"])
+ self.assertFalse(result["block"])
+
+ def test_manual_seed_without_provenance_blocked(self):
+ result = ilp.assess_lock_file_for_create_pr(
+ {"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
+ )
+ self.assertTrue(result["block"])
+ self.assertIn("lock_provenance", result["reasons"][0])
+
+ def test_operator_override_requires_reason(self):
+ result = ilp.assess_lock_file_for_create_pr(
+ _sanctioned_lock(
+ lock_provenance=ilp.build_sanctioned_lock_provenance(
+ tool="operator_override",
+ source=ilp.SOURCE_OPERATOR_OVERRIDE,
+ )
+ )
+ )
+ self.assertTrue(result["block"])
+
+
+class TestExternalStateReportRules(unittest.TestCase):
+ def test_seed_with_external_none_blocked(self):
+ report = (
+ "Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
+ "- External-state mutations: none\n"
+ )
+ result = ilp.assess_issue_lock_external_state_report(report)
+ self.assertTrue(result["block"])
+
+ def test_seed_with_disclosure_passes(self):
+ report = (
+ "Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
+ "- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
+ )
+ result = ilp.assess_issue_lock_external_state_report(report)
+ self.assertTrue(result["proven"])
+
+ def test_remove_claimed_as_cleanup_only_blocked(self):
+ report = (
+ "rm /tmp/gitea_issue_lock.json after PR creation.\n"
+ "- Cleanup mutations: lock removed\n"
+ "- External-state mutations: none\n"
+ )
+ result = ilp.assess_issue_lock_external_state_report(report)
+ self.assertTrue(result["block"])
+
+ def test_manual_lock_pr_without_override_blocked(self):
+ report = (
+ "Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
+ "PR #444 created.\n"
+ )
+ result = ilp.assess_manual_lock_pr_without_override(report)
+ self.assertTrue(result["block"])
+
+ def test_author_reviewer_same_run_blocked(self):
+ report = (
+ "gitea_create_pr opened PR #444.\n"
+ "Submitted approve review on PR #444.\n"
+ )
+ result = ilp.assess_author_reviewer_same_run_report(report)
+ self.assertTrue(result["block"])
+
+
+class TestFinalReportValidatorIntegration(unittest.TestCase):
+ def test_work_issue_blocks_hidden_lock_mutation(self):
+ report = (
+ "## Controller Handoff\n"
+ "- Task: work issue #420\n"
+ "- External-state mutations: none\n"
+ "Restored /tmp/gitea_issue_lock.json before PR creation.\n"
+ )
+ result = assess_final_report_validator(report, "work_issue")
+ rule_ids = {f["rule_id"] for f in result["findings"]}
+ self.assertIn("shared.issue_lock_external_state", rule_ids)
+ self.assertTrue(result["blocked"])
+
+ def test_review_pr_blocks_create_and_approve(self):
+ report = (
+ "## Controller Handoff\n"
+ "- Task: review PR #444\n"
+ "- Review decision: approve\n"
+ "Created PR #444 via gitea_create_pr earlier in this run.\n"
+ )
+ result = assess_final_report_validator(report, "review_pr")
+ rule_ids = {f["rule_id"] for f in result["findings"]}
+ self.assertIn("shared.author_reviewer_same_run", rule_ids)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 4002b5e..9f39ae1 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -101,12 +101,26 @@ ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
+ import issue_lock_provenance
+
+ work_lease = {
+ "operation_type": "author_issue_work",
+ "issue_number": issue_number,
+ "branch": branch_name,
+ "claimant": {"username": "test-user", "profile": "test-author"},
+ "expires_at": "2999-01-01T00:00:00Z",
+ }
record = {
"issue_number": issue_number,
"branch_name": branch_name,
"remote": "dadeschools",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
+ "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
@@ -3284,6 +3298,28 @@ class TestIssueLocking(unittest.TestCase):
)
self.assertIn("does not match locked worktree", str(ctx.exception))
+ @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_manual_lock_seed_blocked(self, _auth, _role):
+ with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
+ json.dump(
+ _sample_issue_lock(
+ issue_number=447,
+ branch_name="feat/issue-447-lock-provenance",
+ lock_provenance=None,
+ ),
+ f,
+ )
+ with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ with self.assertRaises(RuntimeError) as ctx:
+ gitea_create_pr(
+ title="feat: lock provenance Closes #447",
+ head="feat/issue-447-lock-provenance",
+ remote="prgs",
+ )
+ self.assertIn("lock provenance", str(ctx.exception).lower())
+
@patch("mcp_server.api_request")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
From e4adccd82a86c9f417c20fe8339ca4ef2d5edfe0 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 16:33:31 -0400
Subject: [PATCH 19/42] fix: inject duplicate-work context fetcher for testable
lock/create_pr paths (#400)
Expose issue_duplicate_context_fetcher on the MCP server so lock_issue,
commit_files, and create_pr duplicate rechecks avoid live Gitea calls in
unit tests. Update affected test suites to patch the fetcher without
weakening duplicate-work gate assertions.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 20 +++++++-
tests/test_agent_temp_artifacts.py | 5 +-
tests/test_commit_files_capability.py | 7 +++
tests/test_commit_payloads.py | 7 +++
tests/test_issue_work_duplicate_gate.py | 31 +++++++++++++
tests/test_mcp_server.py | 61 +++++++++++++++----------
6 files changed, 105 insertions(+), 26 deletions(-)
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index c4e4fdc..f1b1849 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -548,7 +548,7 @@ def _load_existing_issue_lock() -> dict | None:
if not os.path.exists(ISSUE_LOCK_FILE):
return None
try:
- with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
+ with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
@@ -642,7 +642,7 @@ def _branch_entry_name(branch: dict | str) -> str:
return str(branch.get("name") or branch.get("ref") or "")
-def _collect_issue_duplicate_context(
+def _live_fetch_issue_duplicate_context(
h: str,
o: str,
r: str,
@@ -667,6 +667,22 @@ def _collect_issue_duplicate_context(
return open_prs, branch_names, claim_entry
+# Injectable duplicate-work context fetcher (#400). Production uses the live
+# Gitea API path above; unit tests patch this symbol instead of hitting the
+# network.
+issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context
+
+
+def _collect_issue_duplicate_context(
+ h: str,
+ o: str,
+ r: str,
+ auth: str,
+ issue_number: int,
+) -> tuple[list[dict], list[str], dict]:
+ return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
+
+
def _assess_issue_duplicate_gate(
issue_number: int,
*,
diff --git a/tests/test_agent_temp_artifacts.py b/tests/test_agent_temp_artifacts.py
index 903eab8..1532143 100644
--- a/tests/test_agent_temp_artifacts.py
+++ b/tests/test_agent_temp_artifacts.py
@@ -80,7 +80,10 @@ class TestIssueLockArtifactWarning(unittest.TestCase):
def tearDown(self):
self._env_patcher.stop()
- @patch("mcp_server.api_get_all", return_value=[])
+ @patch(
+ "mcp_server.issue_duplicate_context_fetcher",
+ return_value=([], [], {"status": "not_claimed"}),
+ )
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
diff --git a/tests/test_commit_files_capability.py b/tests/test_commit_files_capability.py
index d1203bd..c1474ab 100644
--- a/tests/test_commit_files_capability.py
+++ b/tests/test_commit_files_capability.py
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
+ 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._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
diff --git a/tests/test_commit_payloads.py b/tests/test_commit_payloads.py
index e8e4123..1e684e7 100644
--- a/tests/test_commit_payloads.py
+++ b/tests/test_commit_payloads.py
@@ -84,7 +84,14 @@ class TestCommitPayloads(unittest.TestCase):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
+ 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._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py
index a041ef3..6e9ea5a 100644
--- a/tests/test_issue_work_duplicate_gate.py
+++ b/tests/test_issue_work_duplicate_gate.py
@@ -121,6 +121,37 @@ class TestDuplicateReportOutcome(unittest.TestCase):
self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
+class TestInjectableDuplicateFetcher(unittest.TestCase):
+ @patch("mcp_server.get_auth_header", return_value="token x")
+ def test_lock_issue_uses_injected_fetcher(self, _auth):
+ seen = {}
+
+ def fetcher(h, o, r, auth, issue_number):
+ seen["issue_number"] = issue_number
+ return [], [], {"status": "not_claimed"}
+
+ with patch(
+ "mcp_server.issue_duplicate_context_fetcher",
+ side_effect=fetcher,
+ ), patch(
+ "mcp_server.issue_lock_worktree.read_worktree_git_state",
+ return_value={
+ "current_branch": "master",
+ "porcelain_status": "",
+ "base_equivalent": True,
+ },
+ ), patch.dict(os.environ, {
+ "GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
+ }, clear=True):
+ with patch.object(mcp_server, "ISSUE_LOCK_FILE", tempfile.mktemp()):
+ mcp_server.gitea_lock_issue(
+ issue_number=400,
+ branch_name="feat/issue-400-duplicate-work-preflight",
+ remote="prgs",
+ )
+ self.assertEqual(seen["issue_number"], 400)
+
+
class TestMcpDuplicateRecheck(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 4002b5e..588e6e8 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -112,6 +112,11 @@ def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
return record
+def _clear_duplicate_context_fetcher(*_args, **_kwargs):
+ """Default injectable duplicate-work context for lock/create_pr tests."""
+ return [], [], {"status": "not_claimed"}
+
+
# ---------------------------------------------------------------------------
# Create Issue
# ---------------------------------------------------------------------------
@@ -166,13 +171,17 @@ class TestCreateIssue(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestCreatePR(unittest.TestCase):
+ @patch(
+ "mcp_server.issue_duplicate_context_fetcher",
+ return_value=([], [], {"status": "not_claimed"}),
+ )
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
- def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role):
+ def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
@@ -187,13 +196,17 @@ class TestCreatePR(unittest.TestCase):
self.assertEqual(payload["base"], "main")
self.assertIn("Closes #123", payload["title"])
+ @patch(
+ "mcp_server.issue_duplicate_context_fetcher",
+ return_value=([], [], {"status": "not_claimed"}),
+ )
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
- def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role):
+ def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
@@ -3044,8 +3057,14 @@ class TestIssueLocking(unittest.TestCase):
def setUp(self):
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
self._env_patcher.start()
+ self._dup_fetcher_patcher = patch(
+ "mcp_server.issue_duplicate_context_fetcher",
+ return_value=([], [], {"status": "not_claimed"}),
+ )
+ self.mock_dup_fetcher = self._dup_fetcher_patcher.start()
def tearDown(self):
+ self._dup_fetcher_patcher.stop()
self._env_patcher.stop()
if os.path.exists(ISSUE_LOCK_FILE):
os.remove(ISSUE_LOCK_FILE)
@@ -3054,10 +3073,8 @@ class TestIssueLocking(unittest.TestCase):
"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_success(self, _auth, mock_api, _git_state):
- mock_api.return_value = [] # no open PRs
+ def test_lock_issue_success(self, _auth, _git_state):
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
@@ -3082,50 +3099,48 @@ class TestIssueLocking(unittest.TestCase):
"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_reused_by_open_pr_branch(self, _auth, mock_api, _git_state):
- mock_api.return_value = [{
+ def test_lock_issue_reused_by_open_pr_branch(self, _auth, _git_state):
+ self.mock_dup_fetcher.return_value = ([{
"number": 200,
"head": {"ref": "feat/issue-196-boundary"},
"title": "Some PR",
- "body": "No closes ref"
- }]
+ "body": "No closes ref",
+ }], [], {"status": "not_claimed"})
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
- self.assertIn("already tied to an open PR", str(ctx.exception))
+ self.assertIn("open PR #200 already covers issue", str(ctx.exception))
@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_reused_by_open_pr_closes_ref(self, _auth, mock_api, _git_state):
- mock_api.return_value = [{
+ def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, _git_state):
+ self.mock_dup_fetcher.return_value = ([{
"number": 200,
"head": {"ref": "feat/other-branch"},
"title": "Some PR",
- "body": "fixes #196"
- }]
+ "body": "fixes #196",
+ }], [], {"status": "not_claimed"})
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
- self.assertIn("already tied to an open PR", str(ctx.exception))
+ self.assertIn("open PR #200 already covers issue", str(ctx.exception))
@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_reused_by_remote_branch(self, _auth, mock_api, _git_state):
- mock_api.side_effect = [
+ def test_lock_issue_reused_by_remote_branch(self, _auth, _git_state):
+ self.mock_dup_fetcher.return_value = (
[],
- [{"name": "feat/issue-196-existing-work"}],
- ]
+ ["feat/issue-196-existing-work"],
+ {"status": "not_claimed"},
+ )
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
- self.assertIn("already has matching branch", str(ctx.exception))
+ self.assertIn("remote branch(es) already match issue pattern", str(ctx.exception))
def test_lock_issue_blocks_active_same_operation_lease(self):
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
From 06c20692348214c8fc2cf4bccbcd0a5dc500ae6f Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:07:22 -0400
Subject: [PATCH 20/42] feat: add per-PR reviewer leases for parallel review
(Closes #407)
Structured PR-thread leases with acquire/heartbeat MCP tools and
mutation-time enforcement so reviewers cannot race the same PR queue.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 284 +++++++++++++
reviewer_pr_lease.py | 382 ++++++++++++++++++
.../workflows/review-merge-pr.md | 20 +
tests/test_reviewer_pr_lease.py | 193 +++++++++
4 files changed, 879 insertions(+)
create mode 100644 reviewer_pr_lease.py
create mode 100644 tests/test_reviewer_pr_lease.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 17938e3..cd24d8d 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -543,6 +543,7 @@ import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
+import reviewer_pr_lease # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
@@ -2016,6 +2017,7 @@ def init_review_decision_lock(remote: str | None, task: str | None):
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
or profile_name
)
+ reviewer_pr_lease.clear_session_lease()
_save_review_decision_lock({
"task": task,
"remote": remote,
@@ -2458,6 +2460,20 @@ def _evaluate_pr_review_submission(
result["permission_report"] = elig["permission_report"]
return result
+ if live:
+ reasons.extend(_reviewer_pr_lease_gate(
+ pr_number=pr_number,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ mutation=action,
+ live_head_sha=result.get("head_sha"),
+ pinned_head_sha=expected_head_sha,
+ ))
+ if reasons:
+ return result
+
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
@@ -3257,6 +3273,19 @@ def gitea_merge_pr(
result["permission_report"] = elig["permission_report"]
return result
+ reasons.extend(_reviewer_pr_lease_gate(
+ pr_number=pr_number,
+ remote=remote,
+ host=host,
+ org=org,
+ repo=repo,
+ mutation="merge",
+ live_head_sha=result.get("head_sha"),
+ pinned_head_sha=expected_head_sha,
+ ))
+ if reasons:
+ return result
+
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
actual_sha = result["head_sha"]
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
@@ -4556,6 +4585,261 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
return blocked
+def _fetch_pr_comments(
+ pr_number: int,
+ *,
+ remote: str,
+ host: str | None,
+ org: str | None,
+ repo: str | None,
+) -> list[dict]:
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
+ return api_request("GET", api, auth) or []
+
+
+def _reviewer_pr_lease_gate(
+ *,
+ pr_number: int,
+ remote: str,
+ host: str | None,
+ org: str | None,
+ repo: str | None,
+ mutation: str,
+ live_head_sha: str | None,
+ pinned_head_sha: str | None,
+) -> list[str]:
+ """Return block reasons when the session lacks an owned PR reviewer lease."""
+ session = reviewer_pr_lease.get_session_lease()
+ session_id = (session or {}).get("session_id")
+ identity = _authenticated_username(remote) or ""
+ try:
+ comments = _fetch_pr_comments(
+ pr_number, remote=remote, host=host, org=org, repo=repo)
+ except Exception as exc:
+ return [f"cannot fetch PR comments for lease gate: {_redact(str(exc))}"]
+ assessment = reviewer_pr_lease.assess_mutation_lease_gate(
+ pr_number=pr_number,
+ comments=comments,
+ reviewer_identity=identity,
+ session_id=session_id,
+ mutation=mutation,
+ live_head_sha=live_head_sha,
+ pinned_head_sha=pinned_head_sha,
+ )
+ return list(assessment.get("reasons") or []) if assessment.get("block") else []
+
+
+@mcp.tool()
+def gitea_acquire_reviewer_pr_lease(
+ pr_number: int,
+ worktree: str,
+ candidate_head: str | None = None,
+ target_branch: str = "master",
+ target_branch_sha: str | None = None,
+ issue_number: int | None = None,
+ session_id: str | None = None,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
+ read_block = _profile_operation_gate("gitea.read")
+ if read_block:
+ return {
+ "success": False,
+ "acquired": False,
+ "reasons": read_block,
+ "permission_report": _permission_block_report("gitea.read"),
+ }
+ comment_block = _profile_operation_gate("gitea.pr.comment")
+ if comment_block:
+ return {
+ "success": False,
+ "acquired": False,
+ "reasons": comment_block,
+ "permission_report": _permission_block_report("gitea.pr.comment"),
+ }
+
+ verify_preflight_purity(remote)
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ profile = get_profile()
+ identity = _authenticated_username(remote) or profile.get("username") or ""
+ sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
+ repo_label = f"{o}/{r}"
+
+ comments = _fetch_pr_comments(
+ pr_number, remote=remote, host=host, org=org, repo=repo)
+ assessment = reviewer_pr_lease.assess_acquire_lease(
+ comments,
+ pr_number=pr_number,
+ reviewer_identity=identity,
+ profile=profile.get("profile_name") or "unknown",
+ session_id=sid,
+ repo=repo_label,
+ issue_number=issue_number,
+ worktree=worktree,
+ candidate_head=candidate_head,
+ target_branch=target_branch,
+ target_branch_sha=target_branch_sha,
+ )
+ if not assessment.get("acquire_allowed"):
+ return {
+ "success": False,
+ "acquired": False,
+ "reasons": assessment.get("reasons") or [],
+ "existing_lease": assessment.get("existing_lease"),
+ }
+
+ body = assessment["lease_body"]
+ comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
+ with _audited(
+ "comment_pr",
+ host=h,
+ remote=remote,
+ org=o,
+ repo=r,
+ pr_number=pr_number,
+ request_metadata={"source": "acquire_reviewer_pr_lease"},
+ ):
+ posted = api_request("POST", comment_url, auth, {"body": body})
+
+ session_lease = reviewer_pr_lease.record_session_lease({
+ "pr_number": pr_number,
+ "issue_number": issue_number,
+ "session_id": sid,
+ "reviewer_identity": identity,
+ "profile": profile.get("profile_name"),
+ "worktree": worktree,
+ "phase": "claimed",
+ "candidate_head": candidate_head,
+ "target_branch": target_branch,
+ "target_branch_sha": target_branch_sha,
+ "repo": repo_label,
+ "comment_id": posted.get("id"),
+ })
+ return {
+ "success": True,
+ "acquired": True,
+ "pr_number": pr_number,
+ "session_id": sid,
+ "comment_id": posted.get("id"),
+ "session_lease": session_lease,
+ "reasons": [],
+ }
+
+
+@mcp.tool()
+def gitea_heartbeat_reviewer_pr_lease(
+ pr_number: int,
+ phase: str,
+ worktree: str | None = None,
+ candidate_head: str | None = None,
+ target_branch_sha: str | None = None,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Post a reviewer lease heartbeat / phase update on the PR thread (#407)."""
+ comment_block = _profile_operation_gate("gitea.pr.comment")
+ if comment_block:
+ return {
+ "success": False,
+ "posted": False,
+ "reasons": comment_block,
+ "permission_report": _permission_block_report("gitea.pr.comment"),
+ }
+ session = reviewer_pr_lease.get_session_lease()
+ if not session or session.get("pr_number") != pr_number:
+ return {
+ "success": False,
+ "posted": False,
+ "reasons": [
+ f"no in-session lease for PR #{pr_number}; acquire first "
+ "(fail closed)"
+ ],
+ }
+
+ verify_preflight_purity(remote)
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ body = reviewer_pr_lease.format_lease_body(
+ repo=f"{o}/{r}",
+ pr_number=pr_number,
+ issue_number=session.get("issue_number"),
+ reviewer_identity=session.get("reviewer_identity") or "",
+ profile=session.get("profile") or "unknown",
+ session_id=session.get("session_id") or reviewer_pr_lease.new_session_id(),
+ worktree=worktree or session.get("worktree") or "",
+ phase=phase,
+ candidate_head=candidate_head or session.get("candidate_head"),
+ target_branch=session.get("target_branch") or "master",
+ target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
+ )
+ comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
+ with _audited(
+ "comment_pr",
+ host=h,
+ remote=remote,
+ org=o,
+ repo=r,
+ pr_number=pr_number,
+ request_metadata={"source": "heartbeat_reviewer_pr_lease", "phase": phase},
+ ):
+ posted = api_request("POST", comment_url, auth, {"body": body})
+
+ updated = reviewer_pr_lease.record_session_lease({
+ **session,
+ "phase": phase,
+ "worktree": worktree or session.get("worktree"),
+ "candidate_head": candidate_head or session.get("candidate_head"),
+ "target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
+ "last_comment_id": posted.get("id"),
+ })
+ return {
+ "success": True,
+ "posted": True,
+ "pr_number": pr_number,
+ "phase": phase,
+ "comment_id": posted.get("id"),
+ "session_lease": updated,
+ "reasons": [],
+ }
+
+
+@mcp.tool()
+def gitea_assess_reviewer_pr_lease(
+ pr_number: int,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Read-only: assess active reviewer lease state for a PR (#407)."""
+ read_block = _profile_operation_gate("gitea.read")
+ if read_block:
+ return {
+ "success": False,
+ "reasons": read_block,
+ "permission_report": _permission_block_report("gitea.read"),
+ }
+ comments = _fetch_pr_comments(
+ pr_number, remote=remote, host=host, org=org, repo=repo)
+ active = reviewer_pr_lease.find_active_reviewer_lease(
+ comments, pr_number=pr_number)
+ return {
+ "success": True,
+ "pr_number": pr_number,
+ "active_lease": active,
+ "session_lease": reviewer_pr_lease.get_session_lease(),
+ "reasons": [],
+ }
+
+
@mcp.tool()
def gitea_list_issue_comments(
issue_number: int,
diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py
new file mode 100644
index 0000000..6e28be5
--- /dev/null
+++ b/reviewer_pr_lease.py
@@ -0,0 +1,382 @@
+"""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 = ""
+
+_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,
+ now: datetime | None = None,
+) -> dict[str, Any]:
+ """Fail closed when another session holds an active lease."""
+ now = now or datetime.now(timezone.utc)
+ reasons: list[str] = []
+ existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
+ 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,
+ }
+
+
+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,
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 5b0e10a..4f9f6eb 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -732,6 +732,26 @@ The final report must identify:
* whether same-PR merge continuation was allowed
* 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.
+
## 27. Merge rules
Before merge, rerun fresh live checks:
diff --git a/tests/test_reviewer_pr_lease.py b/tests/test_reviewer_pr_lease.py
new file mode 100644
index 0000000..69ee6d5
--- /dev/null
+++ b/tests/test_reviewer_pr_lease.py
@@ -0,0 +1,193 @@
+"""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()
\ No newline at end of file
From 1830360850520e49a537bd4d9ec3bf730c20ce1b Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 16:32:23 -0400
Subject: [PATCH 21/42] test: fix reviewer lease gate regressions in MCP
integration tests (#407)
Align merge/review/audit mocks with the per-PR reviewer lease gate introduced
in #407: install owned session leases after init_review_decision_lock, stub
_fetch_pr_comments and _authenticated_username to preserve mock ordering, and
update head-SHA mismatch expectations for lease-first fail-closed paths.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_audit.py | 32 ++++++-
tests/test_mcp_server.py | 139 ++++++++++++++++++++++++++++---
tests/test_pr_queue_inventory.py | 19 ++++-
3 files changed, 172 insertions(+), 18 deletions(-)
diff --git a/tests/test_audit.py b/tests/test_audit.py
index adb9242..11cecd3 100644
--- a/tests/test_audit.py
+++ b/tests/test_audit.py
@@ -286,6 +286,21 @@ class TestSimpleToolAudit(_AuditWiringBase):
class TestGatedToolAudit(_AuditWiringBase):
+ def setUp(self):
+ super().setUp()
+ from tests.test_mcp_server import _install_owned_reviewer_lease
+ import reviewer_pr_lease
+
+ 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)
+
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {"user": {"login": author}, "state": state,
"head": {"sha": sha}, "mergeable": mergeable}
@@ -344,11 +359,22 @@ class TestGatedToolAudit(_AuditWiringBase):
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
+ from tests.test_mcp_server import _install_owned_reviewer_lease
+ import reviewer_pr_lease
+
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(8, "approve", remote="prgs")
- r = gitea_submit_pr_review(pr_number=8, action="approve",
- body="LGTM", remote="prgs",
- final_review_decision_ready=True)
+ lease_patch = _install_owned_reviewer_lease(8)
+ lease_patch.start()
+ try:
+ r = gitea_submit_pr_review(
+ pr_number=8, action="approve",
+ body="LGTM", remote="prgs",
+ final_review_decision_ready=True,
+ )
+ finally:
+ lease_patch.stop()
+ reviewer_pr_lease.clear_session_lease()
self.assertTrue(r["performed"])
recs = self._records()
self.assertEqual(len(recs), 1)
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index aabb7e7..d04cf38 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -79,6 +79,64 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
return [_formal_review(reviewer, "APPROVED", sha=sha)]
+_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
+
+
+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,
+ )
+ ],
+ )
+
+
# Issue-write tools are profile-gated (#69).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
@@ -539,6 +597,19 @@ class TestViewPR(unittest.TestCase):
class TestMergePR(unittest.TestCase):
"""Gated merge workflow (#16). gitea_merge_pr is the only merge path."""
+ def setUp(self):
+ import reviewer_pr_lease
+
+ 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)
+
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {
"user": {"login": author},
@@ -799,9 +870,11 @@ class TestMergePR(unittest.TestCase):
pr_number=8, confirmation=self._confirm(8),
expected_head_sha="deadbeef", remote="prgs")
self.assertFalse(r["performed"])
- self.assertIn(
- "expected head SHA does not match current PR head (fail closed)",
- r["reasons"])
+ self.assertTrue(any(
+ "expected head SHA does not match current PR head (fail closed)" in reason
+ or "PR head changed during lease" in reason
+ for reason in r["reasons"]
+ ))
self._assert_no_merge_call(mock_api)
@patch("mcp_server.api_request")
@@ -1680,7 +1753,20 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
}
def setUp(self):
+ import reviewer_pr_lease
+
init_review_decision_lock("prgs", "review_pr")
+ 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)
def _env(self):
return patch.dict(os.environ, {
@@ -1775,8 +1861,19 @@ class TestSubmitPrReview(unittest.TestCase):
"""Gated review-mutation tool (#15)."""
def setUp(self):
+ import reviewer_pr_lease
+
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(8, "approve", remote="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.addCleanup(self._auth_identity_patch.stop)
+ self.addCleanup(self._lease_patch.stop)
+ self.addCleanup(reviewer_pr_lease.clear_session_lease)
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {
@@ -2014,9 +2111,11 @@ class TestSubmitPrReview(unittest.TestCase):
final_review_decision_ready=True,
)
self.assertFalse(r["performed"])
- self.assertIn(
- "expected head SHA does not match current PR head (fail closed)",
- r["reasons"])
+ self.assertTrue(any(
+ "expected head SHA does not match current PR head (fail closed)" in reason
+ or "PR head changed during lease" in reason
+ for reason in r["reasons"]
+ ))
self._assert_no_mutation(mock_api)
def test_head_sha_match_allows(self):
@@ -2079,9 +2178,9 @@ class TestSubmitPrReview(unittest.TestCase):
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
- gitea_mark_final_review_decision(5, "approve", remote="prgs")
+ gitea_mark_final_review_decision(8, "approve", remote="prgs")
r = gitea_submit_pr_review(
- pr_number=5, action="approve", remote="prgs",
+ pr_number=8, action="approve", remote="prgs",
final_review_decision_ready=True,
)
self.assertFalse(r["performed"])
@@ -2300,6 +2399,13 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
self.assertEqual(res["cleanup_status"].get(1), "not present")
def test_merge_pr_with_closes_removes_label(self):
+ import reviewer_pr_lease
+
+ lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123")
+ 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):
if method == "GET" and "/user" in url:
return {"login": "merger"}
@@ -2334,6 +2440,13 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
self.assertEqual(res["cleanup_status"].get(123), "released")
def test_merge_pr_with_branch_name_removes_label(self):
+ import reviewer_pr_lease
+
+ lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123")
+ 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):
if method == "GET" and "/user" in url:
return {"login": "merger"}
@@ -3015,10 +3128,12 @@ class TestVerifyMutationAuthority(unittest.TestCase):
# profile; the active profile resolves as reviewer — side-channel
# override rejected even with a matching in-process authority.
self._authority()
- with patch.dict(os.environ,
- {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}):
- with self.assertRaises(RuntimeError) as ctx:
- mcp_server.verify_mutation_authority("prgs")
+ with patch("mcp_server.gitea_config.is_runtime_switching_enabled",
+ return_value=False):
+ with patch.dict(os.environ,
+ {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}):
+ with self.assertRaises(RuntimeError) as ctx:
+ mcp_server.verify_mutation_authority("prgs")
self.assertIn("side-channel override rejected", str(ctx.exception))
def test_foreign_pid_authority_is_not_trusted(self):
diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py
index aeb8bc4..1e5a3c5 100644
--- a/tests/test_pr_queue_inventory.py
+++ b/tests/test_pr_queue_inventory.py
@@ -156,9 +156,22 @@ class TestPRQueueInventory(unittest.TestCase):
]
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(1, "approve", remote="prgs")
- result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
+ from tests.test_mcp_server import _install_owned_reviewer_lease
+ import reviewer_pr_lease
+
+ with patch("mcp_server._authenticated_username", return_value="reviewer1"):
+ init_review_decision_lock("prgs", "review_pr")
+ gitea_mark_final_review_decision(1, "approve", remote="prgs")
+ lease_patch = _install_owned_reviewer_lease(
+ 1, head_sha="abc1", 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.assertIn("=== PR Queue Inventory ===", result["message"])
self.assertIn("Repository:", result["message"])
From 3e4b721d60e97147ba0704773cf57cd0d42cbe31 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:18:00 -0400
Subject: [PATCH 22/42] test: align duplicate gate lock fixtures with #447
provenance (#407)
Rebase onto master (#413/#463) requires sanctioned lock_provenance in
create_pr duplicate-recheck tests.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_issue_work_duplicate_gate.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py
index 6e9ea5a..ac4ccea 100644
--- a/tests/test_issue_work_duplicate_gate.py
+++ b/tests/test_issue_work_duplicate_gate.py
@@ -9,6 +9,7 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+import issue_lock_provenance
import issue_work_duplicate_gate as dup_gate
import mcp_server
from issue_work_duplicate_gate import (
@@ -172,11 +173,23 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
self._dir.cleanup()
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
+ work_lease = {
+ "operation_type": "author_issue_work",
+ "issue_number": issue_number,
+ "branch": branch,
+ "claimant": {"username": "test-user", "profile": "test-author"},
+ "expires_at": "2999-01-01T00:00:00Z",
+ }
with open(self.lock_path, "w", encoding="utf-8") as fh:
json.dump({
"issue_number": issue_number,
"branch_name": branch,
"remote": "prgs",
+ "work_lease": work_lease,
+ "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
+ tool="gitea_lock_issue",
+ claimant=work_lease.get("claimant"),
+ ),
}, fh)
@patch("mcp_server._assess_issue_duplicate_gate")
From d5dc9f2708f1e8b3f023807212e13527aa939067 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 11:41:02 -0400
Subject: [PATCH 23/42] feat: require post-merge cleanup safety-gate proof in
reviewer reports (Closes #402)
Adds post_merge_cleanup_proof validation so cleanup claims must carry the
branch deletion and worktree removal checklists, or report CLEANUP_SKIPPED
with an exact blocker.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
final_report_validator.py | 16 ++
post_merge_cleanup_proof.py | 239 ++++++++++++++++++
.../workflows/review-merge-pr.md | 34 +++
tests/test_post_merge_cleanup_proof.py | 169 +++++++++++++
4 files changed, 458 insertions(+)
create mode 100644 post_merge_cleanup_proof.py
create mode 100644 tests/test_post_merge_cleanup_proof.py
diff --git a/final_report_validator.py b/final_report_validator.py
index bbc4b6c..06ed0e8 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -12,6 +12,7 @@ import re
from typing import Any, Callable
import issue_lock_provenance
+from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -938,6 +939,20 @@ 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 = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
@@ -965,6 +980,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_target_branch_freshness,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
+ _rule_reviewer_post_merge_cleanup_proof,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
diff --git a/post_merge_cleanup_proof.py b/post_merge_cleanup_proof.py
new file mode 100644
index 0000000..e1c156d
--- /dev/null
+++ b/post_merge_cleanup_proof.py
@@ -0,0 +1,239 @@
+"""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)(.+)",
+ 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"
+ ),
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 5b0e10a..8851a20 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -797,6 +797,40 @@ 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.
+## 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
If blocked, produce a recovery handoff with:
diff --git a/tests/test_post_merge_cleanup_proof.py b/tests/test_post_merge_cleanup_proof.py
new file mode 100644
index 0000000..10e5bf6
--- /dev/null
+++ b/tests/test_post_merge_cleanup_proof.py
@@ -0,0 +1,169 @@
+"""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()
\ No newline at end of file
From 44a7c62dc3648291bb68bc5080fc01d548607ce7 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 13:07:57 -0400
Subject: [PATCH 24/42] fix: resolve conflicts for PR #415
Rebase onto current prgs/master and fix cleanup-mutations regex so
"Cleanup mutations: none" does not false-positive the proof checklist.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
post_merge_cleanup_proof.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/post_merge_cleanup_proof.py b/post_merge_cleanup_proof.py
index e1c156d..6562a04 100644
--- a/post_merge_cleanup_proof.py
+++ b/post_merge_cleanup_proof.py
@@ -209,7 +209,7 @@ def assess_post_merge_cleanup_proof(
if not remote_delete and not worktree_remove:
cleanup_mutations = re.search(
- r"cleanup mutations\s*:\s*(?!none\b)(.+)",
+ r"cleanup mutations\s*:\s*(?!none\b)\S",
text,
re.IGNORECASE,
)
From ddaf380db2c674f4dadaeaed1fadbe4488bc31f9 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 12:03:08 -0400
Subject: [PATCH 25/42] feat: gate reconciliation audit mode from unauthorized
cleanup (Closes #419)
Add audit vs cleanup phase tracking so reconciliation audits stay read-only
unless cleanup is explicitly authorized with delete capability proof, safety
proof, and before/after snapshots. Block gitea_delete_branch and merged-cleanup
execution during audit phase, validate final reports for false no-mutations
claims, and document the boundary in the reconcile-landed-pr workflow.
---
audit_reconciliation_mode.py | 344 ++++++++++++++++++
final_report_validator.py | 17 +
gitea_mcp_server.py | 75 ++++
review_proofs.py | 9 +
.../workflows/reconcile-landed-pr.md | 34 ++
task_capability_map.py | 4 +
tests/test_audit_reconciliation_mode.py | 291 +++++++++++++++
tests/test_llm_workflow_split.py | 8 +
8 files changed, 782 insertions(+)
create mode 100644 audit_reconciliation_mode.py
create mode 100644 tests/test_audit_reconciliation_mode.py
diff --git a/audit_reconciliation_mode.py b/audit_reconciliation_mode.py
new file mode 100644
index 0000000..52d0244
--- /dev/null
+++ b/audit_reconciliation_mode.py
@@ -0,0 +1,344 @@
+"""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, []
\ No newline at end of file
diff --git a/final_report_validator.py b/final_report_validator.py
index bbc4b6c..0dae1a2 100644
--- a/final_report_validator.py
+++ b/final_report_validator.py
@@ -919,6 +919,22 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
)
+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(
report_text: str,
*,
@@ -977,6 +993,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
+ _rule_audit_reconciliation_boundary,
],
"author_issue": [
_rule_shared_controller_handoff,
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 17938e3..2fd613c 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -546,6 +546,7 @@ import issue_work_duplicate_gate # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
+import audit_reconciliation_mode # noqa: E402
import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402
@@ -3680,6 +3681,18 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
+ audit_allowed, audit_reasons = (
+ audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
+ )
+ if not audit_allowed:
+ return {
+ "success": False,
+ "performed": False,
+ "required_permission": "gitea.branch.delete",
+ "reasons": audit_reasons,
+ "audit_phase": audit_reconciliation_mode.current_phase(),
+ }
+
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -3749,6 +3762,18 @@ def gitea_reconcile_merged_cleanups(
"execute_confirmed must be True when dry_run=False (fail closed)"
)
+ if not dry_run:
+ exec_allowed, exec_reasons = (
+ audit_reconciliation_mode.check_cleanup_execution_allowed()
+ )
+ if not exec_allowed:
+ return {
+ "success": False,
+ "performed": False,
+ "reasons": exec_reasons,
+ "audit_phase": audit_reconciliation_mode.current_phase(),
+ }
+
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -3831,6 +3856,53 @@ def gitea_reconcile_merged_cleanups(
return {"success": True, "performed": True, **report}
+@mcp.tool()
+def gitea_authorize_reconciliation_cleanup_phase(
+ operator_approved: bool = False,
+ workflow_authorized: bool = False,
+ delete_capability_proven: bool = False,
+ safe_to_delete_remote: bool = False,
+ safe_to_remove_worktree: bool = False,
+ before_state: str = "",
+ after_state: str = "",
+) -> dict:
+ """Authorize cleanup phase after audit-only reconciliation (#419).
+
+ Requires operator or workflow approval, exact delete_branch capability proof,
+ branch/worktree safety proof, and before/after state snapshots. Audit phase
+ forbids branch deletion, worktree removal, pushes, and issue/PR mutations.
+ """
+ delete_gate = _profile_operation_gate("gitea.branch.delete")
+ capability_ok = not bool(delete_gate)
+ if delete_capability_proven and delete_gate:
+ return {
+ "authorized": False,
+ "performed": False,
+ "delete_capability_verified": False,
+ "reasons": [
+ "delete_capability_proven=true but active profile lacks "
+ "gitea.branch.delete",
+ ] + delete_gate,
+ "audit_phase": audit_reconciliation_mode.current_phase(),
+ }
+ result = audit_reconciliation_mode.authorize_cleanup_phase(
+ operator_approved=operator_approved,
+ workflow_authorized=workflow_authorized,
+ delete_capability_proven=delete_capability_proven and capability_ok,
+ safety_proof={
+ "safe_to_delete_remote": safe_to_delete_remote,
+ "safe_to_remove_worktree": safe_to_remove_worktree,
+ },
+ before_after_snapshot={
+ "before": (before_state or "").strip(),
+ "after": (after_state or "").strip(),
+ },
+ )
+ result["performed"] = bool(result.get("authorized"))
+ result["delete_capability_verified"] = capability_ok
+ return result
+
+
@mcp.tool()
def gitea_assess_already_landed_reconciliation(
pr_number: int,
@@ -6937,6 +7009,9 @@ def gitea_resolve_task_capability(
if reason_msg:
result["reason"] = reason_msg
role_session_router.sync_route_from_capability(result)
+ if audit_reconciliation_mode.check_audit_task_enters_phase(task):
+ phase_record = audit_reconciliation_mode.enter_audit_phase(task)
+ result["reconciliation_phase"] = phase_record.get("phase")
was_terminal = capability_stop_terminal.is_active()
terminal = capability_stop_terminal.sync_from_capability_result(result)
if terminal:
diff --git a/review_proofs.py b/review_proofs.py
index b74ebb8..9a37277 100644
--- a/review_proofs.py
+++ b/review_proofs.py
@@ -5115,6 +5115,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
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)
_NOT_APPLICABLE_VALUE = re.compile(
diff --git a/skills/llm-project-workflow/workflows/reconcile-landed-pr.md b/skills/llm-project-workflow/workflows/reconcile-landed-pr.md
index 10ba60d..a9f4cd1 100644
--- a/skills/llm-project-workflow/workflows/reconcile-landed-pr.md
+++ b/skills/llm-project-workflow/workflows/reconcile-landed-pr.md
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
* include safe next action (profile switch, human close, or dedicated reconciler
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
Use precise mutation categories in the final report:
diff --git a/task_capability_map.py b/task_capability_map.py
index 1d40a78..9e7d0ba 100644
--- a/task_capability_map.py
+++ b/task_capability_map.py
@@ -104,6 +104,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read",
"role": "author",
},
+ "reconciliation_cleanup": {
+ "permission": "gitea.branch.delete",
+ "role": "author",
+ },
"work_issue": {
"permission": "gitea.pr.create",
"role": "author",
diff --git a/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py
new file mode 100644
index 0000000..a117fab
--- /dev/null
+++ b/tests/test_audit_reconciliation_mode.py
@@ -0,0 +1,291 @@
+"""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()
\ No newline at end of file
diff --git a/tests/test_llm_workflow_split.py b/tests/test_llm_workflow_split.py
index 44d80ce..d5f1fcb 100644
--- a/tests/test_llm_workflow_split.py
+++ b/tests/test_llm_workflow_split.py
@@ -87,6 +87,14 @@ def test_reconcile_landed_workflow_contract():
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
assert "RECOVERY_HANDOFF_ONLY" 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():
From 551ee7d70c7968d3c43015084faa13b0b56a46e2 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:20:53 -0400
Subject: [PATCH 26/42] fix: resolve conflicts for PR #421
From d042a9ca244fe2944a94727df80290570a39325d Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 16:27:10 -0400
Subject: [PATCH 27/42] feat: replace global issue lock with keyed persistent
store (Closes #443)
Store per remote/org/repo/issue locks under GITEA_ISSUE_LOCK_DIR with
atomic writes and per-session binding. Integrate own-branch adoption for
lock recovery, update worktree-start and cleanup reconcile, and add tests
documenting the ban on manual global lock seeding.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/llm-workflow-runbooks.md | 24 +-
gitea_mcp_server.py | 150 ++++++++---
issue_lock_adoption.py | 116 +++++++++
issue_lock_store.py | 385 +++++++++++++++++++++++++++++
merged_cleanup_reconcile.py | 24 +-
scripts/worktree-start | 16 +-
tests/test_agent_temp_artifacts.py | 8 +-
tests/test_commit_payloads.py | 12 +-
tests/test_issue_lock_adoption.py | 68 +++++
tests/test_issue_lock_store.py | 181 ++++++++++++++
tests/test_mcp_server.py | 262 ++++++++++++++------
tests/test_worktrees.py | 35 ++-
12 files changed, 1129 insertions(+), 152 deletions(-)
create mode 100644 issue_lock_adoption.py
create mode 100644 issue_lock_store.py
create mode 100644 tests/test_issue_lock_adoption.py
create mode 100644 tests/test_issue_lock_store.py
diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md
index 09b4d5c..634e22c 100644
--- a/docs/llm-workflow-runbooks.md
+++ b/docs/llm-workflow-runbooks.md
@@ -274,12 +274,24 @@ is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
-records an `author_issue_work` lease in the issue-lock payload with issue
-number, optional PR number, branch, worktree path, claimant identity/profile,
-created timestamp, expiry timestamp, and last heartbeat timestamp. An active
-same-issue/same-operation lease blocks duplicate work. An expired lease still
-blocks takeover until a recovery review records why the prior work is abandoned,
-completed, or unsafe to continue.
+records an `author_issue_work` lease in a keyed lock file under
+`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
+per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
+its active lock through a per-process pointer so concurrent repos/issues never
+share one overwrite-prone slot (#443).
+
+Each lock payload includes issue number, optional PR number, branch, worktree
+path, claimant identity/profile, created timestamp, expiry timestamp, and last
+heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
+work. An expired lease still blocks takeover until a recovery review records why
+the prior work is abandoned, completed, or unsafe to continue.
+
+**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
+recovery path.** That global slot is deprecated and can clobber unrelated live
+leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
+adoption rebinds the session when the issue's exact branch already exists (#442).
+`gitea_create_pr` resolves the durable keyed lock by session pointer or by
+matching `head` branch without unsafe manual seeding.
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index cd24d8d..50c9c77 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -539,6 +539,8 @@ import review_proofs # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
+import issue_lock_store # noqa: E402
+import issue_lock_adoption # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
@@ -551,8 +553,9 @@ import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402
-# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
-# consumed by gitea_create_pr and scripts/worktree-start.
+# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
+# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
+# Legacy global path retained only for test/doc references — do not seed manually.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
@@ -584,15 +587,59 @@ def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
return None
-def _load_existing_issue_lock() -> dict | None:
- if not os.path.exists(ISSUE_LOCK_FILE):
- return None
+def _load_existing_issue_lock(
+ *,
+ remote: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+ issue_number: int | None = None,
+) -> dict | None:
+ if remote and org and repo and issue_number is not None:
+ return issue_lock_store.load_issue_lock(
+ remote=remote,
+ org=org,
+ repo=repo,
+ issue_number=issue_number,
+ )
+ return issue_lock_store.read_session_issue_lock()
+
+
+def _resolve_issue_lock_for_pr(
+ *,
+ remote: str,
+ org: str,
+ repo: str,
+ head: str,
+) -> dict:
+ lock_data = issue_lock_store.read_session_issue_lock()
+ if not lock_data:
+ lock_data = issue_lock_store.find_lock_for_branch(
+ remote=remote,
+ org=org,
+ repo=repo,
+ branch_name=head,
+ )
+ if not lock_data:
+ raise RuntimeError(
+ "Issue lock is missing (fail closed). Call gitea_lock_issue first."
+ )
+ return lock_data
+
+
+def _save_issue_lock(data: dict) -> str:
+ existing = issue_lock_store.load_issue_lock(
+ remote=str(data.get("remote") or ""),
+ org=str(data.get("org") or ""),
+ repo=str(data.get("repo") or ""),
+ issue_number=int(data.get("issue_number") or 0),
+ )
+ overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data)
+ if overwrite_block:
+ raise RuntimeError(overwrite_block)
try:
- with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
- data = json.load(f)
- return data if isinstance(data, dict) else None
- except Exception:
- return None
+ return issue_lock_store.bind_session_lock(data)
+ except Exception as e:
+ raise RuntimeError(f"Could not write issue lock file: {e}") from e
def _work_lease_claimant(host: str | None) -> dict:
@@ -795,6 +842,19 @@ def _enforce_locked_issue_duplicate_recheck(
return None
+def _branch_entry_commit_sha(branch: dict | str) -> str | None:
+ """Best-effort head SHA for a Gitea branch entry (None when absent)."""
+ if not isinstance(branch, dict):
+ return None
+ commit = branch.get("commit")
+ if isinstance(commit, dict):
+ sha = commit.get("id") or commit.get("sha")
+ if sha:
+ return str(sha)
+ sha = branch.get("commit_sha")
+ return str(sha) if sha else None
+
+
def _reveal_endpoints() -> bool:
"""Admin/debug opt-in (#120): include endpoint URLs and token source
names in tool output. Off by default so normal LLM-facing responses
@@ -1242,8 +1302,9 @@ def gitea_lock_issue(
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, PROJECT_ROOT
)
- active_lease_block = _active_work_lease_block(
- _load_existing_issue_lock(),
+ h, o, r = _resolve(remote, host, org, repo)
+ active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
+ _load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
@@ -1267,7 +1328,6 @@ def gitea_lock_issue(
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
)
- h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
duplicate_gate = _assess_issue_duplicate_gate(
issue_number,
@@ -1283,6 +1343,30 @@ def gitea_lock_issue(
f"duplicate work gate blocked issue #{issue_number} (fail closed)"
]))
+ branch_url = f"{repo_api_url(h, o, r)}/branches"
+ try:
+ branches = api_get_all(branch_url, auth)
+ except Exception as e:
+ raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
+ existing_branch_entries = [
+ {
+ "name": _branch_entry_name(branch),
+ "commit_sha": _branch_entry_commit_sha(branch),
+ }
+ for branch in branches
+ ]
+ adoption = issue_lock_adoption.assess_own_branch_adoption(
+ issue_number=issue_number,
+ requested_branch=branch_name,
+ existing_branches=existing_branch_entries,
+ )
+ if adoption["block"]:
+ competing = ", ".join(adoption["competing_branches"])
+ raise ValueError(
+ f"Issue #{issue_number} already has matching branch '{competing}' "
+ "that is not the requested branch (fail closed)"
+ )
+
work_lease = _build_author_issue_work_lease(
issue_number=issue_number,
branch_name=branch_name,
@@ -1303,11 +1387,7 @@ def gitea_lock_issue(
),
}
- try:
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(data, f)
- except Exception as e:
- raise RuntimeError(f"Could not write issue lock file: {e}")
+ lock_file_path = _save_issue_lock(data)
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
git_state.get("porcelain_status") or ""
@@ -1322,7 +1402,22 @@ def gitea_lock_issue(
"branch_name": branch_name,
"worktree_path": resolved_worktree,
"work_lease": work_lease,
+ "lock_file_path": lock_file_path,
}
+ if adoption["adopt"]:
+ result["adoption"] = issue_lock_adoption.build_adoption_proof(
+ issue_number=issue_number,
+ branch_name=branch_name,
+ assessment=adoption,
+ open_pr_checked=True,
+ competing_lock_checked=True,
+ lock_file_path=lock_file_path,
+ lock_file_status="written",
+ )
+ result["message"] = (
+ f"Adopted existing branch '{branch_name}' and locked issue "
+ f"#{issue_number} for recovery (fail-closed check complete)."
+ )
if agent_artifacts:
result["warnings"] = [
"Agent temp artifacts at repo root (delete before implementation): "
@@ -1414,15 +1509,8 @@ def gitea_create_pr(
verify_preflight_purity(remote, worktree_path=worktree_path)
h, o, r = _resolve(remote, host, org, repo)
- # ── Issue Lock Validation (Issue #194 / #196) ──
- if not os.path.exists(ISSUE_LOCK_FILE):
- raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
-
- try:
- with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
- lock_data = json.load(f)
- except Exception as e:
- raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
+ # ── Issue Lock Validation (Issue #194 / #196 / #443) ──
+ lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
lock_data
@@ -2956,13 +3044,7 @@ def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[d
processed_files = []
source_proofs = []
- lock_data = {}
- if os.path.exists(ISSUE_LOCK_FILE):
- try:
- with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
- lock_data = json.load(f)
- except Exception:
- pass
+ lock_data = issue_lock_store.read_session_issue_lock() or {}
locked_worktree = lock_data.get("worktree_path")
if locked_worktree:
diff --git a/issue_lock_adoption.py b/issue_lock_adoption.py
new file mode 100644
index 0000000..7c8e5ef
--- /dev/null
+++ b/issue_lock_adoption.py
@@ -0,0 +1,116 @@
+"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
+
+When an issue's own already-pushed branch exists, lock reacquisition must be
+allowed (adoption) instead of being treated as #400 duplicate competing work.
+This module isolates the pure decision so it can be unit-tested apart from the
+MCP server's live Gitea calls.
+
+Adoption is granted only for the issue's *exact* requested branch. Any other
+branch that merely contains the same ``issue-`` marker is competing work and
+stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
+safety checks are enforced by the caller before this decision is consulted;
+this module additionally records whether they passed for proof purposes.
+"""
+
+from __future__ import annotations
+
+ADOPT = "adopt_existing_branch"
+BLOCK_COMPETING = "block_competing_branch"
+NO_MATCH = "no_matching_branch"
+
+
+def _branch_name(entry) -> str:
+ if isinstance(entry, dict):
+ return str(entry.get("name") or "")
+ return str(entry or "")
+
+
+def _branch_sha(entry) -> str | None:
+ if isinstance(entry, dict):
+ sha = entry.get("commit_sha")
+ if sha:
+ return str(sha)
+ return None
+
+
+def assess_own_branch_adoption(
+ *,
+ issue_number: int,
+ requested_branch: str,
+ existing_branches,
+) -> dict:
+ """Decide whether an existing matching branch is adoptable."""
+ marker = f"issue-{issue_number}"
+ requested = (requested_branch or "").strip()
+
+ matches: list[tuple[str, str | None]] = []
+ for entry in existing_branches or []:
+ name = _branch_name(entry).strip()
+ if marker in name:
+ matches.append((name, _branch_sha(entry)))
+
+ competing = sorted({name for name, _ in matches if name != requested})
+ exact = [(name, sha) for name, sha in matches if name == requested]
+
+ if competing:
+ return {
+ "outcome": BLOCK_COMPETING,
+ "adopt": False,
+ "block": True,
+ "reason": (
+ f"issue #{issue_number} already has matching branch(es) "
+ f"{competing} that are not the requested branch "
+ f"'{requested}' (fail closed)"
+ ),
+ "matched_branch": None,
+ "matched_head_sha": None,
+ "competing_branches": competing,
+ }
+
+ if exact:
+ name, sha = exact[0]
+ return {
+ "outcome": ADOPT,
+ "adopt": True,
+ "block": False,
+ "reason": (
+ f"existing branch '{name}' is the exact requested branch for "
+ f"issue #{issue_number}; adopting it for lock recovery"
+ ),
+ "matched_branch": name,
+ "matched_head_sha": sha,
+ "competing_branches": [],
+ }
+
+ return {
+ "outcome": NO_MATCH,
+ "adopt": False,
+ "block": False,
+ "reason": f"no existing branch matches issue #{issue_number}",
+ "matched_branch": None,
+ "matched_head_sha": None,
+ "competing_branches": [],
+ }
+
+
+def build_adoption_proof(
+ *,
+ issue_number: int,
+ branch_name: str,
+ assessment: dict,
+ open_pr_checked: bool,
+ competing_lock_checked: bool,
+ lock_file_path: str,
+ lock_file_status: str,
+) -> dict:
+ """Assemble the proof block returned by ``gitea_lock_issue`` on adoption."""
+ return {
+ "issue_number": issue_number,
+ "branch_name": branch_name,
+ "branch_head_commit": assessment.get("matched_head_sha"),
+ "adoption_reason": assessment.get("reason"),
+ "no_existing_pr_proof": bool(open_pr_checked),
+ "no_competing_live_lock_proof": bool(competing_lock_checked),
+ "lock_file_path": lock_file_path,
+ "lock_file_status": lock_file_status,
+ }
\ No newline at end of file
diff --git a/issue_lock_store.py b/issue_lock_store.py
new file mode 100644
index 0000000..9ede4bb
--- /dev/null
+++ b/issue_lock_store.py
@@ -0,0 +1,385 @@
+"""Keyed, persistent issue-lock storage (#443).
+
+Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
+lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
+``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
+via a per-process pointer file so concurrent repos/issues never clobber each
+other.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import tempfile
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
+DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
+WORK_LEASE_TTL_HOURS = 4
+AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
+
+_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
+
+
+def default_lock_dir() -> str:
+ raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
+ return raw or DEFAULT_LOCK_DIR
+
+
+def _sanitize_segment(value: str) -> str:
+ text = (value or "").strip()
+ if not text:
+ return "_"
+ return _SAFE_SEGMENT_RE.sub("_", text)
+
+
+def lock_key(
+ *,
+ remote: str,
+ org: str,
+ repo: str,
+ issue_number: int,
+) -> str:
+ return "-".join(
+ _sanitize_segment(part)
+ for part in (remote, org, repo, str(issue_number))
+ )
+
+
+def lock_file_path(
+ *,
+ remote: str,
+ org: str,
+ repo: str,
+ issue_number: int,
+ lock_dir: str | None = None,
+) -> str:
+ root = (lock_dir or default_lock_dir()).strip()
+ return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
+
+
+def session_pointer_path(lock_dir: str | None = None) -> str:
+ root = (lock_dir or default_lock_dir()).strip()
+ return os.path.join(root, f"session-{os.getpid()}.json")
+
+
+def _ensure_lock_dir(lock_dir: str | None = None) -> str:
+ root = (lock_dir or default_lock_dir()).strip()
+ os.makedirs(root, mode=0o700, exist_ok=True)
+ return root
+
+
+def read_lock_file(path: str) -> dict[str, Any] | None:
+ lock_path = (path or "").strip()
+ if not lock_path or not os.path.exists(lock_path):
+ return None
+ try:
+ with open(lock_path, encoding="utf-8") as handle:
+ data = json.load(handle)
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def save_lock_file(path: str, data: dict[str, Any]) -> None:
+ lock_path = (path or "").strip()
+ if not lock_path:
+ raise ValueError("lock path is required (fail closed)")
+ parent = os.path.dirname(lock_path) or "."
+ os.makedirs(parent, mode=0o700, exist_ok=True)
+ payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
+ fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temp_path, lock_path)
+ finally:
+ if os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+
+
+def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
+ """Persist a keyed lock and bind it to the current process session."""
+ remote = str(lock_data.get("remote") or "")
+ org = str(lock_data.get("org") or "")
+ repo = str(lock_data.get("repo") or "")
+ issue_number = int(lock_data.get("issue_number") or 0)
+ if not remote or not org or not repo or issue_number <= 0:
+ raise ValueError("lock record must include remote, org, repo, and issue_number")
+
+ root = _ensure_lock_dir(lock_dir)
+ path = lock_file_path(
+ remote=remote,
+ org=org,
+ repo=repo,
+ issue_number=issue_number,
+ lock_dir=root,
+ )
+ record = dict(lock_data)
+ record["lock_file_path"] = path
+ record["session_pid"] = os.getpid()
+ save_lock_file(path, record)
+
+ pointer = {
+ "pid": os.getpid(),
+ "lock_file_path": path,
+ "issue_number": issue_number,
+ "branch_name": record.get("branch_name"),
+ "remote": remote,
+ "org": org,
+ "repo": repo,
+ }
+ save_lock_file(session_pointer_path(root), pointer)
+ return path
+
+
+def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
+ root = (lock_dir or default_lock_dir()).strip()
+ pointer = read_lock_file(session_pointer_path(root))
+ if not pointer:
+ return None
+ lock_path = str(pointer.get("lock_file_path") or "").strip()
+ if not lock_path:
+ return None
+ return read_lock_file(lock_path)
+
+
+def load_issue_lock(
+ *,
+ remote: str,
+ org: str,
+ repo: str,
+ issue_number: int,
+ lock_dir: str | None = None,
+) -> dict[str, Any] | None:
+ return read_lock_file(
+ lock_file_path(
+ remote=remote,
+ org=org,
+ repo=repo,
+ issue_number=issue_number,
+ lock_dir=lock_dir,
+ )
+ )
+
+
+def iter_lock_files(lock_dir: str | None = None) -> list[str]:
+ root = (lock_dir or default_lock_dir()).strip()
+ if not os.path.isdir(root):
+ return []
+ paths: list[str] = []
+ for name in os.listdir(root):
+ if not name.endswith(".json") or name.startswith("session-"):
+ continue
+ paths.append(os.path.join(root, name))
+ return sorted(paths)
+
+
+def find_lock_for_branch(
+ *,
+ remote: str,
+ org: str,
+ repo: str,
+ branch_name: str,
+ lock_dir: str | None = None,
+) -> dict[str, Any] | None:
+ target = (branch_name or "").strip()
+ if not target:
+ return None
+ for path in iter_lock_files(lock_dir):
+ lock = read_lock_file(path)
+ if not lock:
+ continue
+ if (
+ str(lock.get("remote") or "") == remote
+ and str(lock.get("org") or "") == org
+ and str(lock.get("repo") or "") == repo
+ and str(lock.get("branch_name") or "").strip() == target
+ ):
+ lock = dict(lock)
+ lock.setdefault("lock_file_path", path)
+ return lock
+ return None
+
+
+def _lease_now(now: datetime | None = None) -> datetime:
+ return now or datetime.now(timezone.utc)
+
+
+def _parse_lease_timestamp(value: str | None) -> datetime | None:
+ text = (value or "").strip()
+ if not text:
+ return None
+ try:
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
+ except ValueError:
+ return None
+
+
+def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
+ if not lock:
+ return None
+ lease = lock.get("work_lease")
+ if not isinstance(lease, dict):
+ return None
+ return _parse_lease_timestamp(lease.get("expires_at"))
+
+
+def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
+ expires = lease_expires_at(lock)
+ if expires is None:
+ return False
+ return expires <= _lease_now(now)
+
+
+def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
+ if not lock:
+ return False
+ lease = lock.get("work_lease")
+ if not isinstance(lease, dict):
+ return True
+ expires = _parse_lease_timestamp(lease.get("expires_at"))
+ if expires is None:
+ return True
+ return expires > _lease_now(now)
+
+
+def _same_realpath(left: str | None, right: str | None) -> bool:
+ if not left or not right:
+ return False
+ try:
+ return os.path.realpath(left) == os.path.realpath(right)
+ except OSError:
+ return left == right
+
+
+def assess_same_issue_lease_conflict(
+ existing_lock: dict[str, Any] | None,
+ *,
+ issue_number: int,
+ branch_name: str,
+ worktree_path: str,
+ operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
+ now: datetime | None = None,
+) -> str | None:
+ """Return a fail-closed error when a competing live lease blocks acquisition."""
+ if not existing_lock:
+ return None
+
+ existing_issue = existing_lock.get("issue_number")
+ lease = existing_lock.get("work_lease")
+ existing_operation = (
+ lease.get("operation_type")
+ if isinstance(lease, dict)
+ else AUTHOR_ISSUE_WORK_LEASE
+ )
+ if existing_issue != issue_number or existing_operation != operation_type:
+ return None
+
+ existing_branch = existing_lock.get("branch_name")
+ existing_worktree = existing_lock.get("worktree_path")
+ same_owner = (
+ existing_branch == branch_name
+ and _same_realpath(str(existing_worktree or ""), worktree_path)
+ )
+ if is_lease_expired(existing_lock, now=now):
+ return (
+ f"Issue #{issue_number} has an expired {operation_type} lease on "
+ f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
+ "Recovery review is required before takeover (fail closed)"
+ )
+ if same_owner:
+ return None
+ return (
+ f"Issue #{issue_number} already has an active {operation_type} lease on "
+ f"branch '{existing_branch}' from worktree '{existing_worktree}' "
+ "(fail closed)"
+ )
+
+
+def assess_foreign_lock_overwrite(
+ existing_lock: dict[str, Any] | None,
+ incoming_lock: dict[str, Any],
+ *,
+ now: datetime | None = None,
+) -> str | None:
+ """Block writes that would clobber an unrelated live lease on the same key."""
+ if not existing_lock:
+ return None
+
+ same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
+ same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
+ same_worktree = _same_realpath(
+ str(existing_lock.get("worktree_path") or ""),
+ str(incoming_lock.get("worktree_path") or ""),
+ )
+ if same_issue and same_branch and same_worktree:
+ return None
+ if not is_lease_live(existing_lock, now=now):
+ return None
+ return (
+ "Refusing to overwrite a live foreign issue lock "
+ f"(issue #{existing_lock.get('issue_number')}, "
+ f"branch '{existing_lock.get('branch_name')}', "
+ f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
+ )
+
+
+def find_live_lock_for_branch(
+ branch_name: str,
+ lock_dir: str | None = None,
+) -> dict[str, Any] | None:
+ target = (branch_name or "").strip()
+ if not target:
+ return None
+ for path in iter_lock_files(lock_dir):
+ lock = read_lock_file(path)
+ if not lock:
+ continue
+ if str(lock.get("branch_name") or "").strip() != target:
+ continue
+ if not is_lease_live(lock):
+ continue
+ record = dict(lock)
+ record.setdefault("lock_file_path", path)
+ return record
+ return None
+
+
+def resolve_locked_branch_for_session(
+ branch_name: str | None = None,
+ lock_dir: str | None = None,
+) -> str:
+ if branch_name:
+ lock = find_live_lock_for_branch(branch_name, lock_dir)
+ if lock:
+ return str(lock.get("branch_name") or "")
+ lock = read_session_issue_lock(lock_dir)
+ return str((lock or {}).get("branch_name") or "")
+
+
+def has_active_issue_lock(
+ branch: str,
+ *,
+ lock_dir: str | None = None,
+) -> bool:
+ target = (branch or "").strip()
+ if not target:
+ return False
+ for path in iter_lock_files(lock_dir):
+ lock = read_lock_file(path)
+ if not lock:
+ continue
+ if str(lock.get("branch_name") or "").strip() != target:
+ continue
+ if is_lease_live(lock):
+ return True
+ return False
\ No newline at end of file
diff --git a/merged_cleanup_reconcile.py b/merged_cleanup_reconcile.py
index 60204f6..bf81258 100644
--- a/merged_cleanup_reconcile.py
+++ b/merged_cleanup_reconcile.py
@@ -13,9 +13,9 @@ import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
+import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
-ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
@@ -37,22 +37,18 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
- lock_path = (path or ISSUE_LOCK_FILE).strip()
- if not lock_path or not os.path.exists(lock_path):
- return None
- try:
- with open(lock_path, encoding="utf-8") as handle:
- data = json.load(handle)
- except (OSError, json.JSONDecodeError):
- return None
- return data if isinstance(data, dict) else None
+ if path:
+ return issue_lock_store.read_lock_file(path.strip())
+ return issue_lock_store.read_session_issue_lock()
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
- lock = read_issue_lock(lock_path)
- if not lock:
- return False
- return (lock.get("branch_name") or "").strip() == (branch or "").strip()
+ if lock_path:
+ lock = issue_lock_store.read_lock_file(lock_path.strip())
+ if not lock:
+ return False
+ return (lock.get("branch_name") or "").strip() == (branch or "").strip()
+ return issue_lock_store.has_active_issue_lock(branch)
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
diff --git a/scripts/worktree-start b/scripts/worktree-start
index 09c3bee..a189164 100755
--- a/scripts/worktree-start
+++ b/scripts/worktree-start
@@ -38,13 +38,21 @@ fi
branch="$1"
start_ref="${2:-prgs/master}"
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repo_root="$(cd "$script_dir/.." && pwd)"
+
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then
- if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
- echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
+ locked_branch=$(python3 -c "
+import sys
+sys.path.insert(0, '$repo_root')
+import issue_lock_store
+print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
+")
+ if [[ -z "$locked_branch" ]]; then
+ echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
exit 2
fi
- locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2
@@ -68,8 +76,6 @@ EOF
fi
fi
-script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}"
worktree_path="$repo_root/branches/$worktree_name"
diff --git a/tests/test_agent_temp_artifacts.py b/tests/test_agent_temp_artifacts.py
index 1532143..b37b3b6 100644
--- a/tests/test_agent_temp_artifacts.py
+++ b/tests/test_agent_temp_artifacts.py
@@ -74,11 +74,14 @@ ISSUE_WRITE_ENV = {
class TestIssueLockArtifactWarning(unittest.TestCase):
def setUp(self):
- self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
+ self._lock_dir = tempfile.TemporaryDirectory()
+ env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
+ self._env_patcher = patch.dict(os.environ, env, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
+ self._lock_dir.cleanup()
@patch(
"mcp_server.issue_duplicate_context_fetcher",
@@ -86,9 +89,8 @@ class TestIssueLockArtifactWarning(unittest.TestCase):
)
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
- @patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
@patch("issue_lock_worktree.read_worktree_git_state")
- def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
+ def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
mock_state.return_value = {
"current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n",
diff --git a/tests/test_commit_payloads.py b/tests/test_commit_payloads.py
index 6307a09..d3b3e52 100644
--- a/tests/test_commit_payloads.py
+++ b/tests/test_commit_payloads.py
@@ -66,9 +66,12 @@ class TestCommitPayloads(unittest.TestCase):
)
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
- self.lock_file_path = "/tmp/gitea_issue_lock.json"
+ import issue_lock_store
import issue_lock_provenance
+ self._lock_dir = tempfile.TemporaryDirectory()
+ os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
+
work_lease = {
"operation_type": "author_issue_work",
"issue_number": 263,
@@ -89,8 +92,7 @@ class TestCommitPayloads(unittest.TestCase):
claimant=work_lease.get("claimant"),
),
}
- with open(self.lock_file_path, "w", encoding="utf-8") as fh:
- fh.write(json.dumps(self.lock_data))
+ self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
@@ -114,8 +116,7 @@ class TestCommitPayloads(unittest.TestCase):
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
- if os.path.exists(self.lock_file_path):
- os.remove(self.lock_file_path)
+ self._lock_dir.cleanup()
def _env(self, profile: str) -> dict:
return {
@@ -124,6 +125,7 @@ class TestCommitPayloads(unittest.TestCase):
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
+ "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
@patch("mcp_server.api_request")
diff --git a/tests/test_issue_lock_adoption.py b/tests/test_issue_lock_adoption.py
new file mode 100644
index 0000000..bf64ac1
--- /dev/null
+++ b/tests/test_issue_lock_adoption.py
@@ -0,0 +1,68 @@
+"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from issue_lock_adoption import ( # noqa: E402
+ ADOPT,
+ BLOCK_COMPETING,
+ NO_MATCH,
+ assess_own_branch_adoption,
+ build_adoption_proof,
+)
+
+REQ = "feat/issue-420-server-code-parity"
+
+
+class TestAssessOwnBranchAdoption(unittest.TestCase):
+ def test_exact_own_branch_is_adopted(self):
+ result = assess_own_branch_adoption(
+ issue_number=420,
+ requested_branch=REQ,
+ existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
+ )
+ self.assertEqual(result["outcome"], ADOPT)
+ self.assertTrue(result["adopt"])
+
+ def test_different_branch_same_issue_blocks(self):
+ result = assess_own_branch_adoption(
+ issue_number=420,
+ requested_branch=REQ,
+ existing_branches=[{"name": "feat/issue-420-other-work"}],
+ )
+ self.assertEqual(result["outcome"], BLOCK_COMPETING)
+ self.assertTrue(result["block"])
+
+ def test_no_matching_branch_is_normal_path(self):
+ result = assess_own_branch_adoption(
+ issue_number=420,
+ requested_branch=REQ,
+ existing_branches=[{"name": "feat/issue-999-unrelated"}],
+ )
+ self.assertEqual(result["outcome"], NO_MATCH)
+
+
+class TestBuildAdoptionProof(unittest.TestCase):
+ def test_proof_has_required_fields(self):
+ assessment = assess_own_branch_adoption(
+ issue_number=420,
+ requested_branch=REQ,
+ existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
+ )
+ proof = build_adoption_proof(
+ issue_number=420,
+ branch_name=REQ,
+ assessment=assessment,
+ open_pr_checked=True,
+ competing_lock_checked=True,
+ lock_file_path="/tmp/example-lock.json",
+ lock_file_status="written",
+ )
+ self.assertEqual(proof["branch_head_commit"], "934688a")
+ self.assertTrue(proof["no_existing_pr_proof"])
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py
new file mode 100644
index 0000000..fca8607
--- /dev/null
+++ b/tests/test_issue_lock_store.py
@@ -0,0 +1,181 @@
+"""Unit tests for keyed issue-lock storage (#443)."""
+import json
+import os
+import sys
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import issue_lock_store as ils # noqa: E402
+
+
+def _lease(expires_at: str) -> dict:
+ return {
+ "operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
+ "expires_at": expires_at,
+ "created_at": "2026-01-01T00:00:00Z",
+ "last_heartbeat_at": "2026-01-01T00:00:00Z",
+ }
+
+
+def _lock_record(**overrides) -> dict:
+ record = {
+ "issue_number": 420,
+ "branch_name": "feat/issue-420-server-code-parity",
+ "remote": "prgs",
+ "org": "Scaled-Tech-Consulting",
+ "repo": "Gitea-Tools",
+ "worktree_path": "/tmp/wt-420",
+ "work_lease": _lease("2999-01-01T00:00:00Z"),
+ }
+ record.update(overrides)
+ return record
+
+
+class TestIssueLockStore(unittest.TestCase):
+ def setUp(self):
+ self._dir = tempfile.TemporaryDirectory()
+ self.lock_dir = self._dir.name
+ self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir})
+ self._env.start()
+
+ def tearDown(self):
+ self._env.stop()
+ self._dir.cleanup()
+
+ def test_concurrent_repo_locks_do_not_overwrite(self):
+ lock_a = _lock_record(
+ issue_number=108,
+ branch_name="feat/issue-108-root-menu",
+ repo="mcp-control-plane",
+ worktree_path="/tmp/wt-108",
+ )
+ lock_b = _lock_record(
+ issue_number=420,
+ branch_name="feat/issue-420-server-code-parity",
+ repo="Gitea-Tools",
+ worktree_path="/tmp/wt-420",
+ )
+ path_a = ils.bind_session_lock(lock_a)
+ with mock.patch("os.getpid", return_value=9999):
+ path_b = ils.bind_session_lock(lock_b)
+
+ self.assertNotEqual(path_a, path_b)
+ self.assertTrue(os.path.exists(path_a))
+ self.assertTrue(os.path.exists(path_b))
+ stored_a = ils.read_lock_file(path_a)
+ stored_b = ils.read_lock_file(path_b)
+ self.assertEqual(stored_a["issue_number"], 108)
+ self.assertEqual(stored_b["issue_number"], 420)
+
+ def test_concurrent_issue_locks_same_repo_do_not_overwrite(self):
+ lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a")
+ lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b")
+ path_a = ils.bind_session_lock(lock_a)
+ with mock.patch("os.getpid", return_value=4242):
+ path_b = ils.bind_session_lock(lock_b)
+
+ self.assertNotEqual(path_a, path_b)
+ self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427)
+ self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428)
+
+ def test_foreign_live_lease_blocks_overwrite(self):
+ existing = _lock_record(
+ branch_name="feat/issue-420-other",
+ worktree_path="/tmp/other",
+ work_lease=_lease("2999-01-01T00:00:00Z"),
+ )
+ path = ils.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ issue_number=420,
+ )
+ ils.save_lock_file(path, existing)
+
+ incoming = _lock_record(worktree_path="/tmp/mine")
+ block = ils.assess_foreign_lock_overwrite(existing, incoming)
+ self.assertIn("live foreign issue lock", block or "")
+
+ def test_expired_lease_allows_takeover_with_conflict_check(self):
+ existing = _lock_record(
+ branch_name="feat/issue-420-other",
+ worktree_path="/tmp/other",
+ work_lease=_lease("2000-01-01T00:00:00Z"),
+ )
+ incoming = _lock_record(worktree_path="/tmp/mine")
+ self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
+ block = ils.assess_same_issue_lease_conflict(
+ existing,
+ issue_number=420,
+ branch_name="feat/issue-420-server-code-parity",
+ worktree_path="/tmp/mine",
+ )
+ self.assertIn("Recovery review is required", block or "")
+
+ def test_same_owner_lease_conflict_allows_refresh(self):
+ worktree = "/tmp/wt-420"
+ existing = _lock_record(worktree_path=worktree)
+ block = ils.assess_same_issue_lease_conflict(
+ existing,
+ issue_number=420,
+ branch_name="feat/issue-420-server-code-parity",
+ worktree_path=worktree,
+ )
+ self.assertIsNone(block)
+
+ def test_find_lock_for_branch_after_restart(self):
+ record = _lock_record()
+ path = ils.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ issue_number=420,
+ )
+ ils.save_lock_file(path, record)
+
+ with mock.patch("os.getpid", return_value=5555):
+ self.assertIsNone(ils.read_session_issue_lock())
+
+ found = ils.find_lock_for_branch(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ branch_name="feat/issue-420-server-code-parity",
+ )
+ self.assertEqual(found["issue_number"], 420)
+
+ def test_has_active_issue_lock_scans_keyed_store(self):
+ ils.bind_session_lock(_lock_record())
+ self.assertTrue(
+ ils.has_active_issue_lock("feat/issue-420-server-code-parity")
+ )
+ self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
+
+ def test_atomic_write_preserves_unrelated_lock(self):
+ path_a = ils.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ issue_number=108,
+ )
+ ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane"))
+ path_b = ils.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ issue_number=420,
+ )
+ ils.save_lock_file(path_b, _lock_record())
+
+ self.assertTrue(os.path.exists(path_a))
+ self.assertTrue(os.path.exists(path_b))
+ self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
+
+
+if __name__ == "__main__":
+ unittest.main()
\ No newline at end of file
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index d04cf38..2146c51 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -6,6 +6,7 @@ the MCP protocol) with mocked API responses.
import json
import os
import sys
+import tempfile
import unittest
from unittest.mock import patch, MagicMock
@@ -45,6 +46,7 @@ from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402
import mcp_server
+import issue_lock_store
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
@@ -155,9 +157,6 @@ CREATE_PR_ENV = {
),
}
-ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
-
-
def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
import issue_lock_provenance
@@ -174,6 +173,7 @@ def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
"remote": "dadeschools",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
+ "worktree_path": "/tmp/test-worktree",
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
@@ -189,6 +189,17 @@ def _clear_duplicate_context_fetcher(*_args, **_kwargs):
return [], [], {"status": "not_claimed"}
+def _bind_test_lock(**overrides) -> str:
+ remote = overrides.get("remote", "dadeschools")
+ record = _sample_issue_lock(**overrides)
+ if remote in mcp_server.REMOTES:
+ profile = mcp_server.REMOTES[remote]
+ record.setdefault("org", profile["org"])
+ record.setdefault("repo", profile["repo"])
+ record["remote"] = remote
+ return issue_lock_store.bind_session_lock(record)
+
+
# ---------------------------------------------------------------------------
# Create Issue
# ---------------------------------------------------------------------------
@@ -251,18 +262,21 @@ class TestCreatePR(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open")
- def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
- lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
- mock_open.return_value.__enter__.return_value.read.return_value = lock_json
+ def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher):
+ worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
- result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
+ with tempfile.TemporaryDirectory() as lock_dir:
+ env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
+ with patch.dict(os.environ, env, clear=True):
+ _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
+ result = gitea_create_pr(
+ title="feat: X Closes #123",
+ head="feat/x",
+ base="main",
+ worktree_path=worktree,
+ )
self.assertEqual(result["number"], 3)
self.assertNotIn("url", result)
- mock_exists.assert_called_with(ISSUE_LOCK_FILE)
- mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
payload = mock_api.call_args[0][3]
self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main")
@@ -276,30 +290,42 @@ class TestCreatePR(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open")
- def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
- lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
- mock_open.return_value.__enter__.return_value.read.return_value = lock_json
+ def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher):
+ worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
- env = {**CREATE_PR_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
- with patch.dict(os.environ, env, clear=True):
- result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
+ with tempfile.TemporaryDirectory() as lock_dir:
+ env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
+ with patch.dict(os.environ, env, clear=True):
+ _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
+ result = gitea_create_pr(
+ title="feat: X Closes #123",
+ head="feat/x",
+ base="main",
+ worktree_path=worktree,
+ )
self.assertIn("pulls/3", result["url"])
@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)
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open")
- def test_create_pr_locked_issue_mismatch_fails(self, mock_open, mock_exists, _auth, _role):
- lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
- mock_open.return_value.__enter__.return_value.read.return_value = lock_json
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
- with self.assertRaises(ValueError) as ctx:
- gitea_create_pr(title="feat: X Closes #999", head="feat/x", base="main")
+ def test_create_pr_locked_issue_mismatch_fails(self, _auth, _role):
+ worktree = os.path.realpath(os.getcwd())
+ with tempfile.TemporaryDirectory() as lock_dir:
+ env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
+ with patch.dict(os.environ, env, clear=True):
+ _bind_test_lock(
+ issue_number=123,
+ branch_name="feat/x",
+ worktree_path=worktree,
+ )
+ with self.assertRaises(ValueError) as ctx:
+ gitea_create_pr(
+ title="feat: X Closes #999",
+ head="feat/x",
+ base="main",
+ worktree_path=worktree,
+ )
self.assertIn("Closes #123", str(ctx.exception))
- mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
# ---------------------------------------------------------------------------
@@ -3184,7 +3210,12 @@ class TestIssueLocking(unittest.TestCase):
"""Test issue locking and PR gating constraints."""
def setUp(self):
- self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
+ self._lock_dir = tempfile.TemporaryDirectory()
+ env = {
+ **ISSUE_WRITE_ENV,
+ "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
+ }
+ self._env_patcher = patch.dict(os.environ, env, clear=True)
self._env_patcher.start()
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
@@ -3195,15 +3226,21 @@ class TestIssueLocking(unittest.TestCase):
def tearDown(self):
self._dup_fetcher_patcher.stop()
self._env_patcher.stop()
- if os.path.exists(ISSUE_LOCK_FILE):
- os.remove(ISSUE_LOCK_FILE)
+ self._lock_dir.cleanup()
+
+ def _create_pr_env(self) -> dict:
+ return {
+ **CREATE_PR_ENV,
+ "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
+ }
@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_success(self, _auth, _git_state):
+ def test_lock_issue_success(self, _auth, _api, _git_state):
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
@@ -3213,9 +3250,8 @@ class TestIssueLocking(unittest.TestCase):
self.assertIn("expires_at", res["work_lease"])
self.assertIn("last_heartbeat_at", res["work_lease"])
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
- self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
- with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
- lock = json.load(f)
+ self.assertIn("lock_file_path", res)
+ lock = issue_lock_store.read_lock_file(res["lock_file_path"])
self.assertIn("worktree_path", lock)
self.assertIn("work_lease", lock)
@@ -3271,32 +3307,81 @@ class TestIssueLocking(unittest.TestCase):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("remote branch(es) already match issue pattern", str(ctx.exception))
- def test_lock_issue_blocks_active_same_operation_lease(self):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump({
+ @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_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
+ 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")
+ self.assertTrue(res["success"])
+ self.assertIn("adoption", res)
+ 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", return_value=[])
+ @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
+ def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
+ prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
+ issue_lock_store.save_lock_file(
+ issue_lock_store.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo=prgs_repo,
+ issue_number=196,
+ ),
+ {
"issue_number": 196,
"branch_name": "feat/issue-196-other-work",
+ "remote": "prgs",
+ "org": "Scaled-Tech-Consulting",
+ "repo": prgs_repo,
"worktree_path": "/tmp/other-worktree",
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2999-01-01T00:00:00Z",
},
- }, f)
+ },
+ )
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
- def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump({
+ @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_blocks_expired_same_operation_lease_for_recovery(self, _auth, _api, _git_state):
+ prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
+ issue_lock_store.save_lock_file(
+ issue_lock_store.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo=prgs_repo,
+ issue_number=196,
+ ),
+ {
"issue_number": 196,
"branch_name": "feat/issue-196-other-work",
+ "remote": "prgs",
+ "org": "Scaled-Tech-Consulting",
+ "repo": prgs_repo,
"worktree_path": "/tmp/other-worktree",
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z",
},
- }, f)
+ },
+ )
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
@@ -3363,9 +3448,7 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_lock_fails(self, _auth, _role):
- if os.path.exists(ISSUE_LOCK_FILE):
- os.remove(ISSUE_LOCK_FILE)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(RuntimeError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("Issue lock is missing", str(ctx.exception))
@@ -3374,37 +3457,64 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_branch_mismatch_fails(self, _auth, _role):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(_sample_issue_lock(
- issue_number=196, branch_name="feat/issue-196-mutations"), f)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ worktree = os.path.realpath(os.getcwd())
+ _bind_test_lock(
+ issue_number=196,
+ branch_name="feat/issue-196-mutations",
+ remote="prgs",
+ worktree_path=worktree,
+ )
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(ValueError) as ctx:
- gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
+ gitea_create_pr(
+ title="feat: X Closes #196",
+ head="feat/issue-196-different",
+ remote="prgs",
+ worktree_path=worktree,
+ )
self.assertIn("does not match locked branch", str(ctx.exception))
@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_forbidden_terms_fails(self, _auth, _role):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(_sample_issue_lock(
- issue_number=196, branch_name="feat/issue-196-mutations"), f)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ worktree = os.path.realpath(os.getcwd())
+ _bind_test_lock(
+ issue_number=196,
+ branch_name="feat/issue-196-mutations",
+ remote="prgs",
+ worktree_path=worktree,
+ )
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
for term in ("equivalent to #196", "related to #196", "same as #196"):
with self.assertRaises(ValueError) as ctx:
- gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs")
+ gitea_create_pr(
+ title=f"feat: X {term}",
+ head="feat/issue-196-mutations",
+ remote="prgs",
+ worktree_path=worktree,
+ )
self.assertIn("contains forbidden term", str(ctx.exception))
@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_missing_closes_ref_fails(self, _auth, _role):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(_sample_issue_lock(
- issue_number=196, branch_name="feat/issue-196-mutations"), f)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ worktree = os.path.realpath(os.getcwd())
+ _bind_test_lock(
+ issue_number=196,
+ branch_name="feat/issue-196-mutations",
+ remote="prgs",
+ worktree_path=worktree,
+ )
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(ValueError) as ctx:
- gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
+ gitea_create_pr(
+ title="feat: X refs #196",
+ head="feat/issue-196-mutations",
+ remote="prgs",
+ worktree_path=worktree,
+ )
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
@@ -3412,13 +3522,13 @@ class TestIssueLocking(unittest.TestCase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_worktree_mismatch_fails(self, _auth, _role):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr")
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(_sample_issue_lock(
- issue_number=249,
- branch_name="feat/issue-249-issue-lock-scratch-worktree",
- worktree_path=scratch,
- ), f)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ _bind_test_lock(
+ issue_number=249,
+ branch_name="feat/issue-249-issue-lock-scratch-worktree",
+ worktree_path=scratch,
+ remote="prgs",
+ )
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(
title="feat: lock scratch worktree Closes #249",
@@ -3457,13 +3567,13 @@ class TestIssueLocking(unittest.TestCase):
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(_sample_issue_lock(
- issue_number=249,
- branch_name="feat/issue-249-issue-lock-scratch-worktree",
- worktree_path=scratch,
- ), f)
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
+ _bind_test_lock(
+ issue_number=249,
+ branch_name="feat/issue-249-issue-lock-scratch-worktree",
+ worktree_path=scratch,
+ remote="prgs",
+ )
+ with patch.dict(os.environ, self._create_pr_env(), clear=True):
res = gitea_create_pr(
title="feat: issue-lock scratch worktree Closes #249",
head="feat/issue-249-issue-lock-scratch-worktree",
diff --git a/tests/test_worktrees.py b/tests/test_worktrees.py
index 45f1ac5..9ed3285 100644
--- a/tests/test_worktrees.py
+++ b/tests/test_worktrees.py
@@ -20,33 +20,50 @@ def run(script, *args):
branch = arg
break
- lock_file = Path("/tmp/gitea_issue_lock.json")
- created_lock = False
+ lock_dir_ctx = None
+ extra_env = os.environ.copy()
if script == "worktree-start" and branch:
import re
- import json
+ import tempfile
+ import issue_lock_store
+
m = re.search(r"issue-(\d+)", branch)
if not m:
m = re.search(r"pr-(\d+)", branch)
issue_num = int(m.group(1)) if m else 999
- lock_file.write_text(json.dumps({
+ lock_dir_ctx = tempfile.TemporaryDirectory()
+ extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name
+ record = {
"issue_number": issue_num,
"branch_name": branch,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
- "repo": "Gitea-Tools"
- }), encoding="utf-8")
- created_lock = True
+ "repo": "Gitea-Tools",
+ "worktree_path": "/tmp/test-worktree",
+ "work_lease": {
+ "operation_type": "author_issue_work",
+ "expires_at": "2999-01-01T00:00:00Z",
+ },
+ }
+ path = issue_lock_store.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo="Gitea-Tools",
+ issue_number=issue_num,
+ lock_dir=lock_dir_ctx.name,
+ )
+ issue_lock_store.save_lock_file(path, record)
try:
proc = subprocess.run(
["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO),
+ env=extra_env,
)
return proc.returncode, proc.stdout, proc.stderr
finally:
- if created_lock and lock_file.exists():
- lock_file.unlink()
+ if lock_dir_ctx is not None:
+ lock_dir_ctx.cleanup()
class TestWorktreeStart(unittest.TestCase):
From 43a17a7e8ec27586f1c1bfd96066c248ac21160b Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:25:13 -0400
Subject: [PATCH 28/42] resolve conflicts for PR #465
---
tests/test_mcp_server.py | 36 ++++++++++++++++++++++++++----------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 2146c51..ff2f4a4 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -157,6 +157,9 @@ CREATE_PR_ENV = {
),
}
+ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
+
+
def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
import issue_lock_provenance
@@ -3542,22 +3545,35 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
- with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
- json.dump(
+ worktree = os.path.realpath(os.getcwd())
+ with tempfile.TemporaryDirectory() as lock_dir:
+ env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
+ with patch.dict(os.environ, env, clear=True):
+ issue_lock_store.save_lock_file(
+ issue_lock_store.lock_file_path(
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo=mcp_server.REMOTES["prgs"]["repo"],
+ issue_number=447,
+ lock_dir=lock_dir,
+ ),
_sample_issue_lock(
issue_number=447,
branch_name="feat/issue-447-lock-provenance",
+ remote="prgs",
+ org="Scaled-Tech-Consulting",
+ repo=mcp_server.REMOTES["prgs"]["repo"],
+ worktree_path=worktree,
lock_provenance=None,
),
- f,
- )
- with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
- with self.assertRaises(RuntimeError) as ctx:
- gitea_create_pr(
- title="feat: lock provenance Closes #447",
- head="feat/issue-447-lock-provenance",
- remote="prgs",
)
+ 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())
@patch("mcp_server.api_request")
From e956040b6b67b085a462edd4daca2542b5ed196a Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:29:32 -0400
Subject: [PATCH 29/42] test: align duplicate-gate and artifact tests with
keyed lock store (#443)
Update lock-issue and duplicate-recheck tests to mock branch listing,
use GITEA_ISSUE_LOCK_DIR session binding, and satisfy create_pr
provenance/worktree guards after the keyed persistent lock store landed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_agent_temp_artifacts.py | 1 +
tests/test_issue_work_duplicate_gate.py | 60 ++++++++++++++-----------
2 files changed, 35 insertions(+), 26 deletions(-)
diff --git a/tests/test_agent_temp_artifacts.py b/tests/test_agent_temp_artifacts.py
index b37b3b6..7684d98 100644
--- a/tests/test_agent_temp_artifacts.py
+++ b/tests/test_agent_temp_artifacts.py
@@ -87,6 +87,7 @@ class TestIssueLockArtifactWarning(unittest.TestCase):
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
+ @patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("issue_lock_worktree.read_worktree_git_state")
diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py
index ac4ccea..1ae3024 100644
--- a/tests/test_issue_work_duplicate_gate.py
+++ b/tests/test_issue_work_duplicate_gate.py
@@ -1,5 +1,4 @@
"""Tests for early duplicate-work detection (#400)."""
-import json
import os
import sys
import tempfile
@@ -10,6 +9,7 @@ from unittest.mock import patch
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 mcp_server
from issue_work_duplicate_gate import (
@@ -123,8 +123,9 @@ class TestDuplicateReportOutcome(unittest.TestCase):
class TestInjectableDuplicateFetcher(unittest.TestCase):
+ @patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value="token x")
- def test_lock_issue_uses_injected_fetcher(self, _auth):
+ def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
seen = {}
def fetcher(h, o, r, auth, issue_number):
@@ -141,26 +142,29 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
"porcelain_status": "",
"base_equivalent": True,
},
- ), patch.dict(os.environ, {
- "GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
- }, clear=True):
- with patch.object(mcp_server, "ISSUE_LOCK_FILE", tempfile.mktemp()):
- mcp_server.gitea_lock_issue(
- issue_number=400,
- branch_name="feat/issue-400-duplicate-work-preflight",
- remote="prgs",
- )
+ ):
+ with tempfile.TemporaryDirectory() as lock_dir:
+ with patch.dict(os.environ, {
+ "GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
+ "GITEA_ISSUE_LOCK_DIR": lock_dir,
+ }, clear=True):
+ mcp_server.gitea_lock_issue(
+ issue_number=400,
+ branch_name="feat/issue-400-duplicate-work-preflight",
+ remote="prgs",
+ )
self.assertEqual(seen["issue_number"], 400)
class TestMcpDuplicateRecheck(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
- self.lock_path = os.path.join(self._dir.name, "gitea_issue_lock.json")
- self._lock_patch = patch.object(
- mcp_server, "ISSUE_LOCK_FILE", self.lock_path
+ self._env_patch = patch.dict(
+ os.environ,
+ {"GITEA_ISSUE_LOCK_DIR": self._dir.name},
+ clear=False,
)
- self._lock_patch.start()
+ self._env_patch.start()
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
@@ -173,6 +177,7 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
self._dir.cleanup()
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
+ worktree_path = os.path.realpath(os.getcwd())
work_lease = {
"operation_type": "author_issue_work",
"issue_number": issue_number,
@@ -180,17 +185,19 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
"claimant": {"username": "test-user", "profile": "test-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
- with open(self.lock_path, "w", encoding="utf-8") as fh:
- json.dump({
- "issue_number": issue_number,
- "branch_name": branch,
- "remote": "prgs",
- "work_lease": work_lease,
- "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
- tool="gitea_lock_issue",
- claimant=work_lease.get("claimant"),
- ),
- }, fh)
+ 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.get_profile", return_value={
@@ -254,6 +261,7 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
base="master",
body="Closes #400",
remote="prgs",
+ worktree_path=os.path.realpath(os.getcwd()),
)
self.assertFalse(result["success"])
self.assertIsNone(result.get("number"))
From c7b462a034b7799d8881fe6fd1378a7e866a2697 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:36:33 -0400
Subject: [PATCH 30/42] fix: exempt reconciler close_pr from branches-only
worktree guard (#468)
Reconciler profiles perform Gitea metadata mutations (close_pr) and must
not be blocked when the MCP workspace resolves to the stable control
checkout without GITEA_AUTHOR_WORKTREE. Author file mutations remain
guarded. Add regression tests for reconciler close vs author create_issue.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 9 +-
.../test_reconciler_close_workspace_guard.py | 89 +++++++++++++++++++
2 files changed, 96 insertions(+), 2 deletions(-)
create mode 100644 tests/test_reconciler_close_workspace_guard.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index cd24d8d..8fabf90 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -399,8 +399,13 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
- """#274: author mutations must run from a branches/ session worktree."""
- if _preflight_resolved_role == "reviewer":
+ """#274: author file/branch mutations must run from a branches/ worktree.
+
+ Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a
+ Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
+ (#468).
+ """
+ if _preflight_resolved_role in ("reviewer", "reconciler"):
return
workspace = author_mutation_worktree.resolve_mutation_workspace(
worktree_path,
diff --git a/tests/test_reconciler_close_workspace_guard.py b/tests/test_reconciler_close_workspace_guard.py
new file mode 100644
index 0000000..54a24e4
--- /dev/null
+++ b/tests/test_reconciler_close_workspace_guard.py
@@ -0,0 +1,89 @@
+"""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()
\ No newline at end of file
From 85532059e277c397feddee8dc959693fddf60a02 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:30:12 -0400
Subject: [PATCH 31/42] fix: use exact issue-number boundary in own-branch
adoption (#442)
Replace substring issue-marker matching with a numeric word-boundary
regex so issue-42 adoption is not false-blocked by unrelated issue-420
branches. Add regression tests for the #42 vs #420 collision.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
issue_lock_adoption.py | 48 +++++++++++++++++++--
tests/test_issue_lock_adoption.py | 72 ++++++++++++++++++++++++++++++-
2 files changed, 115 insertions(+), 5 deletions(-)
diff --git a/issue_lock_adoption.py b/issue_lock_adoption.py
index 7c8e5ef..8f8a8d6 100644
--- a/issue_lock_adoption.py
+++ b/issue_lock_adoption.py
@@ -14,6 +14,8 @@ this module additionally records whether they passed for proof purposes.
from __future__ import annotations
+import re
+
ADOPT = "adopt_existing_branch"
BLOCK_COMPETING = "block_competing_branch"
NO_MATCH = "no_matching_branch"
@@ -33,25 +35,58 @@ def _branch_sha(entry) -> str | 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(
*,
issue_number: int,
requested_branch: str,
existing_branches,
) -> dict:
- """Decide whether an existing matching branch is adoptable."""
- marker = f"issue-{issue_number}"
+ """Decide whether an existing matching branch is adoptable.
+
+ Args:
+ issue_number: The tracking issue number being locked.
+ requested_branch: The exact branch the caller wants to lock.
+ existing_branches: Iterable of remote branch entries — either names or
+ dicts with ``name`` and optional ``commit_sha``.
+
+ Returns:
+ dict with:
+ * ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
+ * ``adopt`` (bool), ``block`` (bool)
+ * ``reason`` (str)
+ * ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
+ * ``competing_branches`` (list[str])
+
+ ADOPT: the issue's exact branch exists and no other same-issue branch does.
+ BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
+ NO_MATCH: no branch carries the issue marker — normal lock path applies.
+ """
requested = (requested_branch or "").strip()
matches: list[tuple[str, str | None]] = []
for entry in existing_branches or []:
name = _branch_name(entry).strip()
- if marker in name:
+ if _branch_carries_issue_marker(name, issue_number):
matches.append((name, _branch_sha(entry)))
competing = sorted({name for name, _ in matches if name != requested})
exact = [(name, sha) for name, sha in matches if name == requested]
+ # Fail closed whenever any non-requested same-issue branch exists, even if
+ # the requested branch is also present: ownership is then ambiguous.
if competing:
return {
"outcome": BLOCK_COMPETING,
@@ -103,7 +138,12 @@ def build_adoption_proof(
lock_file_path: str,
lock_file_status: str,
) -> 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.
+ """
return {
"issue_number": issue_number,
"branch_name": branch_name,
diff --git a/tests/test_issue_lock_adoption.py b/tests/test_issue_lock_adoption.py
index bf64ac1..a579cbc 100644
--- a/tests/test_issue_lock_adoption.py
+++ b/tests/test_issue_lock_adoption.py
@@ -25,6 +25,16 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
)
self.assertEqual(result["outcome"], 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):
result = assess_own_branch_adoption(
@@ -34,6 +44,20 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
)
self.assertEqual(result["outcome"], BLOCK_COMPETING)
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):
result = assess_own_branch_adoption(
@@ -42,10 +66,44 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
self.assertEqual(result["outcome"], NO_MATCH)
+ self.assertFalse(result["block"])
+ self.assertFalse(result["adopt"])
+
+ def test_empty_branch_list_is_normal_path(self):
+ result = assess_own_branch_adoption(
+ issue_number=420, requested_branch=REQ, existing_branches=[]
+ )
+ self.assertEqual(result["outcome"], NO_MATCH)
+
+ 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.assertFalse(result["block"])
+ self.assertFalse(result["adopt"])
class TestBuildAdoptionProof(unittest.TestCase):
- def test_proof_has_required_fields(self):
+ def test_proof_has_all_required_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
@@ -60,8 +118,20 @@ class TestBuildAdoptionProof(unittest.TestCase):
lock_file_path="/tmp/example-lock.json",
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.assertTrue(proof["no_existing_pr_proof"])
+ self.assertTrue(proof["no_competing_live_lock_proof"])
if __name__ == "__main__":
From d306c0a5ec0c33ce8168c2c0db6afc8062ebba58 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 18:07:44 -0400
Subject: [PATCH 32/42] feat: harden keyed issue lock store with flock
serialization (#438)
Rebase onto master (#443 keyed store) and port #438 hardening: per-issue
fcntl.flock around bind_session_lock, assess_lock_freshness with dead-PID
detection, verify_lock_for_mutation before create_pr, and live lock
inventory for claim visibility.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 33 +++++
issue_lock_store.py | 255 +++++++++++++++++++++++++++++++--
tests/test_issue_lock_store.py | 77 +++++++++-
3 files changed, 350 insertions(+), 15 deletions(-)
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index c332bba..37a8e9b 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -1382,6 +1382,19 @@ def gitea_lock_issue(
}
lock_file_path = _save_issue_lock(data)
+ lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
+ freshness = issue_lock_store.assess_lock_freshness(lock_record)
+ competing = [
+ entry
+ for entry in issue_lock_store.list_live_locks()
+ if entry.get("issue_number") != issue_number
+ ]
+ lock_proof = issue_lock_store.format_lock_proof(
+ lock_record,
+ freshness=freshness,
+ competing_live_locks=competing,
+ released=False,
+ )
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
git_state.get("porcelain_status") or ""
@@ -1397,6 +1410,8 @@ def gitea_lock_issue(
"worktree_path": resolved_worktree,
"work_lease": work_lease,
"lock_file_path": lock_file_path,
+ "lock_freshness": freshness,
+ "lock_proof": lock_proof,
}
if adoption["adopt"]:
result["adoption"] = issue_lock_adoption.build_adoption_proof(
@@ -1529,6 +1544,15 @@ def gitea_create_pr(
f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
)
+ ownership = issue_lock_store.verify_lock_for_mutation(
+ lock_data,
+ issue_number=locked_issue,
+ branch_name=head,
+ worktree_path=worktree_path,
+ )
+ if ownership["block"]:
+ raise ValueError(ownership["reasons"][0])
+
# Check for forbidden terms anywhere in title/body
forbidden_terms = ["equivalent", "related", "same as"]
text_to_check = f"{title} {body}".lower()
@@ -6691,6 +6715,15 @@ def gitea_reconcile_issue_claims(
heartbeat_lease_minutes=heartbeat_lease_minutes,
reclaim_after_minutes=reclaim_after_minutes,
)
+ live_locks = issue_lock_store.list_live_locks()
+ inventory["live_issue_locks"] = live_locks
+ inventory["live_issue_lock_numbers"] = sorted(
+ {
+ int(entry["issue_number"])
+ for entry in live_locks
+ if entry.get("issue_number") is not None
+ }
+ )
inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory)
inventory["success"] = True
inventory["performed"] = False
diff --git a/issue_lock_store.py b/issue_lock_store.py
index 9ede4bb..6a7b4d7 100644
--- a/issue_lock_store.py
+++ b/issue_lock_store.py
@@ -1,18 +1,21 @@
-"""Keyed, persistent issue-lock storage (#443).
+"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
via a per-process pointer file so concurrent repos/issues never clobber each
-other.
+other. Acquisition is serialized per issue with ``fcntl.flock``.
"""
from __future__ import annotations
+import errno
+import fcntl
import json
import os
import re
import tempfile
+from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -24,6 +27,10 @@ AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
+class LockContentionError(RuntimeError):
+ """Raised when an exclusive per-issue lock cannot be acquired."""
+
+
def default_lock_dir() -> str:
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
return raw or DEFAULT_LOCK_DIR
@@ -72,6 +79,41 @@ def _ensure_lock_dir(lock_dir: str | None = None) -> str:
return root
+def flock_path(json_path: str) -> str:
+ return f"{json_path}.lock"
+
+
+def is_process_alive(pid: int | None) -> bool:
+ if not pid or pid <= 0:
+ return False
+ try:
+ os.kill(int(pid), 0)
+ return True
+ except OSError as exc:
+ return exc.errno != errno.ESRCH
+ except (TypeError, ValueError):
+ return False
+
+
+@contextmanager
+def _exclusive_file_lock(lock_path: str):
+ os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
+ try:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError as exc:
+ raise LockContentionError(
+ f"could not acquire exclusive lock on '{lock_path}'"
+ ) from exc
+ yield fd
+ finally:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ finally:
+ os.close(fd)
+
+
def read_lock_file(path: str) -> dict[str, Any] | None:
lock_path = (path or "").strip()
if not lock_path or not os.path.exists(lock_path):
@@ -126,7 +168,7 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
record = dict(lock_data)
record["lock_file_path"] = path
record["session_pid"] = os.getpid()
- save_lock_file(path, record)
+ record.setdefault("pid", os.getpid())
pointer = {
"pid": os.getpid(),
@@ -137,7 +179,32 @@ def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) ->
"org": org,
"repo": repo,
}
- save_lock_file(session_pointer_path(root), pointer)
+ sentinel = flock_path(path)
+ try:
+ with _exclusive_file_lock(sentinel):
+ existing = read_lock_file(path)
+ overwrite_block = assess_foreign_lock_overwrite(existing, record)
+ if overwrite_block:
+ raise RuntimeError(overwrite_block)
+ lease_block = assess_same_issue_lease_conflict(
+ existing,
+ issue_number=issue_number,
+ branch_name=str(record.get("branch_name") or ""),
+ worktree_path=str(record.get("worktree_path") or ""),
+ )
+ if lease_block:
+ raise RuntimeError(lease_block)
+ save_lock_file(path, record)
+ save_lock_file(session_pointer_path(root), pointer)
+ except LockContentionError as exc:
+ competing = read_lock_file(path)
+ if competing:
+ owner_pid = competing.get("session_pid") or competing.get("pid")
+ raise RuntimeError(
+ f"Issue #{issue_number} lock contention: {exc}; competing owner "
+ f"pid={owner_pid} (fail closed)"
+ ) from exc
+ raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
return path
@@ -241,15 +308,62 @@ 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:
- if not lock:
- return False
- lease = lock.get("work_lease")
- if not isinstance(lease, dict):
- return True
- expires = _parse_lease_timestamp(lease.get("expires_at"))
- if expires is None:
- return True
- return expires > _lease_now(now)
+ return assess_lock_freshness(lock, now=now)["live"]
+
+
+def assess_lock_freshness(
+ lock_data: dict[str, Any] | None,
+ *,
+ now: datetime | None = None,
+) -> dict[str, Any]:
+ """Classify a lock as live, expired, stale, or absent."""
+ current = _lease_now(now)
+ if not lock_data:
+ return {
+ "status": "absent",
+ "live": False,
+ "stale": False,
+ "reason": "no lock record",
+ }
+
+ expires_at = lease_expires_at(lock_data)
+ lease = lock_data.get("work_lease")
+ heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
+ if heartbeat_at is None and isinstance(lease, dict):
+ heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
+
+ pid = lock_data.get("session_pid")
+ if pid is None:
+ pid = lock_data.get("pid")
+ pid_alive = is_process_alive(pid) if pid is not None else False
+
+ if expires_at and expires_at <= current:
+ return {
+ "status": "expired",
+ "live": False,
+ "stale": True,
+ "reason": f"lease expired at {expires_at.isoformat()}",
+ "pid_alive": pid_alive,
+ }
+
+ if pid is not None and not pid_alive:
+ return {
+ "status": "stale",
+ "live": False,
+ "stale": True,
+ "reason": f"owner pid {pid} is not alive",
+ "pid_alive": False,
+ }
+
+ return {
+ "status": "live",
+ "live": True,
+ "stale": False,
+ "reason": "lock heartbeat and lease are fresh",
+ "pid_alive": pid_alive,
+ "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
+ "expires_at": expires_at.isoformat() if expires_at else None,
+ }
def _same_realpath(left: str | None, right: str | None) -> bool:
@@ -382,4 +496,117 @@ def has_active_issue_lock(
continue
if is_lease_live(lock):
return True
- return False
\ No newline at end of file
+ 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)
\ No newline at end of file
diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py
index fca8607..d87fc73 100644
--- a/tests/test_issue_lock_store.py
+++ b/tests/test_issue_lock_store.py
@@ -1,8 +1,9 @@
-"""Unit tests for keyed issue-lock storage (#443)."""
+"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
import json
import os
import sys
import tempfile
+import threading
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -176,6 +177,80 @@ class TestIssueLockStore(unittest.TestCase):
self.assertTrue(os.path.exists(path_b))
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__":
unittest.main()
\ No newline at end of file
From 89582a0f2aade2f8ff8b5ead1f61153b1147df90 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 00:37:54 -0400
Subject: [PATCH 33/42] feat: require approval at current PR head for merge
(#471)
Add merge_approval_gate assessment so gitea_merge_pr fails closed when
visible APPROVED reviews do not pin the live head SHA. Expose
approval_at_current_head and stale_approval_block_reason in review
feedback. Document re-review requirement in canonical merge workflow.
Closes #471.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 21 +++++++
merge_approval_gate.py | 61 +++++++++++++++++++
.../workflows/review-merge-pr.md | 2 +
tests/test_audit.py | 3 +-
tests/test_mcp_server.py | 23 +++++++
tests/test_merge_approval_gate.py | 59 ++++++++++++++++++
tests/test_review_feedback.py | 16 +++++
7 files changed, 184 insertions(+), 1 deletion(-)
create mode 100644 merge_approval_gate.py
create mode 100644 tests/test_merge_approval_gate.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 37a8e9b..8e8683d 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -535,6 +535,7 @@ import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402
+import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
@@ -2472,6 +2473,10 @@ def gitea_get_pr_review_feedback(
e for e in latest_by_reviewer.values()
if e["verdict"] == "APPROVED" and not e["dismissed"]
]
+ approval_head = merge_approval_gate.assess_merge_approval_head(
+ current_head_sha=current_head,
+ latest_by_reviewer=latest_by_reviewer,
+ )
return {
"success": True,
"pr_number": pr_number,
@@ -2482,6 +2487,9 @@ def gitea_get_pr_review_feedback(
login: e["verdict"] for login, e in latest_by_reviewer.items()},
"has_blocking_change_requests": bool(blocking),
"approval_visible": bool(approvals),
+ "approval_at_current_head": approval_head["approval_at_current_head"],
+ "latest_approved_head_sha": approval_head["latest_approved_head_sha"],
+ "stale_approval_block_reason": approval_head["stale_approval_block_reason"],
"latest_reviewed_head_sha": latest_reviewed_head,
"review_feedback_stale": bool(
latest_reviewed_head and current_head
@@ -3442,6 +3450,9 @@ def gitea_merge_pr(
result["permission_report"] = feedback["permission_report"]
return result
result["approval_visible"] = feedback.get("approval_visible")
+ result["approval_at_current_head"] = feedback.get("approval_at_current_head")
+ result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha")
+ result["review_feedback_stale"] = feedback.get("review_feedback_stale")
result["has_blocking_change_requests"] = feedback.get(
"has_blocking_change_requests")
if feedback.get("has_blocking_change_requests"):
@@ -3455,6 +3466,16 @@ def gitea_merge_pr(
"completed before merge (fail closed)"
)
return result
+ if not feedback.get("approval_at_current_head"):
+ reasons.append(
+ feedback.get("stale_approval_block_reason")
+ or (
+ "approval does not apply to current PR head SHA "
+ "(fail closed); required next action: re-review PR at "
+ "current head before merge"
+ )
+ )
+ return result
# Gate 8 — in-process mutation authority (#199): the last check before
# the merge mutation, using the identity the eligibility gate proved.
diff --git a/merge_approval_gate.py b/merge_approval_gate.py
new file mode 100644
index 0000000..08fc8ad
--- /dev/null
+++ b/merge_approval_gate.py
@@ -0,0 +1,61 @@
+"""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,
+ }
\ No newline at end of file
diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md
index 4f9f6eb..80e9218 100644
--- a/skills/llm-project-workflow/workflows/review-merge-pr.md
+++ b/skills/llm-project-workflow/workflows/review-merge-pr.md
@@ -762,6 +762,7 @@ Before merge, rerun fresh live checks:
* author safety
* PR re-fetch
* 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
* PR still open
* PR still mergeable
@@ -778,6 +779,7 @@ Do not merge if:
* capability state is stale
* worktree is dirty
* PR head changed
+* approval is stale (approved SHA ≠ current live head SHA)
* validation failed
* inventory was incomplete
* PR is already landed
diff --git a/tests/test_audit.py b/tests/test_audit.py
index 11cecd3..83fd9b9 100644
--- a/tests/test_audit.py
+++ b/tests/test_audit.py
@@ -313,7 +313,8 @@ class TestGatedToolAudit(_AuditWiringBase):
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
- "submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
+ "commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
+ "dismissed": False}],
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index ff2f4a4..397ac81 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -972,6 +972,29 @@ class TestMergePR(unittest.TestCase):
self.assertIn("[REDACTED]", 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")
+ 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.get_auth_header", return_value=FAKE_AUTH)
def test_merge_blocked_without_visible_approval(self, _auth, mock_api):
diff --git a/tests/test_merge_approval_gate.py b/tests/test_merge_approval_gate.py
new file mode 100644
index 0000000..94f11ac
--- /dev/null
+++ b/tests/test_merge_approval_gate.py
@@ -0,0 +1,59 @@
+"""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()
\ No newline at end of file
diff --git a/tests/test_review_feedback.py b/tests/test_review_feedback.py
index 01fff0c..72cbe61 100644
--- a/tests/test_review_feedback.py
+++ b/tests/test_review_feedback.py
@@ -115,6 +115,22 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase):
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
self.assertFalse(result["has_blocking_change_requests"])
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.get_auth_header", return_value=FAKE_AUTH)
From 3496fc3952c2e68d6a3455d9b94353c5e8ab3933 Mon Sep 17 00:00:00 2001
From: jcwalker3
Date: Tue, 7 Jul 2026 23:40:12 -0500
Subject: [PATCH 34/42] feat: surface explicit adoption proof in
gitea_lock_issue response (Closes #477)
When gitea_lock_issue adopts an existing own branch, the live tool
response now carries explicit, citable adoption-proof fields so #473-style
recovery sessions can quote the lock output directly instead of inferring
adoption from separate offline checks.
- issue_lock_adoption.py: add DECISION_LABELS + decision_label()/
safe_next_action() helpers; extend build_adoption_proof() with
adoption_decision, adopted, adopted_branch, adopted_branch_head,
matcher_summary (boundary-safe reason), competing_branch_check, and
safe_next_action for all outcomes; add build_non_adoption_lock_proof()
for adoption-free NO_MATCH metadata.
- gitea_mcp_server.py: attach adoption-free adoption_check block to normal
(non-adopt) lock responses so they cannot be misread as claiming adoption.
- docs/llm-workflow-runbooks.md: document the adoption/adoption_check
response blocks and instruct recovery reports to cite them directly.
- tests: explicit-field proof for ADOPT/BLOCK_COMPETING/NO_MATCH,
substring-collision boundary safety (issue-42 vs issue-420), non-adoption
proof, and MCP-level live-response assertions.
BLOCK_COMPETING lock attempts still fail closed (raise); no raw API
fallback, branch deletion, force-push, or manual lock seeding introduced.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/llm-workflow-runbooks.md | 11 +++
gitea_mcp_server.py | 8 +++
issue_lock_adoption.py | 116 ++++++++++++++++++++++++++++++
tests/test_issue_lock_adoption.py | 89 +++++++++++++++++++++++
tests/test_mcp_server.py | 39 ++++++++++
5 files changed, 263 insertions(+)
diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md
index 634e22c..8729662 100644
--- a/docs/llm-workflow-runbooks.md
+++ b/docs/llm-workflow-runbooks.md
@@ -303,6 +303,17 @@ shared state and manual writes can clobber another session's live lease. Use
3. Operator override only when explicitly authorized — record
`External-state mutations` and `operator override proof` in the final report.
+**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
+adopts an existing own branch, the response carries an `adoption` block with
+citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
+`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
+the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
+lock instead returns an `adoption_check` block with `adoption_decision`
+(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
+as claiming adoption. Recovery reports should quote the live lock response
+`adoption`/`adoption_check` block directly instead of inferring adoption from
+separate offline checks.
+
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
under `External-state mutations: none` or mix author PR creation with reviewer
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 37a8e9b..a4e2841 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -1427,6 +1427,14 @@ def gitea_lock_issue(
f"Adopted existing branch '{branch_name}' and locked issue "
f"#{issue_number} for recovery (fail-closed check complete)."
)
+ else:
+ # #477 AC2: normal (no-adoption) lock responses carry explicit,
+ # adoption-free proof metadata so they stay clear and cannot be
+ # misread as claiming a branch was adopted.
+ result["adoption_check"] = issue_lock_adoption.build_non_adoption_lock_proof(
+ issue_number=issue_number,
+ branch_name=branch_name,
+ )
if agent_artifacts:
result["warnings"] = [
"Agent temp artifacts at repo root (delete before implementation): "
diff --git a/issue_lock_adoption.py b/issue_lock_adoption.py
index 8f8a8d6..81a5a92 100644
--- a/issue_lock_adoption.py
+++ b/issue_lock_adoption.py
@@ -20,6 +20,43 @@ ADOPT = "adopt_existing_branch"
BLOCK_COMPETING = "block_competing_branch"
NO_MATCH = "no_matching_branch"
+# Citable decision labels aligned with the ``assess_own_branch_adoption``
+# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
+# recovery reports (#473-style) can quote the lock tool output directly
+# instead of inferring adoption from separate offline checks (#477).
+DECISION_LABELS = {
+ ADOPT: "ADOPT",
+ BLOCK_COMPETING: "BLOCK_COMPETING",
+ NO_MATCH: "NO_MATCH",
+}
+
+_SAFE_NEXT_ACTIONS = {
+ ADOPT: (
+ "Own existing branch adopted for lock recovery; proceed to "
+ "gitea_create_pr for this issue and cite this adoption proof."
+ ),
+ BLOCK_COMPETING: (
+ "Competing same-issue branch(es) exist; resolve branch ownership "
+ "before locking. No adoption performed (fail closed)."
+ ),
+ NO_MATCH: (
+ "No existing branch carries this issue marker; normal lock path "
+ "applied. No adoption performed."
+ ),
+}
+
+
+def decision_label(outcome: str) -> str:
+ """Map an ``assess_own_branch_adoption`` outcome to its citable label."""
+ return DECISION_LABELS.get(outcome, "UNKNOWN")
+
+
+def safe_next_action(outcome: str) -> str:
+ """Return the safe next action string for an adoption *outcome*."""
+ return _SAFE_NEXT_ACTIONS.get(
+ outcome, "Unknown adoption outcome; treat as fail closed."
+ )
+
def _branch_name(entry) -> str:
if isinstance(entry, dict):
@@ -128,6 +165,42 @@ 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(
*,
issue_number: int,
@@ -143,7 +216,18 @@ def build_adoption_proof(
Requirement #4: adoption results must carry issue number, branch name,
branch head commit, adoption reason, no-existing-PR proof, no-competing-
live-lock proof, and lock file path/status.
+
+ #477: additionally surface explicit, citable adoption-proof fields tied to
+ the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
+ ``adopted``, ``adopted_branch``, ``adopted_branch_head``,
+ ``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
+ recovery session can quote the live lock response directly. The explicit
+ fields are populated for any outcome; ``adopted_branch`` /
+ ``adopted_branch_head`` are set only when the outcome is ADOPT so a
+ non-adoption proof can never be misread as claiming adoption.
"""
+ outcome = assessment.get("outcome")
+ adopted = outcome == ADOPT
return {
"issue_number": issue_number,
"branch_name": branch_name,
@@ -153,4 +237,36 @@ def build_adoption_proof(
"no_competing_live_lock_proof": bool(competing_lock_checked),
"lock_file_path": lock_file_path,
"lock_file_status": lock_file_status,
+ # Explicit citable fields (#477).
+ "adoption_decision": decision_label(outcome),
+ "adopted": adopted,
+ "adopted_branch": branch_name if adopted else None,
+ "adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
+ "matcher_summary": _matcher_summary(issue_number, assessment),
+ "competing_branch_check": _competing_branch_check(assessment),
+ "safe_next_action": safe_next_action(outcome),
+ }
+
+
+def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
+ """Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
+
+ Requirement #477 AC2: non-adoption lock responses must stay clear and must
+ not imply adoption. This returns explicit ``adopted: False`` metadata with
+ the ``NO_MATCH`` decision so a normal lock response can carry citable proof
+ without ever asserting a branch was adopted.
+ """
+ return {
+ "issue_number": issue_number,
+ "branch_name": branch_name,
+ "adoption_decision": DECISION_LABELS[NO_MATCH],
+ "adopted": False,
+ "adopted_branch": None,
+ "adopted_branch_head": None,
+ "matcher_summary": (
+ f"no existing branch carries the issue-{int(issue_number)} marker; "
+ f"normal lock path (no adoption)"
+ ),
+ "competing_branch_check": {"result": "clear", "competing_branches": []},
+ "safe_next_action": safe_next_action(NO_MATCH),
}
\ No newline at end of file
diff --git a/tests/test_issue_lock_adoption.py b/tests/test_issue_lock_adoption.py
index a579cbc..270248e 100644
--- a/tests/test_issue_lock_adoption.py
+++ b/tests/test_issue_lock_adoption.py
@@ -11,6 +11,7 @@ from issue_lock_adoption import ( # noqa: E402
NO_MATCH,
assess_own_branch_adoption,
build_adoption_proof,
+ build_non_adoption_lock_proof,
)
REQ = "feat/issue-420-server-code-parity"
@@ -134,5 +135,93 @@ class TestBuildAdoptionProof(unittest.TestCase):
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__":
unittest.main()
\ No newline at end of file
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index ff2f4a4..e356baa 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -3325,6 +3325,45 @@ class TestIssueLocking(unittest.TestCase):
self.assertIn("adoption", res)
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(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
From d4dc9aa854b9e198306e856bf015f8f66720f7c8 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 01:26:36 -0400
Subject: [PATCH 35/42] fix: align test mocks with #399 lease gates and head
SHA requirements
PR #416 introduced pr_work_lease reviewer blocks and mandatory full
40-hex expected_head_sha on mark/merge paths. Update audit, review,
permission-report, terminal hard-stop, inventory, and schema tests to
patch lease comment fetches, seed ready review decisions, and use
consistent HEAD_SHA constants so the full suite passes again.
---
tests/test_audit.py | 44 +++++++-----
tests/test_llm_agent_sha.py | 12 ++--
tests/test_mcp_server.py | 85 ++++++++++++++++++++----
tests/test_permission_reports.py | 8 ++-
tests/test_pr_queue_inventory.py | 32 +++++----
tests/test_review_final_report_schema.py | 8 ++-
tests/test_terminal_review_hard_stop.py | 29 +++++---
7 files changed, 159 insertions(+), 59 deletions(-)
diff --git a/tests/test_audit.py b/tests/test_audit.py
index 11cecd3..8193e3e 100644
--- a/tests/test_audit.py
+++ b/tests/test_audit.py
@@ -284,21 +284,38 @@ class TestSimpleToolAudit(_AuditWiringBase):
self.assertEqual(result["number"], 9)
+_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
+
+
class TestGatedToolAudit(_AuditWiringBase):
def setUp(self):
super().setUp()
+ from mcp_server import init_review_decision_lock
from tests.test_mcp_server import _install_owned_reviewer_lease
import reviewer_pr_lease
+ # init_review_decision_lock clears any prior session lease (#407).
+ init_review_decision_lock("prgs", "review_pr")
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):
@@ -349,7 +366,9 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
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 = [
+ {"login": "reviewer-bot"}, self._pr("author-bot"),
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
@@ -358,23 +377,16 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True):
- from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
- from tests.test_mcp_server import _install_owned_reviewer_lease
- import reviewer_pr_lease
+ from mcp_server import gitea_mark_final_review_decision
- init_review_decision_lock("prgs", "review_pr")
- gitea_mark_final_review_decision(8, "approve", remote="prgs")
- lease_patch = _install_owned_reviewer_lease(8)
- lease_patch.start()
- try:
- r = gitea_submit_pr_review(
- pr_number=8, action="approve",
- body="LGTM", remote="prgs",
- final_review_decision_ready=True,
- )
- finally:
- lease_patch.stop()
- reviewer_pr_lease.clear_session_lease()
+ gitea_mark_final_review_decision(
+ 8, "approve", expected_head_sha="abc123", remote="prgs",
+ )
+ r = gitea_submit_pr_review(
+ pr_number=8, action="approve",
+ body="LGTM", remote="prgs",
+ final_review_decision_ready=True,
+ )
self.assertTrue(r["performed"])
recs = self._records()
self.assertEqual(len(recs), 1)
diff --git a/tests/test_llm_agent_sha.py b/tests/test_llm_agent_sha.py
index 1a6ecff..4d74dfa 100644
--- a/tests/test_llm_agent_sha.py
+++ b/tests/test_llm_agent_sha.py
@@ -122,15 +122,19 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
@patch("mcp_server.api_request")
@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):
- 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 mcp_server import init_review_decision_lock
+ 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 = [
{"login": "jcwalker3"}, # /user (inventory)
{"login": "jcwalker3"}, # /user (submit eligibility)
- {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
+ {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
]
- 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(9, "approve", remote="prgs")
+ with patch("mcp_server._list_pr_lease_comments", return_value=[]):
+ _seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
with patch.dict(os.environ, env, clear=True):
r = gitea_review_pr(
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index bdb8eb4..93e1274 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -47,6 +47,7 @@ import gitea_config # noqa: E402
import mcp_server
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
+FULL_HEAD_SHA = "a" * 40
_NO_BLOCKER_FEEDBACK = {
"success": True,
@@ -139,6 +140,45 @@ def _install_owned_reviewer_lease(
)
+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,
+ })
+
+
# Issue-write tools are profile-gated (#69).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
@@ -1104,16 +1144,18 @@ class TestReviewPR(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
- 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"}}]
+ head_sha = FULL_HEAD_SHA
+ 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.side_effect = [
{"login": "jcwalker3"}, # /api/v1/user (inventory)
{"login": "jcwalker3"}, # /api/v1/user (submit eligibility)
- {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/1
+ {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/1
]
from mcp_server import init_review_decision_lock
init_review_decision_lock("prgs", "review_pr")
- gitea_mark_final_review_decision(1, "approve", remote="prgs", expected_head_sha="abc123")
+ with patch("mcp_server._list_pr_lease_comments", return_value=[]):
+ _seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
result = gitea_review_pr(
pr_number=1,
event="APPROVE",
@@ -1769,7 +1811,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
"""Block incidental live review mutations during validation."""
PR = 203
- SHA = "abc123"
+ SHA = FULL_HEAD_SHA
def _pr(self, author, sha=SHA):
return {
@@ -1798,7 +1840,13 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
"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):
@@ -2406,6 +2454,11 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
self.mock_audit = patch("gitea_audit.write_event").start()
# 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._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):
patch.stopall()
@@ -2452,7 +2505,8 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
def test_merge_pr_with_closes_removes_label(self):
import reviewer_pr_lease
- lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123")
+ 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)
@@ -2461,12 +2515,12 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and url.endswith("/reviews"):
- return [_formal_review("reviewer", "APPROVED", sha="sha123")]
+ return [_formal_review("reviewer", "APPROVED", sha=head_sha)]
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
"state": "open",
- "head": {"sha": "sha123", "ref": "feat/my-branch"},
+ "head": {"sha": head_sha, "ref": "feat/my-branch"},
"base": {"ref": "main"},
"mergeable": True,
"merged_commit_sha": "merge123",
@@ -2486,14 +2540,18 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
return {}
self.mock_api.side_effect = api_side_effect
- res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
+ res = gitea_merge_pr(
+ pr_number=1, confirmation="MERGE PR 1", do="merge",
+ expected_head_sha=head_sha,
+ )
self.assertTrue(res["performed"])
self.assertEqual(res["cleanup_status"].get(123), "released")
def test_merge_pr_with_branch_name_removes_label(self):
import reviewer_pr_lease
- lease_patch = _install_owned_reviewer_lease(1, head_sha="sha123")
+ 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)
@@ -2502,12 +2560,12 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and url.endswith("/reviews"):
- return [_formal_review("reviewer", "APPROVED", sha="sha123")]
+ return [_formal_review("reviewer", "APPROVED", sha=head_sha)]
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
"state": "open",
- "head": {"sha": "sha123", "ref": "fix/issue-123-slug"},
+ "head": {"sha": head_sha, "ref": "fix/issue-123-slug"},
"base": {"ref": "main"},
"mergeable": True,
"merged_commit_sha": "merge123",
@@ -2527,7 +2585,10 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
return {}
self.mock_api.side_effect = api_side_effect
- res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
+ res = gitea_merge_pr(
+ pr_number=1, confirmation="MERGE PR 1", do="merge",
+ expected_head_sha=head_sha,
+ )
self.assertTrue(res["performed"])
self.assertEqual(res["cleanup_status"].get(123), "released")
diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py
index 5483ee1..279dc2b 100644
--- a/tests/test_permission_reports.py
+++ b/tests/test_permission_reports.py
@@ -230,7 +230,9 @@ class TestEligibilityDenialReport(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
- mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
+ from tests.test_mcp_server import _seed_ready_review_decision
+
+ _seed_ready_review_decision(42, "approve", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="approve", body="lgtm", remote="prgs",
@@ -272,7 +274,9 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
- mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
+ from tests.test_mcp_server import _seed_ready_review_decision
+
+ _seed_ready_review_decision(42, "comment", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="comment", body="finding", remote="prgs",
diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py
index 1e5a3c5..a61cc3f 100644
--- a/tests/test_pr_queue_inventory.py
+++ b/tests/test_pr_queue_inventory.py
@@ -128,18 +128,29 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"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([
- {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
+ {"number": 1, "title": "PR 1", "state": "open",
+ "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 = [
{"login": "reviewer1"},
{"login": "reviewer1"},
{
"user": {"login": "other_user"},
"state": "open",
- "head": {"sha": "abc1"},
+ "head": {"sha": head_sha},
"mergeable": True,
},
{"id": 100, "state": "APPROVED"},
@@ -148,22 +159,19 @@ class TestPRQueueInventory(unittest.TestCase):
"id": 100,
"user": {"login": "reviewer1"},
"state": "APPROVED",
- "commit_id": "abc1",
+ "commit_id": head_sha,
"submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False,
}
],
]
- from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
- from tests.test_mcp_server import _install_owned_reviewer_lease
- import reviewer_pr_lease
-
- with patch("mcp_server._authenticated_username", return_value="reviewer1"):
+ with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
+ patch("mcp_server._list_pr_lease_comments", return_value=[]):
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")
lease_patch = _install_owned_reviewer_lease(
- 1, head_sha="abc1", session_id="inventory-review-lease",
+ 1, head_sha=head_sha, session_id="inventory-review-lease",
)
lease_patch.start()
self.addCleanup(lease_patch.stop)
diff --git a/tests/test_review_final_report_schema.py b/tests/test_review_final_report_schema.py
index fbcb5a2..3b2211e 100644
--- a/tests/test_review_final_report_schema.py
+++ b/tests/test_review_final_report_schema.py
@@ -19,7 +19,11 @@ def _minimal_review_report(**overrides):
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
- "- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
+ "- Validation: pass: 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",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
@@ -80,7 +84,7 @@ class TestReviewFinalReportSchema(unittest.TestCase):
def test_reviewed_head_without_validation_blocks(self):
report = _minimal_review_report().replace(
- "- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
+ "- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: not run",
)
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
diff --git a/tests/test_terminal_review_hard_stop.py b/tests/test_terminal_review_hard_stop.py
index bd5109b..4c65130 100644
--- a/tests/test_terminal_review_hard_stop.py
+++ b/tests/test_terminal_review_hard_stop.py
@@ -12,6 +12,8 @@ from unittest.mock import patch
import mcp_server
+HEAD_SHA = "a" * 40
+
def _lock(mutations=None, correction=False):
return {
@@ -127,10 +129,20 @@ def _feedback(blocking, stale=False, success=True):
"success": success,
"has_blocking_change_requests": blocking,
"review_feedback_stale": stale,
- "current_head_sha": "abc123",
+ "current_head_sha": HEAD_SHA,
}
+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):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
@@ -139,8 +151,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=False)):
- result = mcp_server.gitea_mark_final_review_decision(
- pr_number=6, action="request_changes", remote="prgs")
+ result = _mark("request_changes")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("duplicate" in r for r in result["reasons"]),
@@ -150,16 +161,14 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=True)):
- result = mcp_server.gitea_mark_final_review_decision(
- pr_number=6, action="request_changes", remote="prgs")
+ result = _mark("request_changes")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_allowed_when_no_blocker(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False)):
- result = mcp_server.gitea_mark_final_review_decision(
- pr_number=6, action="request_changes", remote="prgs")
+ result = _mark("request_changes")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_fails_closed_when_feedback_unavailable(self):
@@ -167,8 +176,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False,
success=False)):
- result = mcp_server.gitea_mark_final_review_decision(
- pr_number=6, action="request_changes", remote="prgs")
+ result = _mark("request_changes")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("could not verify" in r for r in result["reasons"]),
@@ -178,8 +186,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
side_effect=AssertionError("must not be called")):
- result = mcp_server.gitea_mark_final_review_decision(
- pr_number=6, action="approve", remote="prgs")
+ result = _mark("approve")
self.assertTrue(result["marked_ready"], result.get("reasons"))
From 08202f7eaa6d09b2eca8f2960126994a4b22646b Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 02:16:52 -0400
Subject: [PATCH 36/42] fix: guard PR/issue comment listing against non-list
API payloads (Closes #485)
_list_pr_lease_comments and gitea_list_issue_comments now fail safe to an
empty list when the comments API returns a non-list payload (e.g. an error
object), preventing KeyError: slice on the lease and listing paths.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 14 +++-
.../test_pr_lease_comments_non_list_guard.py | 76 +++++++++++++++++++
2 files changed, 88 insertions(+), 2 deletions(-)
create mode 100644 tests/test_pr_lease_comments_non_list_guard.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 9ea9050..cbc999d 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -2515,7 +2515,12 @@ def _list_pr_lease_comments(
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
- comments = api_request("GET", api, auth) or []
+ comments = api_request("GET", api, auth)
+ # Fail safe to no lease comments when the API returns a non-list payload
+ # (e.g. an error object such as an HTTP 401 body): lease state can only be
+ # proven from real comment entries, never inferred from an error shape (#485).
+ if not isinstance(comments, list):
+ return []
return list(comments[:limit])
@@ -5119,7 +5124,12 @@ def gitea_list_issue_comments(
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
- comments = api_request("GET", api, auth) or []
+ comments = api_request("GET", api, auth)
+ # Fail safe to no comments when the API returns a non-list payload (e.g. an
+ # error object such as an HTTP 401 body) so listing never crashes on a
+ # malformed response (#485).
+ if not isinstance(comments, list):
+ comments = []
reveal = _reveal_endpoints()
out = []
for c in comments[:limit]:
diff --git a/tests/test_pr_lease_comments_non_list_guard.py b/tests/test_pr_lease_comments_non_list_guard.py
new file mode 100644
index 0000000..08c9239
--- /dev/null
+++ b/tests/test_pr_lease_comments_non_list_guard.py
@@ -0,0 +1,76 @@
+"""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": ""}
+ 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()
\ No newline at end of file
From 0cbd1fc801e7490a8faa9b21b89cf51d269c9830 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 02:22:54 -0400
Subject: [PATCH 37/42] feat: add first-class stacked PR support to author
workflow (#484)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Normal author work is unchanged: gitea_lock_issue still requires the worktree
to be base-equivalent to master/main/dev, and gitea_create_pr still targets a
normal base branch. A *stacked* PR (based on another unmerged PR's branch) is a
new explicit, proof-backed path — it never bypasses the issue lock.
- stacked_pr_support.py: pure policy. Approve a non-master base only when it is
declared AND owned by a live OPEN PR whose number matches; reject arbitrary,
mismatched, or stale (merged/closed) branches; require the PR body to document
the stack (base branch, stacked-on PR, merge ordering).
- issue_lock_worktree.py: read_worktree_git_state / _find_matching_base_ref gain
an opt-in extra_bases arg so an approved stacked base can anchor
base-equivalence in addition to master/main/dev (empty by default = unchanged).
- gitea_mcp_server.py:
- gitea_lock_issue gains stacked_base_branch + stacked_base_pr. When declared,
it verifies the open PR owns the branch, anchors base-equivalence to it, and
records approved_stacked_base = {branch, pr_number, verified_open} on the lock.
- gitea_create_pr validates a non-master base against the lock's approved
stacked base, re-checks the dependency PR is still open, and enforces the
stacked-PR body fields. Master-based path untouched.
- Docs: llm-workflow-runbooks.md and work-issue.md document when stacked PRs are
allowed and the required PR-body wording.
Tests: test_stacked_pr_support.py (18 policy cases), stacked base-equivalence in
test_issue_lock_worktree.py, approved_stacked_base persistence round-trip in
test_issue_lock_store.py. Existing lock/create_pr regressions still pass.
The #482 -> #479/#478 case motivates this: create_pr previously could not open a
stacked PR because the lock required a master-equivalent worktree.
Closes #484.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
docs/llm-workflow-runbooks.md | 22 ++
gitea_mcp_server.py | 75 ++++++-
issue_lock_worktree.py | 31 ++-
.../workflows/work-issue.md | 8 +
stacked_pr_support.py | 211 ++++++++++++++++++
tests/test_issue_lock_store.py | 24 ++
tests/test_issue_lock_worktree.py | 53 +++++
tests/test_stacked_pr_support.py | 185 +++++++++++++++
8 files changed, 603 insertions(+), 6 deletions(-)
create mode 100644 stacked_pr_support.py
create mode 100644 tests/test_stacked_pr_support.py
diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md
index 634e22c..e0ff616 100644
--- a/docs/llm-workflow-runbooks.md
+++ b/docs/llm-workflow-runbooks.md
@@ -286,6 +286,28 @@ heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
work. An expired lease still blocks takeover until a recovery review records why
the prior work is abandoned, completed, or unsafe to continue.
+**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
+`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
+based on another unmerged PR's branch) is an explicit, opt-in path: pass
+`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
+fails closed unless that branch is owned by a live **open** PR whose number
+matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
+bases. When approved, the lock payload records
+`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
+be base-equivalent to that branch instead of master. `gitea_create_pr` then
+allows `base = ` only when it matches the recorded approval, the
+dependency PR is **still open**, and the PR body documents the stack:
+
+- `Stacked on PR # / issue #`
+- `Base branch: `
+- `Head branch: `
+- `Do not merge before PR #` (merge ordering)
+- retarget/rebase to `master` after the dependency lands, if required
+
+Stacked support never bypasses the issue lock — the base is recorded *on* the
+lock and re-verified at PR time. A merged/closed dependency base fails closed;
+retarget onto `master` or re-lock against a live base.
+
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
recovery path.** That global slot is deprecated and can clobber unrelated live
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 9ea9050..b5681e9 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -535,6 +535,7 @@ import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402
+import stacked_pr_support # noqa: E402
import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
@@ -1262,6 +1263,16 @@ def gitea_create_issue(
@mcp.tool()
+def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
+ """Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484)."""
+ try:
+ return api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) or []
+ except Exception as exc: # fail closed: no proof of an open dependency PR
+ raise RuntimeError(
+ f"Could not list open pull requests to verify stacked base: {exc}"
+ )
+
+
def gitea_lock_issue(
issue_number: int,
branch_name: str,
@@ -1270,6 +1281,8 @@ def gitea_lock_issue(
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
+ stacked_base_branch: str | None = None,
+ stacked_base_pr: int | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
@@ -1282,6 +1295,15 @@ def gitea_lock_issue(
repo: Override Repo.
worktree_path: Author scratch-clone path to validate (defaults to
GITEA_AUTHOR_WORKTREE or the MCP server project root).
+ stacked_base_branch: Opt-in. Declare a non-master base branch for a
+ *stacked* PR (a PR based on another unmerged PR's branch). Normal work
+ leaves this ``None`` and stays master-equivalent. When set, the
+ worktree may be base-equivalent to this branch instead of
+ master/main/dev, and the approved base is recorded on the lock (#484).
+ stacked_base_pr: Required when ``stacked_base_branch`` is set. The number
+ of the OPEN pull request that owns the stacked base branch. The lock
+ fails closed unless this open PR exists and owns that branch, so
+ arbitrary or stale branches cannot be used as stacked bases.
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
@@ -1309,7 +1331,31 @@ def gitea_lock_issue(
if active_lease_block:
raise RuntimeError(active_lease_block)
- git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
+ # ── Stacked-PR base declaration (opt-in, #484) ──
+ # Normal work leaves stacked_base_branch None → master-equivalent path.
+ # A declared stacked base must be proven to own an OPEN PR before it can
+ # anchor base-equivalence; this never bypasses the lock.
+ stacked_extra_bases: tuple[str, ...] = ()
+ stacked_approved: dict | None = None
+ if stacked_base_branch:
+ stacked_assessment = stacked_pr_support.assess_stacked_base_declaration(
+ stacked_base_branch=stacked_base_branch,
+ stacked_base_pr=stacked_base_pr,
+ open_prs=_list_open_pulls(h, o, r, _auth(h)),
+ )
+ if stacked_assessment["block"]:
+ raise RuntimeError(
+ "; ".join(stacked_assessment["reasons"]) + " (fail closed)"
+ )
+ stacked_approved = stacked_assessment["approved"]
+ stacked_extra_bases = (stacked_approved["branch"],)
+
+ if stacked_extra_bases:
+ git_state = issue_lock_worktree.read_worktree_git_state(
+ resolved_worktree, extra_bases=stacked_extra_bases
+ )
+ else:
+ git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree)
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
@@ -1382,6 +1428,8 @@ def gitea_lock_issue(
claimant=work_lease.get("claimant"),
),
}
+ if stacked_approved:
+ data["approved_stacked_base"] = stacked_approved
lock_file_path = _save_issue_lock(data)
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
@@ -1415,6 +1463,13 @@ def gitea_lock_issue(
"lock_freshness": freshness,
"lock_proof": lock_proof,
}
+ if stacked_approved:
+ result["approved_stacked_base"] = stacked_approved
+ result["message"] = (
+ f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
+ f"as a STACKED PR on base '{stacked_approved['branch']}' "
+ f"(open PR #{stacked_approved['pr_number']}); fail-closed check complete."
+ )
if adoption["adopt"]:
result["adoption"] = issue_lock_adoption.build_adoption_proof(
issue_number=issue_number,
@@ -1571,6 +1626,24 @@ def gitea_create_pr(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
+ # ── Stacked-PR base validation (#484) ──
+ # Normal base branches (master/main/dev) pass unchanged. A non-base target 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. This never bypasses the lock.
+ base_open_prs = (
+ []
+ if stacked_pr_support.is_base_branch(base)
+ else _list_open_pulls(h, o, r, _auth(h))
+ )
+ base_check = stacked_pr_support.assess_create_pr_base(
+ base=base,
+ approved_stacked_base=lock_data.get("approved_stacked_base"),
+ body=body,
+ open_prs=base_open_prs,
+ )
+ if base_check["block"]:
+ raise ValueError("; ".join(base_check["reasons"]) + " (fail closed)")
+
duplicate_block = _enforce_locked_issue_duplicate_recheck(
remote,
issue_work_duplicate_gate.PHASE_CREATE_PR,
diff --git a/issue_lock_worktree.py b/issue_lock_worktree.py
index 5d9dc4a..3c23134 100644
--- a/issue_lock_worktree.py
+++ b/issue_lock_worktree.py
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
return os.path.realpath(os.path.abspath(path))
-def read_worktree_git_state(worktree_path: str) -> dict:
- """Read branch name and porcelain status from a git worktree."""
+def read_worktree_git_state(
+ worktree_path: str,
+ extra_bases: tuple[str, ...] | list[str] = (),
+) -> dict:
+ """Read branch name and porcelain status from a git worktree.
+
+ ``extra_bases`` names additional branches (e.g. an approved stacked base)
+ that may anchor base-equivalence in addition to master/main/dev. When empty
+ (the default), only the normal base branches are considered.
+ """
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
- base_branch, base_sha = _find_matching_base_ref(path, head_sha)
+ base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
@@ -203,13 +211,26 @@ def _assessment(
}
-def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
- """Return the stable branch ref whose commit matches HEAD, if any."""
+def _find_matching_base_ref(
+ path: str,
+ head_sha: str | None,
+ extra_bases: tuple[str, ...] | list[str] = (),
+) -> tuple[str | None, str | None]:
+ """Return the stable branch ref whose commit matches HEAD, if any.
+
+ Normal base branches (master/main/dev) are always considered. ``extra_bases``
+ adds explicitly-approved stacked bases; each is checked as a local ref and via
+ the ``prgs``/``origin`` remotes.
+ """
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
+ for branch in extra_bases:
+ name = (branch or "").strip()
+ if name:
+ candidates.extend((f"prgs/{name}", f"origin/{name}", name))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md
index ceef51c..6766523 100644
--- a/skills/llm-project-workflow/workflows/work-issue.md
+++ b/skills/llm-project-workflow/workflows/work-issue.md
@@ -144,6 +144,14 @@ 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.
+### 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=, stacked_base_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=)`. The body must state: `Stacked on PR # / issue #`, `Base branch: `, `Head branch: `, `Do not merge before PR #`, 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
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
diff --git a/stacked_pr_support.py b/stacked_pr_support.py
new file mode 100644
index 0000000..e3cab47
--- /dev/null
+++ b/stacked_pr_support.py
@@ -0,0 +1,211 @@
+"""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 #')")
+ 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}
diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py
index d87fc73..3928b19 100644
--- a/tests/test_issue_lock_store.py
+++ b/tests/test_issue_lock_store.py
@@ -157,6 +157,30 @@ class TestIssueLockStore(unittest.TestCase):
)
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):
path_a = ils.lock_file_path(
remote="prgs",
diff --git a/tests/test_issue_lock_worktree.py b/tests/test_issue_lock_worktree.py
index 02347f4..67baa78 100644
--- a/tests/test_issue_lock_worktree.py
+++ b/tests/test_issue_lock_worktree.py
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
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):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
diff --git a/tests/test_stacked_pr_support.py b/tests/test_stacked_pr_support.py
new file mode 100644
index 0000000..e592fc1
--- /dev/null
+++ b/tests/test_stacked_pr_support.py
@@ -0,0 +1,185 @@
+"""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()
From afb4ba563cb0eb293d6554d10b060faf84ca7bd0 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:47:30 -0400
Subject: [PATCH 38/42] fix: preserve capability preflight across interleaved
read-only whoami (#469)
Read-only gitea_whoami calls no longer clear a valid capability proof when
whoami was already verified clean. Capability is consumed on mutation (one
resolve per mutation), and verify_preflight_purity rejects task mismatches.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 122 +++++++++++++++++++-------
tests/test_preflight_read_survival.py | 98 +++++++++++++++++++++
2 files changed, 190 insertions(+), 30 deletions(-)
create mode 100644 tests/test_preflight_read_survival.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index b5681e9..8d994ac 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -165,6 +165,7 @@ _preflight_capability_called = False
_preflight_whoami_violation = False
_preflight_capability_violation = False
_preflight_resolved_role = None
+_preflight_resolved_task: str | None = None
_process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None
@@ -379,11 +380,30 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
}
-def record_preflight_check(type_name: str, resolved_role: str | None = None):
+def _clear_preflight_capability_state() -> None:
+ """Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
+ global _preflight_capability_called, _preflight_capability_violation
+ global _preflight_resolved_task
+ global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
+ global _preflight_reviewer_violation_files
+
+ _preflight_capability_called = False
+ _preflight_capability_violation = False
+ _preflight_capability_violation_files = []
+ _preflight_capability_baseline_porcelain = None
+ _preflight_resolved_task = None
+ _preflight_reviewer_violation_files = []
+
+
+def record_preflight_check(
+ type_name: str,
+ resolved_role: str | None = None,
+ resolved_task: str | None = None,
+):
"""Record a pre-flight check (whoami or capability) with session-scoped deltas."""
global _preflight_whoami_called, _preflight_capability_called
global _preflight_whoami_violation, _preflight_capability_violation
- global _preflight_resolved_role
+ global _preflight_resolved_role, _preflight_resolved_task
global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
global _preflight_whoami_violation_files, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
@@ -391,13 +411,24 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
current = _get_workspace_porcelain()
if type_name == "whoami":
- # Fresh whoami restarts the capability step and re-evaluates violations
- # instead of replaying a sticky record (#252).
- _preflight_capability_called = False
- _preflight_capability_violation = False
- _preflight_capability_violation_files = []
- _preflight_capability_baseline_porcelain = None
- _preflight_reviewer_violation_files = []
+ # Re-evaluate whoami violations instead of replaying sticky state (#252).
+ # Interleaved read-only whoami must not clear a valid capability proof (#469).
+ saved_capability = None
+ preserve_capability = (
+ _preflight_capability_called
+ and _preflight_whoami_called
+ and not _preflight_whoami_violation
+ )
+ if preserve_capability:
+ saved_capability = (
+ _preflight_capability_violation,
+ list(_preflight_capability_violation_files),
+ _preflight_capability_baseline_porcelain,
+ _preflight_resolved_role,
+ _preflight_resolved_task,
+ )
+ else:
+ _clear_preflight_capability_state()
process_start = _ensure_process_start_porcelain()
whoami_delta = _new_tracked_changes_since(process_start, current)
@@ -405,6 +436,20 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_whoami_violation_files = whoami_delta
_preflight_whoami_baseline_porcelain = current
_preflight_whoami_called = True
+
+ if (
+ preserve_capability
+ and saved_capability is not None
+ and not whoami_delta
+ ):
+ (
+ _preflight_capability_violation,
+ _preflight_capability_violation_files,
+ _preflight_capability_baseline_porcelain,
+ _preflight_resolved_role,
+ _preflight_resolved_task,
+ ) = saved_capability
+ _preflight_capability_called = True
elif type_name == "capability":
baseline = _preflight_whoami_baseline_porcelain or ""
capability_delta = _new_tracked_changes_since(baseline, current)
@@ -414,6 +459,8 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_capability_called = True
if resolved_role:
_preflight_resolved_role = resolved_role
+ if resolved_task:
+ _preflight_resolved_task = resolved_task
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
@@ -434,7 +481,11 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
)
-def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
+def verify_preflight_purity(
+ remote: str | None = None,
+ worktree_path: str | None = None,
+ task: str | None = None,
+):
"""Verify that identity and capability were verified prior to session edits."""
global _preflight_reviewer_violation_files
@@ -453,6 +504,16 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
raise RuntimeError(
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
+ if (
+ task is not None
+ and _preflight_resolved_task is not None
+ and task != _preflight_resolved_task
+ ):
+ raise RuntimeError(
+ "Pre-flight task mismatch: "
+ f"resolved '{_preflight_resolved_task}' but mutation requires "
+ f"'{task}' (fail closed)"
+ )
ctx = _resolve_author_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
@@ -508,6 +569,7 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
)
_enforce_branches_only_author_mutation(worktree_path)
+ _clear_preflight_capability_state()
from mcp.server.fastmcp import FastMCP # noqa: E402
@@ -1223,7 +1285,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
- verify_preflight_purity(remote, worktree_path=worktree_path)
+ verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
@@ -1356,7 +1418,7 @@ def gitea_lock_issue(
)
else:
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
- verify_preflight_purity(remote, worktree_path=resolved_worktree)
+ verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue")
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
current_branch=git_state.get("current_branch"),
@@ -1572,7 +1634,7 @@ def gitea_create_pr(
)
if blocked:
return blocked
- verify_preflight_purity(remote, worktree_path=worktree_path)
+ verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
@@ -2633,7 +2695,7 @@ def _evaluate_pr_review_submission(
final_review_decision_ready: bool = False,
) -> dict:
"""Shared gate chain for live submit and dry-run review tools."""
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="review_pr")
action = (action or "").strip().lower()
result = {
"requested_action": action,
@@ -3140,12 +3202,12 @@ def gitea_edit_pr(
if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
- verify_preflight_purity(remote)
+ closing = payload.get("state") == "closed"
+ verify_preflight_purity(remote, task="close_pr" if closing else None)
# PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
# never touches the network.
- closing = payload.get("state") == "closed"
if closing:
gate_reasons = _profile_operation_gate("gitea.pr.close")
if gate_reasons:
@@ -3390,7 +3452,7 @@ def gitea_commit_files(
branch="",
)
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="commit_files")
processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo)
@@ -3491,7 +3553,7 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="merge_pr")
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -4020,7 +4082,7 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="delete_branch")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
@@ -4129,7 +4191,7 @@ def gitea_reconcile_merged_cleanups(
report["executed"] = False
return {"success": True, "performed": False, **report}
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="reconcile_merged_cleanups")
actions: list[dict] = []
for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or ""
@@ -4443,7 +4505,7 @@ def gitea_reconcile_already_landed_pr(
)
return result
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="reconcile_already_landed_pr")
if post_comment and comment_body.strip():
comment_block = _profile_operation_gate("gitea.pr.comment")
@@ -4546,7 +4608,7 @@ def gitea_close_issue(
task_capability_map.required_permission("close_issue"))
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="close_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
@@ -4974,7 +5036,7 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"),
}
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
@@ -5075,7 +5137,7 @@ def gitea_heartbeat_reviewer_pr_lease(
],
}
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
body = reviewer_pr_lease.format_lease_body(
@@ -5246,7 +5308,7 @@ def gitea_create_issue_comment(
(permission blocks also carry a structured 'permission_report',
#142).
"""
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="comment_issue")
gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
@@ -6780,7 +6842,7 @@ def gitea_mark_issue(
task_capability_map.required_permission("mark_issue"))
if blocked:
return blocked
- verify_preflight_purity(remote, worktree_path=worktree_path)
+ verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -6858,7 +6920,7 @@ def gitea_post_heartbeat(
task_capability_map.required_permission("post_heartbeat"))
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="post_heartbeat")
active_profile = profile or get_profile().get("profile_name")
body = issue_claim_heartbeat.format_heartbeat_body(
kind="progress",
@@ -7101,7 +7163,7 @@ def gitea_cleanup_stale_claims(
task_capability_map.required_permission("cleanup_stale_claims"))
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="cleanup_stale_claims")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -7255,7 +7317,7 @@ def gitea_set_issue_labels(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
- verify_preflight_purity(remote)
+ verify_preflight_purity(remote, task="set_issue_labels")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -7492,7 +7554,7 @@ def gitea_resolve_task_capability(
"exact_safe_next_action": next_safe_action,
}
- record_preflight_check("capability", required_role)
+ record_preflight_check("capability", required_role, resolved_task=task)
# Try automatic dispatch switching
_ensure_matching_profile(required_permission, required_role, remote, host)
diff --git a/tests/test_preflight_read_survival.py b/tests/test_preflight_read_survival.py
new file mode 100644
index 0000000..26156a2
--- /dev/null
+++ b/tests/test_preflight_read_survival.py
@@ -0,0 +1,98 @@
+"""#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._process_start_porcelain = ""
+
+ 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()
\ No newline at end of file
From 3bce0a55fb53456f5b946785c1f8711f254967b0 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Tue, 7 Jul 2026 17:49:06 -0400
Subject: [PATCH 39/42] test: reset preflight globals in read-survival setUp
(#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_preflight_read_survival.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/tests/test_preflight_read_survival.py b/tests/test_preflight_read_survival.py
index 26156a2..6da8af6 100644
--- a/tests/test_preflight_read_survival.py
+++ b/tests/test_preflight_read_survival.py
@@ -21,7 +21,15 @@ class TestPreflightReadSurvival(unittest.TestCase):
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
From 3d11e1f12b659bf28e8dde06e1ca73268786197b Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 03:57:38 -0400
Subject: [PATCH 40/42] feat: isolate MCP workspace binding across role
namespaces (Closes #510)
Namespace-scoped workspace resolution prevents GITEA_AUTHOR_WORKTREE from
poisoning reviewer, merger, and reconciler purity checks. Each role uses
its own worktree env var with actionable binding-source errors and safe
reconnect guidance. Preserves #274/#475 author and control-checkout guards.
---
.env.example | 9 +
author_mutation_worktree.py | 2 +
docs/llm-workflow-runbooks.md | 39 +++
gitea_mcp_server.py | 217 ++++++++++++---
namespace_workspace_binding.py | 307 ++++++++++++++++++++++
tests/test_mcp_server.py | 11 +-
tests/test_namespace_workspace_binding.py | 240 +++++++++++++++++
7 files changed, 777 insertions(+), 48 deletions(-)
create mode 100644 namespace_workspace_binding.py
create mode 100644 tests/test_namespace_workspace_binding.py
diff --git a/.env.example b/.env.example
index 5463787..4777fac 100644
--- a/.env.example
+++ b/.env.example
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
# profile's values. Leave unset for pure env-based configuration.
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
GITEA_MCP_PROFILE=prgs
+
+# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
+# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
+# merger process) are ignored.
+# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
+# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
+# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
+# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
+# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
diff --git a/author_mutation_worktree.py b/author_mutation_worktree.py
index 29dc05d..dccb94a 100644
--- a/author_mutation_worktree.py
+++ b/author_mutation_worktree.py
@@ -12,6 +12,8 @@ import subprocess
BASE_BRANCHES = frozenset({"master", "main", "dev"})
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
+# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
+# via namespace_workspace_binding (#510).
def _normalize_path(path: str) -> str:
diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md
index d67769d..cb904c4 100644
--- a/docs/llm-workflow-runbooks.md
+++ b/docs/llm-workflow-runbooks.md
@@ -823,6 +823,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
+## Namespace workspace binding (#510)
+
+Each MCP namespace resolves its **own** active task workspace. Foreign role
+worktree environment variables must not poison another namespace's purity
+checks.
+
+| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
+|-----------|------------------------------------------------------------------|---------------|
+| author | `GITEA_AUTHOR_WORKTREE` | `branches/` worktree only (#274) |
+| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/` worktree |
+| merger | `GITEA_MERGER_WORKTREE` | clean `branches/` worktree **or** clean control checkout |
+| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/` worktree **or** clean control checkout |
+
+`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
+MCP processes ignore it even when it points at a dirty author WIP tree.
+
+### Safe reconnect / rebind procedure
+
+When a mutation blocks on workspace binding:
+
+1. Read the error — it names the **resolved workspace path**, **role
+ namespace**, and **binding source** (tool arg, env var, or process root).
+2. Reconnect or relaunch the correct namespace MCP server from the intended
+ workspace (or set the role-specific env var before launch).
+3. Pass `worktree_path` on reviewer/merger mutation tools when the active
+ branches/ worktree differs from the MCP process root.
+4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
+ own namespace — that destroys another agent's WIP.
+
+### CTH guidance for workspace binding blockers
+
+When posting a Canonical Thread Handoff after a binding blocker:
+
+- State which namespace was active (author / reviewer / merger / reconciler).
+- Quote the resolved workspace path and binding source from the error.
+- Name the safe reconnect action (relaunch MCP from `branches/...`, set
+ `GITEA_*_WORKTREE`, or pass `worktree_path`).
+- Explicitly note that foreign worktrees must not be cleaned to unblock.
+
## Safety notes
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index 411a80b..d9608b0 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -175,12 +175,76 @@ _preflight_reviewer_violation_files: list[str] = []
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
+REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
+MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
+RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
+
+import namespace_workspace_binding as nwb # noqa: E402
def _preflight_in_test_mode() -> bool:
return "pytest" in sys.modules or "unittest" in sys.modules
+def _reviewer_session_worktree() -> str | None:
+ import reviewer_pr_lease as _reviewer_pr_lease
+
+ session = _reviewer_pr_lease.get_session_lease()
+ if not session:
+ return None
+ worktree = (session.get("worktree") or "").strip()
+ return worktree or None
+
+
+def _effective_workspace_role() -> str:
+ """Resolve the namespace key used for workspace binding (#510)."""
+ profile = get_profile()
+ role = _preflight_resolved_role
+ if not role:
+ role = _role_kind(
+ profile.get("allowed_operations") or [],
+ profile.get("forbidden_operations") or [],
+ )
+ return nwb.normalize_role_kind(
+ role,
+ profile_name=profile.get("profile_name"),
+ )
+
+
+def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
+ """Resolve the namespace-scoped workspace root inspected by pre-flight guards."""
+ role = _effective_workspace_role()
+ workspace, _source = nwb.resolve_namespace_workspace(
+ role_kind=role,
+ worktree_path=worktree_path,
+ process_project_root=PROJECT_ROOT,
+ session_lease_worktree=(
+ _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
+ ),
+ profile_name=get_profile().get("profile_name"),
+ )
+ return workspace
+
+
+def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
+ """Canonical namespace workspace + repository root for guards (#460/#510)."""
+ role = _effective_workspace_role()
+ return nwb.resolve_namespace_mutation_context(
+ role_kind=role,
+ worktree_path=worktree_path,
+ process_project_root=PROJECT_ROOT,
+ session_lease_worktree=(
+ _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
+ ),
+ profile_name=get_profile().get("profile_name"),
+ )
+
+
+def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
+ """Backward-compatible alias for namespace workspace context."""
+ return _resolve_namespace_mutation_context(worktree_path)
+
+
def _ensure_process_start_porcelain() -> str:
"""Capture the shared-worktree baseline once per MCP process (#252)."""
global _process_start_porcelain
@@ -189,26 +253,6 @@ def _ensure_process_start_porcelain() -> str:
return _process_start_porcelain
-def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
- """Resolve the workspace root inspected by pre-flight guards."""
- return author_mutation_worktree.resolve_mutation_workspace(
- worktree_path,
- PROJECT_ROOT,
- active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
- author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
- )
-
-
-def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
- """Canonical workspace + repository root for runtime_context and guards (#460)."""
- return author_mutation_worktree.resolve_author_mutation_context(
- worktree_path,
- PROJECT_ROOT,
- active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
- author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV),
- )
-
-
def _get_git_root(path: str) -> str | None:
try:
res = subprocess.run(
@@ -276,7 +320,7 @@ def _format_preflight_files(files: list[str]) -> str:
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
- ctx = _resolve_author_mutation_context(worktree_path)
+ ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
inspected_root = _get_git_root(workspace)
process_root = ctx["process_project_root"]
@@ -294,6 +338,9 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
"dirty_files": list(dirty_files),
"dirty_scope": dirty_scope,
"workspace_roots_aligned": ctx["roots_aligned"],
+ "workspace_role_kind": ctx.get("workspace_role_kind"),
+ "workspace_binding_source": ctx.get("workspace_binding_source"),
+ "ignored_bindings": list(ctx.get("ignored_bindings") or []),
}
if not ctx["roots_aligned"]:
details["workspace_root_mismatch"] = (
@@ -304,13 +351,19 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
def _format_preflight_workspace_details(details: dict) -> str:
- return (
- f"MCP server process root: {details.get('mcp_server_process_root')}; "
- f"active task workspace root: {details.get('active_task_workspace_root')}; "
- f"inspected git root: {details.get('inspected_git_root')}; "
- f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}; "
- f"dirty scope: {details.get('dirty_scope')}"
- )
+ parts = [
+ f"MCP server process root: {details.get('mcp_server_process_root')}",
+ f"active task workspace root: {details.get('active_task_workspace_root')}",
+ f"workspace role: {details.get('workspace_role_kind')}",
+ f"binding source: {details.get('workspace_binding_source')}",
+ f"inspected git root: {details.get('inspected_git_root')}",
+ f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}",
+ f"dirty scope: {details.get('dirty_scope')}",
+ ]
+ ignored = details.get("ignored_bindings") or []
+ if ignored:
+ parts.append(f"ignored foreign bindings: {'; '.join(ignored)}")
+ return "; ".join(parts)
def assess_preflight_status(worktree_path: str | None = None) -> dict:
@@ -333,6 +386,25 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
"Active task workspace has tracked file edits before mutation "
f"({_format_preflight_workspace_details(workspace_details)})"
)
+ role = _effective_workspace_role()
+ if role in nwb.NON_AUTHOR_ROLES:
+ binding = nwb.assess_metadata_only_worktree_binding(
+ role_kind=role,
+ declared_worktree_path=worktree_path,
+ mutation_workspace=_resolve_preflight_workspace_path(worktree_path),
+ process_project_root=PROJECT_ROOT,
+ profile_name=get_profile().get("profile_name"),
+ )
+ if binding.get("block"):
+ reasons.append(binding["reasons"][0])
+ reasons.append(
+ nwb.format_namespace_workspace_binding_error(
+ role_kind=role,
+ workspace_path=binding["mutation_workspace"],
+ binding_source="MCP server process root (default)",
+ reasons=binding.get("reasons"),
+ )
+ )
return {
"preflight_ready": not reasons,
"preflight_block_reasons": reasons,
@@ -466,13 +538,14 @@ def record_preflight_check(
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
"""#274: author file/branch mutations must run from a branches/ worktree.
- Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a
- Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
- (#468).
+ Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
+ is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
+ (#468). Non-author namespaces use dedicated workspace env vars (#510).
"""
- if _preflight_resolved_role in ("reviewer", "reconciler"):
+ role = _effective_workspace_role()
+ if role in nwb.NON_AUTHOR_ROLES:
return
- ctx = _resolve_author_mutation_context(worktree_path)
+ ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
assessment = author_mutation_worktree.assess_author_mutation_worktree(
@@ -520,11 +593,12 @@ def verify_preflight_purity(
f"'{task}' (fail closed)"
)
- ctx = _resolve_author_mutation_context(worktree_path)
+ ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
canonical_root = ctx["canonical_repo_root"]
process_root = ctx["process_project_root"]
real_workspace = os.path.realpath(workspace)
+ role = ctx.get("workspace_role_kind") or _effective_workspace_role()
if real_workspace != process_root:
if not _preflight_in_test_mode():
@@ -541,11 +615,15 @@ def verify_preflight_purity(
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
if dirty_files:
- details = _preflight_workspace_details(workspace, dirty_files)
raise RuntimeError(
- "Pre-flight order violation: Active task workspace has tracked "
- "file edits before mutation (fail closed). "
- f"{_format_preflight_workspace_details(details)}"
+ nwb.format_namespace_workspace_binding_error(
+ role_kind=role,
+ workspace_path=workspace,
+ binding_source=ctx.get("workspace_binding_source")
+ or "unknown binding source",
+ dirty_files=dirty_files,
+ ignored_bindings=ctx.get("ignored_bindings"),
+ )
)
else:
if _preflight_whoami_violation:
@@ -561,14 +639,14 @@ def verify_preflight_purity(
f"{_format_preflight_files(_preflight_capability_violation_files)}"
)
- if _preflight_resolved_role == "reviewer":
+ if role in {"reviewer", "merger"}:
current = _get_workspace_porcelain()
baseline = _preflight_capability_baseline_porcelain or ""
reviewer_delta = _new_tracked_changes_since(baseline, current)
_preflight_reviewer_violation_files = reviewer_delta
if reviewer_delta:
raise RuntimeError(
- "Reviewer role violation: Reviewer profile is forbidden from modifying "
+ f"{role.title()} role violation: profile is forbidden from modifying "
"tracked workspace files (fail closed). Offending files: "
f"{_format_preflight_files(reviewer_delta)}"
)
@@ -576,6 +654,45 @@ def verify_preflight_purity(
_enforce_branches_only_author_mutation(worktree_path)
_clear_preflight_capability_state()
+
+def _verify_role_mutation_workspace(
+ remote: str | None = None,
+ *,
+ worktree_path: str | None = None,
+ worktree: str | None = None,
+ task: str | None = None,
+) -> str:
+ """Bind reviewer/merger mutations to the active namespace workspace (#510)."""
+ role = _effective_workspace_role()
+ git_state = issue_lock_worktree.read_worktree_git_state(
+ _resolve_preflight_workspace_path(worktree_path)
+ )
+ assessment = nwb.assess_namespace_mutation_workspace(
+ role_kind=role,
+ worktree_path=worktree_path,
+ worktree=worktree,
+ process_project_root=PROJECT_ROOT,
+ session_lease_worktree=(
+ _reviewer_session_worktree() if role in {"reviewer", "merger"} else None
+ ),
+ profile_name=get_profile().get("profile_name"),
+ current_branch=git_state.get("current_branch"),
+ )
+ if assessment["block"]:
+ raise RuntimeError(
+ nwb.format_namespace_workspace_binding_error(
+ role_kind=role,
+ workspace_path=assessment["mutation_workspace"],
+ binding_source=assessment.get("workspace_binding_source")
+ or "unknown binding source",
+ reasons=assessment.get("reasons"),
+ ignored_bindings=assessment.get("ignored_bindings"),
+ )
+ )
+ resolved = assessment["mutation_workspace"]
+ verify_preflight_purity(remote, worktree_path=resolved, task=task)
+ return resolved
+
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
@@ -2711,9 +2828,12 @@ def _evaluate_pr_review_submission(
*,
live: bool,
final_review_decision_ready: bool = False,
+ worktree_path: str | None = None,
) -> dict:
"""Shared gate chain for live submit and dry-run review tools."""
- verify_preflight_purity(remote, task="review_pr")
+ _verify_role_mutation_workspace(
+ remote, worktree_path=worktree_path, task="review_pr"
+ )
action = (action or "").strip().lower()
result = {
"requested_action": action,
@@ -3113,6 +3233,7 @@ def gitea_dry_run_pr_review(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
+ worktree_path: str | None = None,
) -> dict:
"""Validate review submission mechanics without a live PR mutation."""
return _evaluate_pr_review_submission(
@@ -3125,6 +3246,7 @@ def gitea_dry_run_pr_review(
org=org,
repo=repo,
live=False,
+ worktree_path=worktree_path,
)
@@ -3140,6 +3262,7 @@ def gitea_submit_pr_review(
org: str | None = None,
repo: str | None = None,
final_review_decision_ready: bool = False,
+ worktree_path: str | None = None,
) -> dict:
"""Gated PR review mutation: comment findings, request changes, or approve.
@@ -3158,6 +3281,7 @@ def gitea_submit_pr_review(
repo=repo,
live=True,
final_review_decision_ready=final_review_decision_ready,
+ worktree_path=worktree_path,
)
@@ -3518,6 +3642,7 @@ def gitea_merge_pr(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
+ worktree_path: str | None = None,
) -> dict:
"""Gated merge of a Gitea pull request (#16).
@@ -3564,6 +3689,10 @@ def gitea_merge_pr(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
+ worktree_path: Merger workspace path under ``branches/`` or clean
+ control checkout; defaults to ``GITEA_MERGER_WORKTREE``,
+ ``GITEA_ACTIVE_WORKTREE``, or the MCP server process root. Ignores
+ foreign ``GITEA_AUTHOR_WORKTREE`` bindings (#510).
Returns:
dict describing the attempt: performed, authenticated user, profile
@@ -3571,7 +3700,9 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
- verify_preflight_purity(remote, task="merge_pr")
+ _verify_role_mutation_workspace(
+ remote, worktree_path=worktree_path, task="merge_pr"
+ )
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -5054,7 +5185,7 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"),
}
- verify_preflight_purity(remote, task="review_pr")
+ _verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
diff --git a/namespace_workspace_binding.py b/namespace_workspace_binding.py
new file mode 100644
index 0000000..4831907
--- /dev/null
+++ b/namespace_workspace_binding.py
@@ -0,0 +1,307 @@
+"""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 [],
+ }
\ No newline at end of file
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 651b253..179159d 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -3879,7 +3879,7 @@ class TestPreflightVerification(unittest.TestCase):
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
- self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
+ self.assertIn("forbidden from modifying tracked workspace files", str(ctx.exception))
self.assertIn("reviewer_edit.py", str(ctx.exception))
# Foreign pre-existing dirty state does not block when unchanged.
@@ -3945,7 +3945,8 @@ class TestPreflightVerification(unittest.TestCase):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(worktree_path=worktree)
msg = str(ctx.exception)
- self.assertIn("active task workspace root", msg)
- self.assertIn("inspected git root", msg)
- self.assertIn("dirty files: task_file.py", msg)
- self.assertIn("dirty scope:", msg)
+ self.assertIn("resolved workspace", msg)
+ self.assertIn(worktree, msg)
+ self.assertIn("worktree_path argument", msg)
+ self.assertIn("task_file.py", msg)
+ self.assertIn("author namespace", msg)
diff --git a/tests/test_namespace_workspace_binding.py b/tests/test_namespace_workspace_binding.py
new file mode 100644
index 0000000..b9c270c
--- /dev/null
+++ b/tests/test_namespace_workspace_binding.py
@@ -0,0 +1,240 @@
+"""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))
\ No newline at end of file
From d56baa635e53f71be3a895816cecbea411f9363b Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 04:42:35 -0400
Subject: [PATCH 41/42] fix: register gitea_lock_issue as MCP tool (Closes
#521)
Move @mcp.tool() from internal _list_open_pulls helper onto
gitea_lock_issue so author sessions can discover and call the
issue lock required by gitea_create_pr.
Add regression tests for MCP registration and create_pr lock flow.
---
gitea_mcp_server.py | 2 +-
tests/test_lock_issue_mcp_registration.py | 127 ++++++++++++++++++++++
2 files changed, 128 insertions(+), 1 deletion(-)
create mode 100644 tests/test_lock_issue_mcp_registration.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index d9608b0..c2514bd 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -1446,7 +1446,6 @@ def gitea_create_issue(
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
-@mcp.tool()
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
"""Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484)."""
try:
@@ -1457,6 +1456,7 @@ def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
)
+@mcp.tool()
def gitea_lock_issue(
issue_number: int,
branch_name: str,
diff --git a/tests/test_lock_issue_mcp_registration.py b/tests/test_lock_issue_mcp_registration.py
new file mode 100644
index 0000000..4b20763
--- /dev/null
+++ b/tests/test_lock_issue_mcp_registration.py
@@ -0,0 +1,127 @@
+"""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)
\ No newline at end of file
From 2faf18a80213d7813ab36d08d0c9efa87c12d1d0 Mon Sep 17 00:00:00 2001
From: Jason Walker <913443@dadeschools.net>
Date: Wed, 8 Jul 2026 04:23:17 -0400
Subject: [PATCH 42/42] feat: add safe post-merge moot lease cleanup for
already-merged PRs (Closes #515)
Refuse merge-oriented reviewer-lease acquisition/adoption on an already
merged/closed PR, and add a read-first cleanup path for a lingering moot lease.
- reviewer_pr_lease.assess_acquire_lease: new pr_merged_or_closed flag; fail
closed with a post_merge_moot reason and no lease body when the PR already
merged/closed.
- reviewer_pr_lease.assess_post_merge_moot_lease: pure assessment. Only treats a
lease as moot when the PR is merged/closed; never proposes touching an active
lease on an open PR; provides a terminal phase:released (blocker:
post-merge-moot) body to neutralise the moot lease append-only; idempotent
once the released marker is the newest entry.
- gitea_acquire_reviewer_pr_lease: fetch live PR state, pass the flag, surface
post_merge_moot on refusal (no comment posted).
- gitea_cleanup_post_merge_moot_lease: new read-first tool. Reports merged
state, merge_commit_sha, linked-issue closure, lease-moot, cleanup
performed/skipped, and no_merge_or_adoption. apply=True posts the released
marker only when merged/closed with an active lease; refuses on open PRs.
- tests/test_post_merge_moot_lease.py: 10 tests covering acquire refusal on
merged PR (no post), safe/idempotent cleanup, and open-PR foreign-lease
protection.
No relaxation of #407 reviewer-lease or #16 gated-merge gates for open PRs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
gitea_mcp_server.py | 129 ++++++++++++++
reviewer_pr_lease.py | 109 +++++++++++-
tests/test_post_merge_moot_lease.py | 257 ++++++++++++++++++++++++++++
3 files changed, 494 insertions(+), 1 deletion(-)
create mode 100644 tests/test_post_merge_moot_lease.py
diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py
index c2514bd..ca164e0 100644
--- a/gitea_mcp_server.py
+++ b/gitea_mcp_server.py
@@ -5195,6 +5195,13 @@ def gitea_acquire_reviewer_pr_lease(
comments = _fetch_pr_comments(
pr_number, remote=remote, host=host, org=org, repo=repo)
+ # Refuse merge-oriented lease acquisition/adoption on an already-merged or
+ # closed PR: the lease is moot and adopting it for merge work is unsafe (#515).
+ pr_live = api_request(
+ "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
+ pr_merged_or_closed = bool(
+ pr_live.get("merged") or pr_live.get("merged_at")
+ ) or (str(pr_live.get("state") or "").strip().lower() == "closed")
assessment = reviewer_pr_lease.assess_acquire_lease(
comments,
pr_number=pr_number,
@@ -5207,6 +5214,7 @@ def gitea_acquire_reviewer_pr_lease(
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
+ pr_merged_or_closed=pr_merged_or_closed,
)
if not assessment.get("acquire_allowed"):
return {
@@ -5214,6 +5222,7 @@ def gitea_acquire_reviewer_pr_lease(
"acquired": False,
"reasons": assessment.get("reasons") or [],
"existing_lease": assessment.get("existing_lease"),
+ "post_merge_moot": assessment.get("post_merge_moot", False),
}
body = assessment["lease_body"]
@@ -5362,6 +5371,126 @@ def gitea_assess_reviewer_pr_lease(
}
+@mcp.tool()
+def gitea_cleanup_post_merge_moot_lease(
+ pr_number: int,
+ apply: bool = False,
+ remote: str = "dadeschools",
+ host: str | None = None,
+ org: str | None = None,
+ repo: str | None = None,
+) -> dict:
+ """Safely resolve a moot reviewer lease left on an ALREADY-MERGED/closed PR (#515).
+
+ Read-first and fail-safe. This tool never merges and never adopts a lease.
+ It only acts when the live PR state is merged/closed, and it never steals or
+ force-cleans an active *foreign* lease while the PR is still open. When
+ ``apply`` is true and a lease is still active on a merged/closed PR, it posts
+ a terminal ``phase: released`` lease marker (``blocker: post-merge-moot``) —
+ an append-only comment that neutralises the moot lease without deleting any
+ other session's comment.
+
+ Args:
+ pr_number: The PR whose lingering lease to assess/clean.
+ apply: When false (default) report only (read-only). When true, post the
+ terminal released marker if — and only if — cleanup is allowed.
+ remote: Known instance — 'dadeschools' or 'prgs'.
+ host: Override the Gitea host.
+ org: Override the owner/organization.
+ repo: Override the repository name.
+
+ Returns:
+ dict reporting PR merged/closed state, merge_commit_sha, linked-issue
+ closure state, whether the lease is moot, whether cleanup was performed
+ or skipped (and why), and ``no_merge_or_adoption`` True — this path never
+ merges or adopts.
+ """
+ read_block = _profile_operation_gate("gitea.read")
+ if read_block:
+ return {
+ "success": False,
+ "cleanup_performed": False,
+ "no_merge_or_adoption": True,
+ "reasons": read_block,
+ "permission_report": _permission_block_report("gitea.read"),
+ }
+ h, o, r = _resolve(remote, host, org, repo)
+ auth = _auth(h)
+ pr_live = api_request(
+ "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
+ comments = _fetch_pr_comments(
+ pr_number, remote=remote, host=host, org=org, repo=repo)
+ assessment = reviewer_pr_lease.assess_post_merge_moot_lease(
+ comments,
+ pr_number=pr_number,
+ pr_merged=bool(pr_live.get("merged") or pr_live.get("merged_at")),
+ pr_state=pr_live.get("state"),
+ merge_commit_sha=pr_live.get("merge_commit_sha"),
+ )
+
+ # Best-effort linked-issue closure state for the report.
+ active = assessment.get("active_lease") or {}
+ issue_no = active.get("issue_number")
+ linked_issue_state = None
+ if issue_no:
+ try:
+ issue = api_request(
+ "GET", f"{repo_api_url(h, o, r)}/issues/{issue_no}", auth) or {}
+ linked_issue_state = issue.get("state")
+ except Exception:
+ linked_issue_state = None
+
+ report = {
+ "success": True,
+ "pr_number": pr_number,
+ "pr_state": assessment.get("pr_state"),
+ "pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
+ "merge_commit_sha": assessment.get("merge_commit_sha"),
+ "linked_issue_number": issue_no,
+ "linked_issue_state": linked_issue_state,
+ "lease_moot": assessment.get("is_moot"),
+ "active_lease": assessment.get("active_lease"),
+ "cleanup_allowed": assessment.get("cleanup_allowed"),
+ "cleanup_performed": False,
+ "no_merge_or_adoption": True,
+ "mode": "apply" if apply else "read_only",
+ "reasons": assessment.get("reasons") or [],
+ }
+
+ if not apply:
+ return report
+ if not assessment.get("cleanup_allowed"):
+ report["cleanup_skipped_reason"] = (
+ assessment.get("reasons") or ["cleanup not allowed"]
+ )
+ return report
+
+ comment_block = _profile_operation_gate("gitea.pr.comment")
+ if comment_block:
+ report["success"] = False
+ report["reasons"] = comment_block
+ report["permission_report"] = _permission_block_report("gitea.pr.comment")
+ return report
+
+ verify_preflight_purity(remote)
+ body = assessment["release_body"]
+ comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
+ with _audited(
+ "comment_pr",
+ host=h,
+ remote=remote,
+ org=o,
+ repo=r,
+ pr_number=pr_number,
+ request_metadata={"source": "cleanup_post_merge_moot_lease"},
+ ):
+ posted = api_request("POST", comment_url, auth, {"body": body})
+
+ report["cleanup_performed"] = True
+ report["released_comment_id"] = posted.get("id")
+ return report
+
+
@mcp.tool()
def gitea_list_issue_comments(
issue_number: int,
diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py
index 6e28be5..da1169e 100644
--- a/reviewer_pr_lease.py
+++ b/reviewer_pr_lease.py
@@ -217,12 +217,24 @@ def assess_acquire_lease(
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."""
+ """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)
@@ -270,6 +282,101 @@ def assess_acquire_lease(
"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,
}
diff --git a/tests/test_post_merge_moot_lease.py b/tests/test_post_merge_moot_lease.py
new file mode 100644
index 0000000..a71cb72
--- /dev/null
+++ b/tests/test_post_merge_moot_lease.py
@@ -0,0 +1,257 @@
+"""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()