fix: resolve conflicts for PR #486

Merge prgs/master into PR branch to restore mergeability.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 22:28:12 -04:00
co-authored by Claude Opus 4.8
47 changed files with 6721 additions and 40 deletions
+19
View File
@@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
## 1. Standard test commands
### Canonical runner: `./run-tests.sh`
The canonical full-validation command is the root-level runner. It invokes the
project virtualenv interpreter and passes any extra arguments straight through
to `pytest`:
```bash
# Full validation
./run-tests.sh
# Focused validation (extra args forward to pytest)
./run-tests.sh tests/test_mcp_server.py -q
```
`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
setup message if the virtualenv Python is missing (so a session never silently
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
forms below remain valid and equivalent.
The test suite needs the project virtualenv (it provides the MCP SDK):
```bash
+58
View File
@@ -377,6 +377,37 @@ explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
### Root checkout guard (#475)
The MCP server enforces a fail-closed **root checkout guard** before author,
reviewer, and merger mutations when the active workspace is not an isolated
`branches/...` worktree (reconciler close paths remain exempt per #468).
The guard blocks when the **control checkout** (repository root) is:
- on a non-stable branch (`master` / `main` / `dev` expected),
- detached HEAD,
- dirty (tracked edits),
- or its `HEAD` does not match `prgs/master` when that ref is available.
**Remediation (never auto-reset or stash):**
> Root checkout is not on master. Preserve state, switch root back to master,
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
**Recovery after root hijack:**
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
rescue branch from a `branches/...` worktree).
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
`git worktree add`.
`branches/...` directories are disposable role worktrees; the root checkout is
the stable orchestration surface only.
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
@@ -641,6 +672,33 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
#### Merger lease adoption (#536)
When review and merge run in **separate sessions**, the merger must **not**
manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state.
That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not
canonical proof and is rejected by mutation gates.
**Canonical merger handoff:**
1. Confirm a reviewer session holds an active PR lease and posted **APPROVED**
at the current live head (`gitea_get_pr_review_feedback`).
2. In a clean merger worktree under `branches/`, call
`gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and
optional `issue_number`.
3. The tool posts durable adoption proof on the PR thread (`<!-- mcp-review-lease-adoption:v1 -->`)
recording actor, profiles, adopted-from session/comment, adoption reason, and
timestamp, then records sanctioned in-session provenance.
4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`.
**Forbidden:** Python one-liners or scripts that call `record_session_lease()`
without provenance from `gitea_acquire_reviewer_pr_lease`,
`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`.
**Same-session review+merge:** the reviewer session may use
`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session
merger handoff.
### Close the issue after merge / Reconciliation
- **Profile:** issue-manager or merger.
+67
View File
@@ -0,0 +1,67 @@
# MCP operator shell menu
## Purpose
`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating
the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path,
or runbook section.
It is intentionally **safe by default**: status checks and copy-paste workflow
prompts. It does not delete branches, force-push, edit lock files, or bypass
sanctioned MCP tools.
## How to run
From the repository root:
```bash
./mcp-menu.sh
```
The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
`set -euo pipefail`.
## Safety rules
- **Read-only by default** — root checkout health is inspection only.
- **No destructive git** — no `git push --force`, branch deletion, or
`--delete` refspecs.
- **No lock-file editing** — issue locks are acquired only through
`gitea_lock_issue`.
- **No raw API bypass** — prompts direct operators to sanctioned MCP tools.
- **Remote mutations require confirmation** — any future menu action that would
mutate remote or server state must be clearly labeled and require explicit
operator confirmation before running.
- **Author work stays under `branches/`** — the root checkout is a stable
control checkout on `master` / `prgs/master`.
## Menu options
| Option | Description |
|--------|-------------|
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
| Reviewer workflow prompts | PR review prompt (review-only; no merge). |
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
| Proxmox deployment placeholder | **Not implemented** — informational message only. |
| Create Proxmox LXC placeholder | **Not implemented** — informational message only. |
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
| Exit | Quit the menu. |
## Placeholder-only entries
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
dedicated issues implement sanctioned automation. The menu prints a clear
message and does not invoke deploy scripts.
## Related documentation
- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks
- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill
- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows
## Tests
Hermetic coverage lives in `tests/test_mcp_menu_script.py`.
+2
View File
@@ -24,6 +24,8 @@
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
- `gitea_mark_final_review_decision` — mark validation complete.
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407).
- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536).
- `gitea_merge_pr` — gated merge (only merge path).
## Read tools
+125
View File
@@ -12,6 +12,7 @@ import re
from typing import Any, Callable
import issue_lock_provenance
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
HANDOFF_HEADING,
@@ -122,6 +123,22 @@ _TARGET_BRANCH_SHA_RE = re.compile(
r"target branch sha\s*:\s*[0-9a-f]{40}",
re.IGNORECASE,
)
_WORKFLOW_LOAD_HELPER_RE = re.compile(
r"workflow[- ]load helper result\s*:",
re.IGNORECASE,
)
_WORKFLOW_LOAD_HASH_RE = re.compile(
r"workflow[- ]load helper result[\s\S]{0,400}?workflow[_ ]hash\s*:\s*[0-9a-f]{12}",
re.IGNORECASE,
)
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)",
re.IGNORECASE,
)
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
r"(?:read|viewed|loaded)\s+(?:the\s+)?(?:canonical\s+)?(?:workflow|review-merge-pr\.md)",
re.IGNORECASE,
)
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_RECONCILE_STALE_FIELDS = (
"pr number opened",
@@ -572,6 +589,27 @@ def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
)
def _rule_worktree_cleanup_audit_proof(report_text: str) -> list[dict[str, str]]:
from worktree_cleanup_audit import assess_cleanup_audit_final_report
text = report_text or ""
if "cleanup audit" not in text.lower() and "reconciliation table" not in text.lower():
return []
result = assess_cleanup_audit_final_report(text)
if result.get("proven"):
return []
return _findings_from_reasons(
"author.worktree_cleanup_audit_proof",
result.get("reasons") or [],
field="Worktree cleanup audit",
severity="block",
safe_next_action=(
"include reconciliation table counts, disposition rows, and "
"final git worktree list proof"
),
)
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
@@ -1028,6 +1066,54 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
)
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
"""#403: require structured workflow-load helper result, not file-view narrative."""
if not report_text.strip():
return []
findings: list[dict[str, str]] = []
has_helper = bool(_WORKFLOW_LOAD_HELPER_RE.search(report_text))
has_hash = bool(_WORKFLOW_LOAD_HASH_RE.search(report_text))
has_boundary = bool(_WORKFLOW_LOAD_BOUNDARY_RE.search(report_text))
has_narrative_only = bool(_WORKFLOW_FILE_VIEW_NARRATIVE_RE.search(report_text))
if has_narrative_only and not has_helper:
findings.append(validator_finding(
"reviewer.workflow_load_boundary",
"block",
"Workflow-load helper result",
(
"canonical workflow file-view narrative without structured "
"gitea_load_review_workflow helper result"
),
(
"include Workflow-load helper result with workflow_hash and "
"boundary_status from gitea_load_review_workflow"
),
))
return findings
if has_helper and (not has_hash or not has_boundary):
missing = []
if not has_hash:
missing.append("workflow_hash")
if not has_boundary:
missing.append("boundary_status")
findings.append(validator_finding(
"reviewer.workflow_load_boundary",
"block",
"Workflow-load helper result",
(
"workflow-load helper result incomplete; missing "
+ ", ".join(missing)
),
(
"copy workflow_load_helper_result fields from "
"gitea_load_review_workflow into the final report"
),
))
return findings
def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
from audit_reconciliation_mode import assess_audit_reconciliation_report
@@ -1063,6 +1149,22 @@ def _rule_reviewer_review_mutation(
)
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
result = assess_mutation_capability_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.mutation_capability_proof",
result.get("reasons") or [],
field="Capabilities proven",
severity="block",
safe_next_action=result.get("safe_next_action")
or "document exact per-mutation capability proof before each 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"):
@@ -1077,12 +1179,30 @@ def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str,
)
def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, str]]:
result = assess_mcp_native_cleanup_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"shared.mcp_native_cleanup_proof",
result.get("reasons") or [],
field="Cleanup mutations",
severity="block",
safe_next_action=result.get("safe_next_action")
or "use authorized reconciler MCP cleanup tools; never raw scripts",
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
_rule_shared_author_reviewer_same_run,
)
_SHARED_CLEANUP_PROOF_RULES = (
_rule_shared_mcp_native_cleanup_proof,
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
@@ -1104,9 +1224,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_already_landed_eligible,
_rule_reviewer_already_landed_state,
_rule_reviewer_target_branch_freshness,
_rule_reviewer_workflow_load_boundary,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
_rule_reviewer_mutation_capability_proof,
_rule_reviewer_post_merge_cleanup_proof,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reviewer_stale_head_proof,
],
"merge_pr": [
@@ -1124,6 +1247,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
@@ -1145,6 +1269,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
_rule_conflict_fix_push_proof,
_rule_worktree_cleanup_audit_proof,
],
"issue_filing": [
_rule_shared_controller_handoff,
+704 -19
View File
@@ -211,6 +211,28 @@ def _effective_workspace_role() -> str:
)
def _actual_profile_role() -> str:
"""Resolve the workspace role from the ACTIVE PROFILE alone (#540).
Unlike :func:`_effective_workspace_role`, this never consults
``_preflight_resolved_role``. A task capability whose ``required_role_kind``
is ``author`` (e.g. ``comment_issue``) stamps ``_preflight_resolved_role =
"author"``; the #274/#475 role exemptions must key off the real profile
identity so that stamp cannot poison a genuine reviewer/merger/reconciler
session into being treated as an author. Author profiles still classify as
``author`` here, so author blocking is preserved.
"""
profile = get_profile()
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()
@@ -539,11 +561,21 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
"""#274: author file/branch mutations must run from a branches/ worktree.
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
and merger metadata mutations must not require ``GITEA_AUTHOR_WORKTREE``
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
(#468, #483). Non-author namespaces use dedicated workspace env vars (#510).
The exemption honours BOTH the effective workspace role and the actual
profile role (#540). ``comment_issue`` preflight stamps
``_preflight_resolved_role = "author"`` (its ``required_role_kind``), which
would otherwise poison :func:`_effective_workspace_role` into classifying a
genuine reconciler as an author and defeat this exemption. Keying off the
real profile role as well preserves the exemption without weakening author
blocking an actual author profile classifies as ``author`` in both.
"""
role = _effective_workspace_role()
if role in nwb.NON_AUTHOR_ROLES:
if (
_effective_workspace_role() in nwb.NON_AUTHOR_ROLES
or _actual_profile_role() in nwb.NON_AUTHOR_ROLES
):
return
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
@@ -651,6 +683,7 @@ def verify_preflight_purity(
f"{_format_preflight_files(reviewer_delta)}"
)
_enforce_root_checkout_guard(worktree_path)
_enforce_branches_only_author_mutation(worktree_path)
_clear_preflight_capability_state()
@@ -693,6 +726,28 @@ def _verify_role_mutation_workspace(
verify_preflight_purity(remote, worktree_path=resolved, task=task)
return resolved
def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
"""#475: fail closed when the stable control checkout is contaminated."""
ctx = _resolve_author_mutation_context(worktree_path)
canonical_root = ctx["canonical_repo_root"]
workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(canonical_root)
remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root)
assessment = root_checkout_guard.assess_root_checkout_guard(
workspace_path=workspace,
canonical_repo_root=canonical_root,
current_branch=git_state.get("current_branch"),
head_sha=git_state.get("head_sha"),
porcelain_status=git_state.get("porcelain_status") or "",
remote_master_sha=remote_master_sha,
resolved_role=_preflight_resolved_role,
actual_role=_actual_profile_role(),
)
if assessment["block"]:
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
@@ -714,6 +769,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_boundary # noqa: E402
import review_workflow_load # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
@@ -724,16 +780,21 @@ import stacked_pr_support # noqa: E402
import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import root_checkout_guard # noqa: E402
import remote_repo_guard # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import reviewer_pr_lease # noqa: E402
import merger_lease_adoption # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import worktree_cleanup_audit # 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 pr_work_lease # noqa: E402
import native_mcp_preference # noqa: E402
import worktree_cleanup_audit # noqa: E402
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
@@ -1135,11 +1196,49 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
profile = REMOTES[remote]
return (
host or profile["host"],
org or profile["org"],
repo or profile["repo"],
resolved_host = host or profile["host"]
resolved_org = org or profile["org"]
resolved_repo = repo or profile["repo"]
_enforce_remote_repo_guard(
remote,
resolved_org,
resolved_repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
return (resolved_host, resolved_org, resolved_repo)
def _enforce_remote_repo_guard(
remote: str,
resolved_org: str,
resolved_repo: str,
*,
org_explicit: bool,
repo_explicit: bool,
) -> None:
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530).
Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is
set, so the unit suite (which calls tools with bare remotes against mocked APIs)
is unaffected. In production it protects every read/lookup/mutation tool because
they all resolve targets through :func:`_resolve`.
"""
if "pytest" in sys.modules and not os.environ.get(
"GITEA_FORCE_REMOTE_REPO_CHECK"
):
return
local_remote_url = _local_git_remote_url(remote)
assessment = remote_repo_guard.assess_remote_repo_match(
remote=remote,
resolved_org=resolved_org,
resolved_repo=resolved_repo,
local_remote_url=local_remote_url,
org_explicit=org_explicit,
repo_explicit=repo_explicit,
)
if assessment["block"]:
raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment))
def _auth(host: str) -> str:
@@ -2394,10 +2493,18 @@ def _review_decision_session_reasons(lock: dict | None) -> list[str]:
return reasons
def init_review_decision_lock(remote: str | None, task: str | None):
def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True):
"""Seed read-only-until-ready state for reviewer PR review tasks."""
if task != "review_pr":
return
if not force:
lock = _load_review_decision_lock()
if lock is not None:
if lock.get("remote") == remote and lock.get("session_pid") == os.getpid():
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
stored_lock = (lock.get("session_profile_lock") or "").strip()
if not env_lock or not stored_lock or env_lock == stored_lock:
return
review_workflow_load.clear_review_workflow_load()
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip()
@@ -2790,6 +2897,85 @@ def gitea_get_pr_review_feedback(
}
def _fetch_pr_lease_comments_safe(
pr_number: int,
*,
remote: str,
host: str | None,
org: str | None,
repo: str | None,
limit: int = 100,
require_open: bool = False,
) -> dict:
"""Fetch PR thread comments with structured fail-closed errors (#519)."""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
resolved_repo = f"{o}/{r}"
pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
try:
pr = api_request("GET", pr_url, auth)
except RuntimeError as exc:
return {
"success": False,
"comments": [],
"reasons": [
"PR lookup failed before conflict-fix push assessment "
f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): "
f"{_redact(str(exc))}"
],
"pr_lookup": "failed",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
}
pr_state = (pr.get("state") or "").strip().lower()
if require_open and pr_state != "open":
return {
"success": False,
"comments": [],
"reasons": [
f"PR #{pr_number} on {resolved_repo} is not open "
f"(state={pr_state or 'unknown'})"
],
"pr_lookup": "not_open",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
try:
comments = api_request("GET", api, auth)
except RuntimeError as exc:
return {
"success": False,
"comments": [],
"reasons": [
"PR comment fetch failed during conflict-fix push assessment "
f"(pr_number={pr_number}, issue_index={pr_number}, "
f"repo={resolved_repo}, remote={remote}): "
f"{_redact(str(exc))}"
],
"pr_lookup": "ok",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
if not isinstance(comments, list):
comments = []
return {
"success": True,
"comments": list(comments[:limit]),
"reasons": [],
"pr_lookup": "ok",
"resolved_repo": resolved_repo,
"remote": remote,
"pr_number": pr_number,
"head_sha": (pr.get("head") or {}).get("sha"),
}
def _list_pr_lease_comments(
pr_number: int,
*,
@@ -2799,7 +2985,15 @@ def _list_pr_lease_comments(
repo: str | None,
limit: int = 100,
) -> list[dict]:
"""Fetch PR/issue thread comments used for reviewer/conflict-fix leases."""
"""Fetch PR/issue thread comments used for reviewer/conflict-fix leases.
Intentionally does **not** pre-fetch the PR via GET /pulls/{n}. Shared
callers (merge approval feedback, reviewer lease gates) depend on a single
comments GET so mock sequences and fail-open #485 non-list handling stay
stable. Structured PR-lookup failures belong only to
:func:`_fetch_pr_lease_comments_safe` used by conflict-fix push assessment
(#519).
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
@@ -4326,6 +4520,59 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo
raise
@mcp.tool()
def gitea_capture_branches_worktree_snapshot(
open_pr_branches: list[str] | None = None,
active_lock_branch: str | None = None,
leased_paths: list[str] | None = None,
worktree_path: str | None = None,
) -> dict:
"""Read-only: capture ``branches/`` and worktree audit snapshot (#404)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
root = PROJECT_ROOT
if worktree_path:
root = os.path.realpath(os.path.abspath(worktree_path))
git_root = _get_git_root(root)
if git_root:
root = git_root
snapshot = worktree_cleanup_audit.capture_branches_worktree_snapshot(
root,
open_pr_branches=open_pr_branches,
active_lock_branch=active_lock_branch,
leased_paths=leased_paths,
)
return {"success": True, "snapshot": snapshot}
@mcp.tool()
def gitea_assess_worktree_cleanup_integrity(
before_snapshot: dict,
after_snapshot: dict,
removals: list[dict] | None = None,
explained_missing: dict[str, str] | None = None,
) -> dict:
"""Read-only: reconcile cleanup before/after snapshots (#404)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"integrity_passed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
return worktree_cleanup_audit.assess_worktree_cleanup_integrity(
before=before_snapshot,
after=after_snapshot,
removals=removals,
explained_missing=explained_missing,
)
@mcp.tool()
def gitea_reconcile_merged_cleanups(
dry_run: bool = True,
@@ -4677,6 +4924,90 @@ def gitea_scan_already_landed_open_prs(
}
@mcp.tool()
def gitea_audit_worktree_cleanup(
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS,
) -> dict:
"""Read-only: classify every session-owned worktree under ``branches/`` (#401).
Audits the local ``branches/`` directory, classifying each worktree as
active open PR, active issue work, dirty local, clean stale removable,
detached review leftover, or unsafe/unknown. Open PR branch heads are
fetched live so a worktree tied to an open PR is never marked removable;
the active issue-lock branch is read from the local lock file and treated
as active work. Deletes nothing and mutates no Gitea state.
Fails closed if the live open-PR list cannot be fetched: without it,
removability cannot be proven, so no candidates are returned.
Args:
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
ttl_hours: Age (hours) after which a clean issue/conflict-fix
worktree becomes stale-removable (default from
GITEA_WORKTREE_TTL_HOURS).
Returns:
dict with per-worktree classifications, counts, removable
candidates, and the ``git worktree list`` verification proof.
"""
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"),
}
try:
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
open_prs = api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth)
except Exception as exc:
return {
"success": False,
"performed": False,
"open_pr_state_verified": False,
"reasons": [
"could not fetch live open PRs; removability unverified "
f"(fail closed): {_redact(str(exc))}"
],
}
open_pr_branches = {
str((pr.get("head") or {}).get("ref"))
for pr in open_prs
if (pr.get("head") or {}).get("ref")
}
active_issue_branches: set[str] = set()
lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE)
if lock and lock.get("branch_name"):
active_issue_branches.add(str(lock["branch_name"]).strip())
report = worktree_cleanup_audit.audit_branches_directory(
PROJECT_ROOT,
open_pr_branches=open_pr_branches,
active_issue_branches=active_issue_branches,
now=datetime.now(timezone.utc),
ttl_hours=ttl_hours,
)
return {
"success": True,
"performed": False,
"open_pr_state_verified": True,
"task_mode": "work-issue",
**report,
}
@mcp.tool()
def gitea_reconcile_already_landed_pr(
pr_number: int,
@@ -5374,7 +5705,10 @@ def gitea_acquire_reviewer_pr_lease(
"target_branch_sha": target_branch_sha,
"repo": repo_label,
"comment_id": posted.get("id"),
})
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ACQUIRE,
comment_id=posted.get("id"),
))
return {
"success": True,
"acquired": True,
@@ -5386,6 +5720,175 @@ def gitea_acquire_reviewer_pr_lease(
}
@mcp.tool()
def gitea_adopt_merger_pr_lease(
pr_number: int,
worktree: str,
expected_head_sha: str,
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:
"""Adopt an active reviewer PR lease for a merger session (#536).
Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``.
This tool posts durable adoption proof on the PR thread and records
sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed
after a separate reviewer session held the lease.
Args:
pr_number: Open PR to adopt.
worktree: Merger workspace under ``branches/``.
expected_head_sha: Approved head the merger will pin during merge.
issue_number: Optional linked issue for the adoption record.
session_id: Optional merger session id (generated when omitted).
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with adoption proof, session lease, and block reasons when the
guarded preconditions are not met.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"adopted": 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,
"adopted": False,
"reasons": comment_block,
"permission_report": _permission_block_report("gitea.pr.comment"),
}
merge_block = _profile_operation_gate("gitea.pr.merge")
if merge_block:
return {
"success": False,
"adopted": False,
"reasons": merge_block,
"permission_report": _permission_block_report("gitea.pr.merge"),
}
_verify_role_mutation_workspace(
remote, worktree=worktree, task="adopt_merger_pr_lease"
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
profile_name = profile.get("profile_name") or "unknown"
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}"
pr_live = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
pr_state = (pr_live.get("state") or "").strip().lower()
pr_open = pr_state == "open" and not pr_live.get("merged")
live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get(
"head_sha"
)
feedback = gitea_get_pr_review_feedback(
pr_number=pr_number,
remote=remote,
host=host,
org=org,
repo=repo,
)
approval_at_head = bool(feedback.get("approval_at_current_head"))
comments = _fetch_pr_comments(
pr_number, remote=remote, host=host, org=org, repo=repo)
assessment = merger_lease_adoption.assess_adopt_merger_lease(
comments,
pr_number=pr_number,
adopter_identity=identity,
adopter_profile=profile_name,
adopter_session_id=sid,
repo=repo_label,
issue_number=issue_number,
worktree=worktree,
expected_head_sha=expected_head_sha,
live_head_sha=live_head,
approval_at_head=approval_at_head,
pr_open=pr_open,
)
if not assessment.get("adopt_allowed"):
return {
"success": False,
"adopted": False,
"pr_number": pr_number,
"reasons": assessment.get("reasons") or [],
"active_lease": assessment.get("active_lease"),
"expected_head_sha": expected_head_sha,
"live_head_sha": live_head,
}
active = assessment.get("active_lease") or {}
body = assessment["adoption_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": "adopt_merger_pr_lease"},
):
posted = api_request("POST", comment_url, auth, {"body": body})
provenance = merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ADOPT,
comment_id=posted.get("id"),
adopted_from_session_id=active.get("session_id"),
adopted_from_profile=active.get("profile"),
adopted_from_reviewer_identity=active.get("reviewer_identity"),
adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON,
)
session_lease = reviewer_pr_lease.record_session_lease({
"pr_number": pr_number,
"issue_number": issue_number or active.get("issue_number"),
"session_id": sid,
"reviewer_identity": identity,
"profile": profile_name,
"worktree": worktree,
"phase": "adopted",
"candidate_head": live_head,
"target_branch": active.get("target_branch") or "master",
"target_branch_sha": active.get("target_branch_sha"),
"repo": repo_label,
"comment_id": posted.get("id"),
}, lease_provenance=provenance)
return {
"success": True,
"adopted": True,
"pr_number": pr_number,
"session_id": sid,
"adoption_comment_id": posted.get("id"),
"adopted_from_session_id": active.get("session_id"),
"adopted_from_profile": active.get("profile"),
"adopted_from_reviewer_identity": active.get("reviewer_identity"),
"adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON,
"expected_head_sha": expected_head_sha,
"live_head_sha": live_head,
"session_lease": session_lease,
"lease_provenance": provenance,
"reasons": [],
}
@mcp.tool()
def gitea_heartbeat_reviewer_pr_lease(
pr_number: int,
@@ -5446,6 +5949,17 @@ def gitea_heartbeat_reviewer_pr_lease(
):
posted = api_request("POST", comment_url, auth, {"body": body})
prior_provenance = (session.get("lease_provenance") or {}).copy()
heartbeat_provenance = merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_HEARTBEAT,
comment_id=posted.get("id"),
adopted_from_session_id=prior_provenance.get("adopted_from_session_id"),
adopted_from_profile=prior_provenance.get("adopted_from_profile"),
adopted_from_reviewer_identity=prior_provenance.get(
"adopted_from_reviewer_identity"
),
adoption_reason=prior_provenance.get("adoption_reason"),
)
updated = reviewer_pr_lease.record_session_lease({
**session,
"phase": phase,
@@ -5453,7 +5967,8 @@ def gitea_heartbeat_reviewer_pr_lease(
"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"),
})
"comment_id": posted.get("id"),
}, lease_provenance=heartbeat_provenance)
return {
"success": True,
"posted": True,
@@ -5614,6 +6129,118 @@ def gitea_cleanup_post_merge_moot_lease(
return report
@mcp.tool()
def gitea_release_reviewer_pr_lease(
pr_number: int,
worktree: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Safely release an active reviewer lease owned by the current session on an open PR.
This provides a canonical way to clean up reviewer leases when a review fails
before submission.
Args:
pr_number: The PR number whose reviewer lease to release.
worktree: Optional worktree path (resolves automatically if not supplied).
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict reporting release status.
"""
_verify_role_mutation_workspace(
remote, worktree=worktree, task="review_pr"
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
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
)
if not active:
return {
"success": True,
"released": False,
"reasons": ["no active reviewer lease found on PR"],
}
identity = _authenticated_username(remote) or ""
session = reviewer_pr_lease.get_session_lease() or {}
session_id = session.get("session_id")
owner_identity = active.get("reviewer_identity")
owner_session_id = active.get("session_id")
# Authorize release: identity matches, session_id matches, or lease is expired/reclaimable
authorized = False
reasons = []
if owner_identity and identity and owner_identity == identity:
authorized = True
elif owner_session_id and session_id and owner_session_id == session_id:
authorized = True
else:
freshness = reviewer_pr_lease.classify_lease_freshness(active)
if freshness in {"expired", "reclaimable"}:
authorized = True
if not authorized:
return {
"success": False,
"released": False,
"reasons": [
f"unauthorized to release active lease: owned by {owner_identity} "
f"(session {owner_session_id})"
],
}
# Format and post release comment
body = reviewer_pr_lease.format_lease_body(
repo=active.get("repo") or f"{o}/{r}",
pr_number=pr_number,
issue_number=active.get("issue_number"),
reviewer_identity=owner_identity or identity,
profile=active.get("profile") or "reviewer",
session_id=owner_session_id or session_id or "unknown",
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"),
blocker="manual-release",
)
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": "release_reviewer_pr_lease"},
):
posted = api_request("POST", comment_url, auth, {"body": body})
reviewer_pr_lease.clear_session_lease()
return {
"success": True,
"released": True,
"comment_id": posted.get("id"),
"reasons": [],
}
@mcp.tool()
def gitea_list_issue_comments(
issue_number: int,
@@ -5685,6 +6312,7 @@ def gitea_create_issue_comment(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Post a markdown comment to a Gitea issue's discussion thread.
@@ -5706,6 +6334,7 @@ def gitea_create_issue_comment(
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Optional path to verify branches-only guard.
Returns:
dict with 'success', 'comment_id', and 'issue_number' ('url' only
@@ -5714,7 +6343,7 @@ def gitea_create_issue_comment(
(permission blocks also carry a structured 'permission_report',
#142).
"""
verify_preflight_purity(remote, task="comment_issue")
verify_preflight_purity(remote, worktree_path=worktree_path, task="comment_issue")
gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
@@ -6958,14 +7587,42 @@ def gitea_get_runtime_context(
return result
@mcp.tool()
def gitea_record_pre_review_command(
command: str,
cwd: str | None = None,
classification: str | None = None,
) -> dict:
"""Classify and record a command executed before workflow load (#403).
Read-only with respect to Gitea API. Pre-review inventory/diagnostic commands
may be recorded as allowed; boundary violations block reviewer mutations.
"""
recorded = review_workflow_boundary.record_pre_review_command(
command,
cwd=cwd,
project_root=PROJECT_ROOT,
classification=classification,
)
boundary_state = review_workflow_boundary.assess_boundary_status(PROJECT_ROOT)
return {
"success": True,
"recorded": recorded,
"boundary_status": boundary_state.get("boundary_status"),
"boundary_clean": boundary_state.get("boundary_clean"),
"reasons": list(boundary_state.get("reasons") or []),
}
@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).
"""Load and record canonical review-merge workflow proof for this session (#389, #403).
Read-only with respect to Gitea API; records in-process workflow source/hash
proof required before reviewer review or merge mutations.
proof and session boundary state required before reviewer review or merge
mutations.
"""
try:
recorded = review_workflow_load.record_review_workflow_load(
@@ -6977,8 +7634,11 @@ def gitea_load_review_workflow(
"reasons": [str(exc)],
"recovery_handoff": review_workflow_load.recovery_handoff_without_replay(),
}
boundary_reasons = review_workflow_boundary.boundary_blockers(PROJECT_ROOT)
helper = review_workflow_boundary.workflow_load_helper_result(
recorded, PROJECT_ROOT)
return {
"success": True,
"success": not boundary_reasons,
"loaded": True,
"workflow_source": recorded["workflow_source"],
"task_mode": recorded["task_mode"],
@@ -6990,7 +7650,14 @@ def gitea_load_review_workflow(
"prompt_conflicts_with_workflow"],
"prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [],
"workflow_load_proof_present": True,
"reasons": [],
"boundary_status": recorded.get("boundary_status"),
"boundary_clean": recorded.get("boundary_clean"),
"workflow_load_helper_result": helper,
"reasons": boundary_reasons,
"recovery_handoff": (
review_workflow_load.recovery_handoff_without_replay()
if boundary_reasons else []
),
}
@@ -7485,22 +8152,39 @@ def gitea_assess_conflict_fix_push(
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
comments = _list_pr_lease_comments(
fetched = _fetch_pr_lease_comments_safe(
pr_number,
remote=remote,
host=host,
org=org,
repo=repo,
require_open=True,
)
return pr_work_lease.assess_conflict_fix_push(
if not fetched["success"]:
return {
"push_allowed": False,
"block": True,
"reasons": fetched["reasons"],
"pr_lookup": fetched.get("pr_lookup"),
"resolved_repo": fetched.get("resolved_repo"),
"remote": fetched.get("remote"),
"pr_number": pr_number,
"assessment_failed": True,
}
assessment = pr_work_lease.assess_conflict_fix_push(
pr_number=pr_number,
comments=comments,
comments=fetched["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,
)
assessment["pr_lookup"] = fetched.get("pr_lookup")
assessment["resolved_repo"] = fetched.get("resolved_repo")
assessment["remote"] = fetched.get("remote")
assessment["live_pr_head_sha"] = fetched.get("head_sha")
return assessment
@mcp.tool()
@@ -8138,6 +8822,7 @@ def gitea_resolve_task_capability(
init_review_decision_lock(
remote if remote in REMOTES else None,
task,
force=False,
)
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
Executable
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding.
#
# Safe by default: read-only status and copy-paste prompts unless an action is
# explicitly labeled and confirmed. No branch deletion, force-push, lock-file
# editing, or raw API bypass.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$SCRIPT_DIR"
pause() {
read -r -p "Press Enter to return to the menu..."
}
print_banner() {
printf '\n=== Gitea-Tools MCP Operator Menu ===\n'
printf 'Repository: %s\n' "$REPO_ROOT"
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
}
show_root_checkout_health() {
printf '\n--- Project status / root checkout health ---\n\n'
printf 'Current directory: %s\n' "$(pwd)"
local branch head_sha prgs_master_sha dirty
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
if [[ -z "$branch" ]]; then
branch="(detached HEAD)"
fi
printf 'Current branch: %s\n' "$branch"
printf '\nGit status (short, branch):\n'
git -C "$REPO_ROOT" status --short --branch || true
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"
printf '\nHEAD SHA: %s\n' "$head_sha"
if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then
prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)"
printf 'prgs/master SHA: %s\n' "$prgs_master_sha"
if [[ "$head_sha" != "$prgs_master_sha" ]]; then
printf '\nWARNING: root checkout HEAD does not match prgs/master.\n'
printf 'Keep the stable control checkout on master/prgs/master.\n'
fi
else
printf 'prgs/master SHA: unavailable (remote ref not fetched)\n'
fi
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then
printf '\nWARNING: root checkout has uncommitted changes (dirty).\n'
printf 'Author mutations belong in a session worktree under branches/.\n'
fi
if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then
printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n'
printf 'Return to master before using the control checkout.\n'
fi
pause
}
print_prompt_block() {
local title="$1"
local body="$2"
printf '\n--- %s ---\n\n' "$title"
printf '%s\n' "$body"
printf '\n(Copy the prompt above into your LLM session.)\n'
pause
}
show_author_prompts() {
while true; do
printf '\n--- Author workflow prompts ---\n'
printf ' 1) Work issue (author/coder)\n'
printf ' 2) Conflict-fix author session\n'
printf ' 3) Root checkout recovery session\n'
printf ' 0) Back\n'
read -r -p 'Choice: ' choice
case "$choice" in
1)
print_prompt_block "Author — work issue" \
"You are the AUTHOR session for <org>/<repo>.
Goal: implement issue #<N> only.
Workflow:
1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/.
2. gitea_lock_issue for issue #<N> and branch feat/issue-<N>-<short-desc>.
3. Implement in the locked worktree only — never mutate the root control checkout.
4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger."
;;
2)
print_prompt_block "Author — conflict-fix session" \
"You are the AUTHOR session for <org>/<repo> in conflict-fix mode.
Goal: resolve merge conflicts on PR #<P> / branch <branch> only.
Workflow:
1. Preflight: prove author identity and exact push/commit capability for the locked PR branch.
2. Confirm conflict-fix lease and stale-head protection before pushing.
3. Work only in the session-owned worktree under branches/ — never the root checkout.
4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval.
5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger."
;;
3)
print_prompt_block "Author — root checkout recovery" \
"You are a RECOVERY session for <org>/<repo>.
Goal: restore the stable root control checkout to clean master/prgs/master.
Workflow:
1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master.
2. Do not implement features from the root checkout. Stash or move work to branches/<session> first.
3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files.
4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation."
;;
0) return ;;
*) printf 'Invalid choice.\n' ;;
esac
done
}
show_reviewer_prompts() {
print_prompt_block "Reviewer — PR review" \
"You are the REVIEWER session for <org>/<repo>.
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
2. Preflight: prove reviewer identity, review_pr capability, clean review worktree.
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
4. gitea_review_pr with approve, request-changes, or comment as warranted.
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
}
show_merger_prompts() {
print_prompt_block "Merger — PR merge" \
"You are the MERGER session for <org>/<repo>.
Goal: merge PR #<P> only after every gate passes.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA.
3. Confirm approval pins the current head SHA; re-validate if the branch moved.
4. gitea_merge_pr only on explicit operator approval after all gates pass.
5. Final report: merged SHA, cleanup handoff, mutation ledger."
}
show_reconciler_prompts() {
print_prompt_block "Reconciler — already-landed / closed PR cleanup" \
"You are the RECONCILER session for <org>/<repo>.
Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md
2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability.
3. Do not review, merge, implement code, or create branches.
4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate.
5. Final report: PR numbers handled, close proof, mutation ledger."
}
show_onboarding_prompt() {
print_prompt_block "Onboarding — new project to MCP workflow" \
"Onboard <org>/<repo> into the MCP Control Plane workflow.
Checklist:
1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability.
2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed.
3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config.
4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install.
5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/.
6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md.
Canonical router: skills/llm-project-workflow/SKILL.md"
}
show_proxmox_placeholder() {
printf '\n--- Proxmox deployment (placeholder) ---\n\n'
printf 'Push this project to Proxmox — TODO / issue-backed\n'
printf 'Create Proxmox LXC — TODO / issue-backed\n\n'
printf 'These actions are NOT implemented yet.\n'
printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n'
printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n'
pause
}
run_tests() {
printf '\n--- Run tests ---\n\n'
if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then
printf 'Running ./run-tests.sh ...\n\n'
(cd "$REPO_ROOT" && ./run-tests.sh)
pause
return
fi
if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then
printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n'
(cd "$REPO_ROOT" && ./venv/bin/python -m pytest)
pause
return
fi
printf 'ERROR: No test runner available (fail closed).\n' >&2
printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2
exit 1
}
main_menu() {
while true; do
print_banner
printf ' 1) Project status / root checkout health\n'
printf ' 2) Author workflow prompts\n'
printf ' 3) Reviewer workflow prompts\n'
printf ' 4) Merger workflow prompts\n'
printf ' 5) Reconciler workflow prompts\n'
printf ' 6) Onboarding new project to this MCP workflow\n'
printf ' 7) Proxmox deployment menu placeholder\n'
printf ' 8) Create Proxmox LXC placeholder\n'
printf ' 9) Run tests\n'
printf ' 0) Exit\n'
read -r -p 'Choice: ' choice
case "$choice" in
1) show_root_checkout_health ;;
2) show_author_prompts ;;
3) show_reviewer_prompts ;;
4) show_merger_prompts ;;
5) show_reconciler_prompts ;;
6) show_onboarding_prompt ;;
7|8) show_proxmox_placeholder ;;
9) run_tests ;;
0) printf 'Goodbye.\n'; exit 0 ;;
*) printf 'Invalid choice.\n'; pause ;;
esac
done
}
main_menu
+359
View File
@@ -0,0 +1,359 @@
"""MCP-native post-merge cleanup proof verifier (#517).
Post-merge cleanup of leases, comments, branches, and worktrees must be
proven through explicit MCP tools (or approved helpers), never raw scripts or
ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a
reconciler profile with the right capability proof.
"""
from __future__ import annotations
import re
from typing import Any
AUTHORIZED_CLEANUP_TOOLS = frozenset({
"gitea_cleanup_post_merge_moot_lease",
"gitea_reconcile_merged_cleanups",
"gitea_delete_branch",
"gitea_cleanup_merged_pr_branch",
"gitea_audit_worktree_cleanup",
"gitea_capture_branches_worktree_snapshot",
"gitea_assess_worktree_cleanup_integrity",
"gitea_authorize_reconciliation_cleanup_phase",
})
_RAW_BRANCH_DELETE_PATTERNS = (
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
)
_RAW_COMMENT_DELETE_PATTERNS = (
re.compile(
r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*"
r"(?:/comments/|issues/\d+/comments)",
re.I,
),
re.compile(
r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+",
re.I,
),
re.compile(
r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b",
re.I,
),
re.compile(
r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete",
re.I,
),
)
_CLEANUP_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_GIT_REF_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_WORKTREE_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_MERGE_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"})
_MUTATION_LEDGER_PATTERNS = (
_CLEANUP_MUTATIONS_RE,
_GIT_REF_MUTATIONS_RE,
_WORKTREE_MUTATIONS_RE,
)
_RAW_WORKTREE_REMOVE_PATTERNS = (
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I),
)
_AUTHORIZED_TOOL_RE = re.compile(
r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b",
re.IGNORECASE,
)
_RECONCILER_CAPABILITY_RE = re.compile(
r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)",
re.IGNORECASE,
)
_MERGER_CLEANUP_ROLE_RE = re.compile(
r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}"
r"(?:branch|worktree|comment|lease)",
re.IGNORECASE,
)
_HANDOFF_TO_RECONCILER_RE = re.compile(
r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler",
re.IGNORECASE,
)
def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None:
match = pattern.search(text)
if not match:
return None
return (match.group(1) or "").strip()
def _is_none_ledger_value(value: str) -> bool:
return value.strip().lower() in _NONE_LEDGER_VALUES
def scoped_cleanup_mutation_ledger_text(text: str | None) -> str:
"""Return mutation-ledger bodies scoped to cleanup enforcement (#517)."""
text = text or ""
parts: list[str] = []
for pattern in _MUTATION_LEDGER_PATTERNS:
body = _ledger_field_body(text, pattern)
if body is not None and not _is_none_ledger_value(body):
parts.append(body)
return "\n".join(parts)
def _git_ref_cleanup_claimed(body: str) -> bool:
return bool(raw_branch_delete_commands(body))
def _worktree_cleanup_claimed(body: str) -> bool:
for pattern in _RAW_WORKTREE_REMOVE_PATTERNS:
if pattern.search(body):
return True
return bool(
re.search(
r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b",
body,
re.IGNORECASE,
)
)
def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]:
claiming: list[tuple[str, str]] = []
cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE)
if cleanup_body is not None and not _is_none_ledger_value(cleanup_body):
claiming.append(("Cleanup mutations", cleanup_body))
git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE)
if git_ref_body is not None and not _is_none_ledger_value(git_ref_body):
if _git_ref_cleanup_claimed(git_ref_body):
claiming.append(("Git ref mutations", git_ref_body))
worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE)
if worktree_body is not None and not _is_none_ledger_value(worktree_body):
if _worktree_cleanup_claimed(worktree_body):
claiming.append(("Worktree mutations", worktree_body))
return claiming
def raw_branch_delete_commands(text: str | None) -> list[str]:
"""Return raw git branch-delete commands cited in *text*."""
if not text:
return []
commands: list[str] = []
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
return list(dict.fromkeys(commands))
def raw_comment_delete_commands(text: str | None) -> list[str]:
"""Return raw comment-deletion commands/scripts cited in *text*."""
if not text:
return []
commands: list[str] = []
for pattern in _RAW_COMMENT_DELETE_PATTERNS:
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
return list(dict.fromkeys(commands))
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
"""Fail closed when mutation ledgers cite raw git branch deletion."""
commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(text))
reasons = [
(
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
f"{command}"
)
for command in commands
]
return {
"proven": not reasons,
"block": bool(reasons),
"commands": commands,
"reasons": reasons,
"safe_next_action": (
"use gitea_delete_branch, gitea_reconcile_merged_cleanups, or another "
"approved MCP cleanup helper with explicit branch.delete capability"
if reasons
else "proceed"
),
}
def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]:
"""Fail closed when mutation ledgers cite raw comment deletion."""
commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text))
reasons = [
(
"raw comment deletion bypasses MCP lease cleanup gates: "
f"{command}"
)
for command in commands
]
return {
"proven": not reasons,
"block": bool(reasons),
"commands": commands,
"reasons": reasons,
"safe_next_action": (
"use gitea_cleanup_post_merge_moot_lease (append-only release comment) "
"or hand cleanup to a reconciler session; never delete lease comments"
if reasons
else "proceed"
),
}
def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]:
"""Require merge mutations and cleanup mutations in separate ledger fields."""
text = text or ""
merge_match = _MERGE_MUTATIONS_RE.search(text)
cleanup_match = _CLEANUP_MUTATIONS_RE.search(text)
reasons: list[str] = []
if cleanup_match and not merge_match:
combined = re.search(
r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations",
text,
re.IGNORECASE,
)
if combined:
reasons.append(
"merge and cleanup mutations must use separate 'Merge mutations' "
"and 'Cleanup mutations' ledger fields"
)
if merge_match and cleanup_match:
merge_val = (merge_match.group(1) or "").strip().lower()
cleanup_val = (cleanup_match.group(1) or "").strip().lower()
if merge_val == cleanup_val and merge_val not in {"", "none"}:
reasons.append(
"merge mutations and cleanup mutations must not duplicate the "
"same ledger entry"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"safe_next_action": (
"split merge mutations (gitea_merge_pr) from cleanup mutations "
"(reconciler MCP tools) in the controller handoff"
if reasons
else "proceed"
),
}
def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]:
"""Validate cleanup claims cite authorized MCP tools and reconciler capability."""
text = text or ""
claiming = _cleanup_claiming_ledger_fields(text)
if not claiming:
return {
"proven": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
reasons: list[str] = []
for field_name, body in claiming:
if not _AUTHORIZED_TOOL_RE.search(body):
reasons.append(
f"{field_name} cleanup must name an authorized MCP cleanup tool "
f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})"
)
if not _RECONCILER_CAPABILITY_RE.search(text):
reasons.append(
"post-merge cleanup requires reconciler capability proof "
"(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"safe_next_action": (
"hand cleanup to a prgs-reconciler session and cite the exact MCP tool "
"plus delete_branch/reconcile_merged_cleanups capability proof"
if reasons
else "proceed"
),
}
def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]:
"""Merger sessions that performed cleanup must hand off to reconciler."""
text = text or ""
if not _MERGER_CLEANUP_ROLE_RE.search(text):
return {
"proven": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
if _HANDOFF_TO_RECONCILER_RE.search(text):
return {
"proven": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
return {
"proven": False,
"block": True,
"reasons": [
"merger/reviewer session performed cleanup but did not hand off to "
"reconciler for MCP-native cleanup"
],
"safe_next_action": (
"merger sessions must end with cleanup handed to prgs-reconciler; "
"do not perform ad hoc branch/comment/worktree cleanup as merger"
),
}
def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]:
"""Composite #517 verifier for MCP-native post-merge cleanup proof."""
text = report_text or ""
checks = (
assess_raw_branch_delete_report(text),
assess_raw_comment_delete_report(text),
assess_merge_cleanup_mutation_separation(text),
assess_authorized_reconciler_cleanup_path(text),
assess_merger_cleanup_handoff_guidance(text),
)
reasons: list[str] = []
safe_next = "proceed"
for result in checks:
reasons.extend(result.get("reasons") or [])
if result.get("block") and result.get("safe_next_action"):
safe_next = result["safe_next_action"]
block = bool(reasons)
return {
"proven": not block,
"block": block,
"reasons": reasons,
"safe_next_action": safe_next,
"raw_branch_commands": raw_branch_delete_commands(
scoped_cleanup_mutation_ledger_text(text)
),
"raw_comment_commands": raw_comment_delete_commands(
scoped_cleanup_mutation_ledger_text(text)
),
}
+253
View File
@@ -0,0 +1,253 @@
"""Guarded merger adoption of an existing reviewer PR lease (#536).
Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable,
comment-backed adoption path for merger sessions that inherit a reviewer lease
after formal approval at the current PR head.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import reviewer_pr_lease as leases
ADOPTION_MARKER = "<!-- mcp-review-lease-adoption:v1 -->"
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_HEARTBEAT,
})
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
def format_adoption_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
adopter_identity: str,
adopter_profile: str,
adopter_session_id: str,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
adopted_from_session_id: str,
adopted_from_profile: str,
adopted_from_reviewer_identity: str,
adopted_from_comment_id: int | None,
adoption_reason: str = DEFAULT_ADOPTION_REASON,
adopted_at: datetime | None = None,
) -> str:
"""Render a durable merger adoption proof comment."""
adopted_at = adopted_at or datetime.now(timezone.utc)
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
lease_body = leases.format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=adopter_identity,
profile=adopter_profile,
session_id=adopter_session_id,
worktree=worktree,
phase="adopted",
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=adopted_at,
blocker="none",
)
lines = [
ADOPTION_MARKER,
f"adopted_at: {adopted_text}",
f"adopted_by_identity: {adopter_identity}",
f"adopted_by_profile: {adopter_profile}",
f"adopted_from_session_id: {adopted_from_session_id}",
f"adopted_from_profile: {adopted_from_profile}",
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
f"adoption_reason: {adoption_reason}",
lease_body,
]
return "\n".join(lines)
def is_adoption_comment(body: str) -> bool:
return ADOPTION_MARKER in (body or "")
def build_lease_provenance(
*,
source: str,
comment_id: int | None = None,
adopted_from_session_id: str | None = None,
adopted_from_profile: str | None = None,
adopted_from_reviewer_identity: str | None = None,
adoption_reason: str | None = None,
recorded_at: datetime | None = None,
) -> dict[str, Any]:
recorded_at = recorded_at or datetime.now(timezone.utc)
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
proof = {
"source": source,
"recorded_at": recorded_text,
}
if comment_id is not None:
proof["comment_id"] = comment_id
if adopted_from_session_id:
proof["adopted_from_session_id"] = adopted_from_session_id
if adopted_from_profile:
proof["adopted_from_profile"] = adopted_from_profile
if adopted_from_reviewer_identity:
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
if adoption_reason:
proof["adoption_reason"] = adoption_reason
return proof
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
if not session:
return False
provenance = session.get("lease_provenance") or {}
source = (provenance.get("source") or "").strip()
if source not in SANCTIONED_PROVENANCE_SOURCES:
return False
if source == SOURCE_ADOPT:
return bool(provenance.get("comment_id")) and bool(
provenance.get("adopted_from_session_id")
)
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
return bool(session.get("comment_id") or provenance.get("comment_id"))
return False
def assess_adopt_merger_lease(
comments: list[dict],
*,
pr_number: int,
adopter_identity: str,
adopter_profile: str,
adopter_session_id: str,
repo: str,
issue_number: int | None,
worktree: str,
expected_head_sha: str | None,
live_head_sha: str | None,
approval_at_head: bool,
pr_open: bool = True,
now: datetime | None = None,
) -> dict[str, Any]:
"""Decide whether a merger may adopt the active reviewer lease (#536)."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
pinned = leases._normalize_sha(expected_head_sha)
live = leases._normalize_sha(live_head_sha)
if not pr_open:
reasons.append(
f"PR #{pr_number} is not open; merger lease adoption is only for open PRs"
)
if not approval_at_head:
reasons.append(
"formal APPROVED review at the current PR head is required before "
"merger lease adoption (fail closed)"
)
if not pinned:
reasons.append("expected_head_sha is required for merger lease adoption")
if not live:
reasons.append("live PR head SHA unavailable (fail closed)")
elif pinned and live and pinned != live:
reasons.append(
"expected_head_sha does not match live PR head; refresh approval before adoption"
)
active = leases.find_active_reviewer_lease(
comments, pr_number=pr_number, now=now
)
if not active:
reasons.append(
f"no active reviewer lease on PR #{pr_number} to adopt; reviewer "
"must acquire first"
)
else:
owner_session = (active.get("session_id") or "").strip()
freshness = active.get("freshness") or leases.classify_lease_freshness(
active, now=now
)
if freshness not in _MERGER_ADOPTABLE_FRESHNESS:
reasons.append(
f"active reviewer lease freshness is '{freshness}'; explicit "
"reclaim is not implemented (fail closed)"
)
lease_head = active.get("candidate_head")
if lease_head and live and lease_head != live:
reasons.append(
"active reviewer lease candidate_head differs from live PR head"
)
if owner_session and owner_session == adopter_session_id:
# Same session already holds the thread lease — allow idempotent adopt
# only when the newest entry is not yet an adoption by this session.
entries = leases._lease_entries(comments, pr_number=pr_number)
newest = entries[-1] if entries else None
if newest and (newest.get("phase") or "") == "adopted":
reasons.append(
"merger session already recorded an adoption lease on this PR"
)
elif owner_session and owner_session != adopter_session_id:
# Cross-session merger handoff: adopt the foreign reviewer lease.
pass
if not (adopter_identity or "").strip():
reasons.append("adopter identity required")
if not (adopter_session_id or "").strip():
reasons.append("adopter session_id required")
if not (worktree or "").strip():
reasons.append("merger worktree path required")
if "merger" not in (adopter_profile or "").lower():
reasons.append(
f"profile '{adopter_profile}' is not a merger profile; adoption is "
"merger-only (fail closed)"
)
adopt_allowed = not reasons
adoption_body = None
if adopt_allowed and active:
adoption_body = format_adoption_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number or active.get("issue_number"),
adopter_identity=adopter_identity,
adopter_profile=adopter_profile,
adopter_session_id=adopter_session_id,
worktree=worktree,
candidate_head=live,
target_branch=active.get("target_branch") or "master",
target_branch_sha=active.get("target_branch_sha"),
adopted_from_session_id=active.get("session_id") or "",
adopted_from_profile=active.get("profile") or "unknown",
adopted_from_reviewer_identity=active.get("reviewer_identity") or "",
adopted_from_comment_id=active.get("comment_id"),
adopted_at=now,
)
return {
"adopt_allowed": adopt_allowed,
"reasons": reasons,
"active_lease": active,
"adoption_body": adoption_body,
"adopter_session_id": adopter_session_id,
"expected_head_sha": pinned,
"live_head_sha": live,
}
+98
View File
@@ -0,0 +1,98 @@
"""Remote/repo mismatch guard (#530).
Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES``
table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo
is ``Timesheet``, but the tools in this project operate on
``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools
worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``),
the resolved target silently points at the wrong repository, producing false 404s
and risking mutation of a different repo.
This module provides a pure assessment that compares the MCP-resolved ``org/repo``
against the local git remote URL and fails closed on a genuine mismatch, unless the
caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative)
or the local remote URL is unavailable (best-effort corroboration only).
"""
from __future__ import annotations
REMEDIATION = (
"Pass explicit org= and repo= matching the local git remote, "
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools."
)
def assess_remote_repo_match(
*,
remote: str,
resolved_org: str,
resolved_repo: str,
local_remote_url: str | None,
org_explicit: bool,
repo_explicit: bool,
) -> dict:
"""Fail closed when the resolved org/repo disagrees with the local git remote.
The guard is intentionally conservative:
* When the caller passed both ``org`` and ``repo`` explicitly, their intent is
authoritative and the guard never blocks.
* When the local git remote URL is unavailable (``None``/empty), corroboration
is impossible, so the guard does not block (best-effort only).
* Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL
(case-insensitive); if it does not, the guard blocks.
"""
reasons: list[str] = []
if org_explicit and repo_explicit:
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
url = (local_remote_url or "").strip()
if not url:
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
expected_slug = f"{resolved_org}/{resolved_repo}".lower()
if expected_slug in url.lower():
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
reasons.append(
f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote "
f"'{remote}' does not match the local git remote URL '{url}'"
)
return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url)
def format_remote_repo_guard_error(assessment: dict) -> str:
"""Single RuntimeError message for the MCP resolver gate."""
reasons = "; ".join(
assessment.get("reasons") or ["remote/repo resolution mismatch"]
)
resolved = (
f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}"
)
local = assessment.get("local_remote_url") or "(unknown)"
return (
f"Remote/repo guard (#530): {reasons}. "
f"Resolved target: {resolved}; local git remote: {local}. "
f"{REMEDIATION}"
)
def _assessment(
proven: bool,
reasons: list[str],
remote: str,
resolved_org: str,
resolved_repo: str,
local_remote_url: str | None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"remote": remote,
"resolved_org": resolved_org,
"resolved_repo": resolved_repo,
"local_remote_url": local_remote_url,
"remediation": REMEDIATION,
}
+9
View File
@@ -5629,3 +5629,12 @@ def assess_proof_backed_handoff_report(report_text, **kwargs):
from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess
return _assess(report_text, **kwargs)
def assess_mutation_capability_proof(report_text, **kwargs):
"""#405: exact per-mutation capability proof in reviewer final reports."""
from reviewer_mutation_capability_proof import (
assess_mutation_capability_proof as _assess,
)
return _assess(report_text, **kwargs)
+259
View File
@@ -0,0 +1,259 @@
"""Reviewer session boundary tracking for workflow-load gate (#403).
Pre-review commands executed before ``gitea_load_review_workflow`` must be
classified. Boundary violations block downstream reviewer mutations even when
workflow hash proof is present.
"""
from __future__ import annotations
import os
import re
from typing import Any
CLASSIFICATION_READ_ONLY_INVENTORY = "read_only_inventory"
CLASSIFICATION_DIAGNOSTIC = "diagnostic"
CLASSIFICATION_BOUNDARY_VIOLATION = "boundary_violation"
CLASSIFICATION_UNCLASSIFIED = "unclassified"
ALLOWED_CLASSIFICATIONS = frozenset({
CLASSIFICATION_READ_ONLY_INVENTORY,
CLASSIFICATION_DIAGNOSTIC,
CLASSIFICATION_BOUNDARY_VIOLATION,
CLASSIFICATION_UNCLASSIFIED,
})
_PRE_REVIEW_COMMANDS: list[dict[str, Any]] = []
_READ_ONLY_INVENTORY_PATTERNS = (
re.compile(
r"\bgitea[_-](?:list|view|whoami|get[-_]|resolve[-_]task|check[-_]pr|route[-_]task)",
re.I,
),
re.compile(r"\bgit\s+(?:fetch|remote\s+update|branch\s+-a|log|show|rev-parse)\b", re.I),
re.compile(r"\bgit\s+status\b", re.I),
re.compile(r"\bgit\s+worktree\s+list\b", re.I),
)
_DIAGNOSTIC_PATTERNS = (
re.compile(r"\bgit\s+diff(?:\s+--stat)?\b", re.I),
re.compile(r"\bwhich\s+pytest\b", re.I),
re.compile(r"\bpytest\s+--version\b", re.I),
)
_BOUNDARY_VIOLATION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"\b(?:pytest|python\s+-m\s+pytest|python\s+-m\s+unittest)\b", re.I),
"validation command before workflow load"),
(re.compile(r"\bprofiles\.json\b", re.I), "local profile config inspection"),
(re.compile(r"\bgitea-mcp(?:\.v2-contexts)?\.json\b", re.I),
"local Gitea MCP config inspection"),
(re.compile(r"\b\.env(?:\.|$|\b)", re.I), "credential file inspection"),
(re.compile(r"\bkeychain\b", re.I), "credential store inspection"),
(re.compile(r"\bpkill\b", re.I), "MCP repair activity"),
(re.compile(r"\b(?:edit|write|modify).{0,40}\bmcp\b", re.I),
"MCP config exploration"),
(re.compile(r"\bgit\s+(?:add|commit|reset|clean|checkout|merge|rebase|push)\b", re.I),
"git mutation before workflow load"),
)
def clear_pre_review_commands() -> None:
"""Test helper and session reset."""
global _PRE_REVIEW_COMMANDS
_PRE_REVIEW_COMMANDS = []
def pre_review_commands() -> list[dict[str, Any]]:
"""Return a shallow copy of recorded pre-review commands."""
return [dict(entry) for entry in _PRE_REVIEW_COMMANDS]
def _normalize_path(path: str | None) -> str:
return os.path.realpath(os.path.abspath((path or "").strip() or os.getcwd()))
def is_main_checkout_path(cwd: str | None, project_root: str | None) -> bool:
"""True when *cwd* is the stable control checkout (not under branches/)."""
if not project_root:
return False
root = _normalize_path(project_root)
path = _normalize_path(cwd)
if path != root:
return False
marker = f"{os.sep}branches{os.sep}"
return marker not in path
def classify_pre_review_command(
command: str,
*,
cwd: str | None = None,
project_root: str | None = None,
) -> dict[str, Any]:
"""Classify a command executed before workflow load."""
text = (command or "").strip()
path = _normalize_path(cwd)
root = _normalize_path(project_root) if project_root else None
reasons: list[str] = []
for pattern, label in _BOUNDARY_VIOLATION_PATTERNS:
if pattern.search(text):
if label.startswith("validation") and root and not is_main_checkout_path(path, root):
continue
if label.startswith("git mutation") and root and not is_main_checkout_path(path, root):
continue
reasons.append(label)
return {
"command": text,
"cwd": path,
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
"reasons": reasons,
}
for pattern in _READ_ONLY_INVENTORY_PATTERNS:
if pattern.search(text):
return {
"command": text,
"cwd": path,
"classification": CLASSIFICATION_READ_ONLY_INVENTORY,
"reasons": [],
}
for pattern in _DIAGNOSTIC_PATTERNS:
if pattern.search(text):
return {
"command": text,
"cwd": path,
"classification": CLASSIFICATION_DIAGNOSTIC,
"reasons": [],
}
if root and is_main_checkout_path(path, root):
if re.search(r"\b(?:cat|head|less|read)\b", text, re.I):
if re.search(r"workflow|skill|runbook", text, re.I):
return {
"command": text,
"cwd": path,
"classification": CLASSIFICATION_BOUNDARY_VIOLATION,
"reasons": [
"canonical workflow viewed as local file without "
"gitea_load_review_workflow (narrative load is not proof)"
],
}
return {
"command": text,
"cwd": path,
"classification": CLASSIFICATION_UNCLASSIFIED,
"reasons": [
"pre-review command not classified; record via "
"gitea_record_pre_review_command before workflow load"
],
}
def record_pre_review_command(
command: str,
*,
cwd: str | None = None,
project_root: str | None = None,
classification: str | None = None,
) -> dict[str, Any]:
"""Record and classify a pre-review command for the current session."""
assessed = classify_pre_review_command(
command, cwd=cwd, project_root=project_root)
if classification:
if classification not in ALLOWED_CLASSIFICATIONS:
assessed["classification"] = CLASSIFICATION_UNCLASSIFIED
assessed["reasons"] = [
f"unknown classification '{classification}'; fail closed"
]
else:
assessed["classification"] = classification
assessed["reasons"] = []
entry = {
**assessed,
"session_pid": os.getpid(),
}
_PRE_REVIEW_COMMANDS.append(entry)
return dict(entry)
def assess_boundary_status(project_root: str | None = None) -> dict[str, Any]:
"""Summarize pre-review boundary state for session proof and reports."""
violations = [
entry for entry in _PRE_REVIEW_COMMANDS
if entry.get("classification") == CLASSIFICATION_BOUNDARY_VIOLATION
]
unclassified = [
entry for entry in _PRE_REVIEW_COMMANDS
if entry.get("classification") == CLASSIFICATION_UNCLASSIFIED
]
reasons: list[str] = []
for entry in violations:
reasons.extend(entry.get("reasons") or [
f"boundary violation: {entry.get('command', '')[:80]}"
])
for entry in unclassified:
reasons.extend(entry.get("reasons") or [
"unclassified pre-review command blocks reviewer mutations"
])
clean = not reasons
return {
"boundary_status": "clean" if clean else "violation",
"boundary_clean": clean,
"pre_review_command_count": len(_PRE_REVIEW_COMMANDS),
"boundary_violation_count": len(violations),
"unclassified_command_count": len(unclassified),
"violations": [
{
"command": v.get("command"),
"cwd": v.get("cwd"),
"reasons": list(v.get("reasons") or []),
}
for v in violations
],
"reasons": reasons,
}
def boundary_blockers(project_root: str | None = None) -> list[str]:
"""Reasons reviewer mutations must fail closed due to boundary state."""
status = assess_boundary_status(project_root)
if status.get("boundary_clean"):
return []
return list(status.get("reasons") or [
"reviewer session boundary violation before workflow load"
])
def workflow_load_helper_result(
load: dict | None,
project_root: str | None = None,
) -> dict[str, Any]:
"""Structured helper result for final reports (#403)."""
boundary = assess_boundary_status(project_root)
if load is None:
return {
"workflow_load_proof_present": False,
"workflow_source": None,
"workflow_hash": None,
"final_report_schema_hash": None,
"boundary_status": boundary.get("boundary_status"),
"boundary_clean": False,
"reasons": [
"gitea_load_review_workflow helper result missing from report"
],
}
return {
"workflow_load_proof_present": True,
"workflow_source": load.get("workflow_source"),
"workflow_hash": load.get("workflow_hash"),
"final_report_schema_path": load.get("final_report_schema_path"),
"final_report_schema_hash": load.get("final_report_schema_hash"),
"boundary_status": load.get("boundary_status", boundary.get("boundary_status")),
"boundary_clean": bool(load.get("boundary_clean", boundary.get("boundary_clean"))),
"pre_review_command_count": boundary.get("pre_review_command_count"),
"reasons": [],
}
+21 -2
View File
@@ -1,4 +1,4 @@
"""Canonical review-merge workflow load proof for reviewer mutations (#389)."""
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403)."""
from __future__ import annotations
@@ -7,6 +7,8 @@ import os
import re
from pathlib import Path
import review_workflow_boundary as boundary
WORKFLOW_REL_PATH = (
"skills/llm-project-workflow/workflows/review-merge-pr.md"
)
@@ -95,10 +97,16 @@ def record_review_workflow_load(
global _REVIEW_WORKFLOW_LOAD
meta = build_canonical_workflow_metadata(
project_root, prompt_text=prompt_text)
boundary_state = boundary.assess_boundary_status(project_root)
_REVIEW_WORKFLOW_LOAD = {
**meta,
"session_pid": os.getpid(),
"loaded": True,
"boundary_status": boundary_state.get("boundary_status"),
"boundary_clean": boundary_state.get("boundary_clean"),
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
"boundary_reasons": list(boundary_state.get("reasons") or []),
}
return dict(_REVIEW_WORKFLOW_LOAD)
@@ -107,6 +115,7 @@ def clear_review_workflow_load() -> None:
"""Test helper and review_pr session reset."""
global _REVIEW_WORKFLOW_LOAD
_REVIEW_WORKFLOW_LOAD = None
boundary.clear_pre_review_commands()
def workflow_load_status(project_root: str | None = None) -> dict:
@@ -125,6 +134,9 @@ def workflow_load_status(project_root: str | None = None) -> dict:
],
}
reasons = _session_validation_reasons(load, project_root)
boundary_reasons = boundary.boundary_blockers(project_root)
if boundary_reasons:
reasons = list(reasons) + boundary_reasons
return {
"workflow_load_proof_present": True,
"workflow_load_valid": not reasons,
@@ -136,6 +148,10 @@ def workflow_load_status(project_root: str | None = None) -> dict:
"prompt_conflicts_with_workflow": load.get(
"prompt_conflicts_with_workflow"),
"session_pid": load.get("session_pid"),
"boundary_status": load.get("boundary_status"),
"boundary_clean": load.get("boundary_clean"),
"workflow_load_helper_result": boundary.workflow_load_helper_result(
load, project_root),
"reasons": reasons,
}
@@ -179,9 +195,12 @@ def review_workflow_load_blockers(
project_root: str | None = None,
) -> list[str]:
"""Reasons reviewer mutations must fail closed."""
boundary_reasons = boundary.boundary_blockers(project_root)
if boundary_reasons and _REVIEW_WORKFLOW_LOAD is None:
return boundary_reasons
status = workflow_load_status(project_root)
if not status.get("workflow_load_proof_present"):
return list(status.get("reasons") or [])
return list(status.get("reasons") or []) + boundary_reasons
if not status.get("workflow_load_valid"):
return list(status.get("reasons") or [])
return []
+129
View File
@@ -0,0 +1,129 @@
"""Exact per-mutation capability proof verifier for reviewer reports (#405).
A reviewer final report may prove ``review_pr`` capability and then also merge
a PR or delete a remote branch. Merge and branch deletion are separate
mutations that require their own exact capability proof — a nearby capability
must never authorize a different operation. This verifier requires a
mutation-capability table pairing every performed mutation with the exact
task/permission resolved *before* that mutation.
"""
from __future__ import annotations
import re
# Mutations this verifier tracks, with the exact capability tokens that
# authorize each. A row for the mutation must cite one of its own tokens;
# tokens from a different mutation (a "nearby capability") never count.
_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve",
"gitea.pr.request_changes", "request_changes_pr", "approve_pr")
_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge")
_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete")
# Detect that a mutation was actually performed (not merely mentioned as a
# non-goal or skipped).
_MERGE_PERFORMED = re.compile(
r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
r"^\s*[-*]?\s*merge result\s*:\s*merged\b|"
r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})",
re.IGNORECASE | re.MULTILINE,
)
_DELETE_PERFORMED = re.compile(
r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|"
r"\bdeleted (?:the )?(?:remote )?branch\b|"
r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)",
re.IGNORECASE | re.MULTILINE,
)
_REVIEW_PERFORMED = re.compile(
r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|"
r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*"
r"(?:approved|request[_ ]changes)\b|\breview submitted\b)",
re.IGNORECASE | re.MULTILINE,
)
# Post-hoc proof: capability resolved *after* the mutation is never valid.
_POST_HOC = re.compile(
r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*"
r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)",
re.IGNORECASE,
)
# The report must carry an explicit mutation-capability table.
_TABLE_MARKER = re.compile(
r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation",
re.IGNORECASE,
)
def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool:
low = text.lower()
return any(tok.lower() in low for tok in tokens)
def assess_mutation_capability_proof(report_text: str) -> dict:
"""Validate exact per-mutation capability proof in a reviewer report.
Returns ``{proven, block, reasons, safe_next_action}``. A report that
performs no mutation beyond an ordinary review passes only when its
review capability is cited; merge/delete each demand their own exact
capability row. Fail closed on nearby-capability substitution, a
missing table, missing rows, or post-hoc proof.
"""
text = report_text or ""
reasons: list[str] = []
merged = bool(_MERGE_PERFORMED.search(text))
deleted = bool(_DELETE_PERFORMED.search(text))
reviewed = bool(_REVIEW_PERFORMED.search(text))
extra_mutation = merged or deleted
if _POST_HOC.search(text):
reasons.append(
"capability proof recorded after the mutation; exact capability "
"must be resolved before each mutation"
)
# A review-only report needs its review capability cited; no table required.
if reviewed and not _tokens_present(text, _REVIEW_TOKENS):
reasons.append(
"review mutation performed without exact review capability proof "
"(review_pr / gitea.pr.review)"
)
if extra_mutation and not _TABLE_MARKER.search(text):
reasons.append(
"mutation beyond review performed without a mutation-capability "
"table (mutation, exact task/capability, result, order-before)"
)
if merged:
if not _tokens_present(text, _MERGE_TOKENS):
reasons.append(
"merge performed without exact merge capability proof "
"(merge_pr / gitea.pr.merge); nearby review_pr does not "
"authorize merge"
)
if deleted:
if not _tokens_present(text, _DELETE_TOKENS):
reasons.append(
"branch deletion performed without exact delete capability "
"proof (delete_branch / gitea.branch.delete); nearby "
"merge_pr does not authorize branch deletion"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"safe_next_action": (
"proceed"
if proven
else "add a mutation-capability table with the exact resolved "
"task/permission and pre-mutation order for every mutation; "
"skip any mutation whose exact capability is unproven"
),
}
+31 -5
View File
@@ -23,6 +23,7 @@ _ACTIVE_PHASES = frozenset({
"approved",
"request-changes",
"merging",
"adopted",
})
DEFAULT_LEASE_TTL_MINUTES = 120
@@ -380,9 +381,22 @@ def assess_post_merge_moot_lease(
}
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
def record_session_lease(
lease: dict[str, Any],
*,
lease_provenance: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Record the in-session lease mirror for mutation gates.
*lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare
manual seeding without provenance cannot satisfy merger/reviewer mutation
gates.
"""
global _SESSION_LEASE
_SESSION_LEASE = dict(lease)
stored = dict(lease)
if lease_provenance:
stored["lease_provenance"] = dict(lease_provenance)
_SESSION_LEASE = stored
return dict(_SESSION_LEASE)
@@ -415,13 +429,25 @@ def assess_mutation_lease_gate(
if not session:
reasons.append(
f"no in-session reviewer lease recorded; acquire via "
f"gitea_acquire_reviewer_pr_lease before {mutation}"
f"gitea_acquire_reviewer_pr_lease or adopt via "
f"gitea_adopt_merger_pr_lease before {mutation}"
)
elif session.get("pr_number") != pr_number:
else:
import merger_lease_adoption as mla
if not mla.is_sanctioned_session_lease(session):
reasons.append(
"in-session lease lacks sanctioned provenance; manual "
"_SESSION_LEASE seeding is not canonical proof — use "
"gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease"
)
if session and session.get("pr_number") != pr_number:
reasons.append(
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
)
elif (session.get("session_id") or "") != (session_id or session.get("session_id")):
elif session and (session.get("session_id") or "") != (
session_id or session.get("session_id")
):
reasons.append("session lease session_id mismatch (fail closed)")
if active:
+148
View File
@@ -0,0 +1,148 @@
"""Root checkout guard (#475).
The project root checkout is the stable control checkout on master/prgs/master.
Author/reviewer/merge flows must fail closed when the control checkout is
contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of
prgs/master). Isolated ``branches/...`` worktrees remain allowed.
"""
from __future__ import annotations
import os
import subprocess
from author_mutation_worktree import is_path_under_branches
from reviewer_worktree import parse_dirty_tracked_files
REMEDIATION = (
"Root checkout is not on master. Preserve state, switch root back to master, "
"and use scripts/worktree-review or the sanctioned issue worktree flow."
)
BASE_BRANCHES = frozenset({"master", "main", "dev"})
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
def resolve_remote_master_sha(
canonical_repo_root: str,
*,
remote_refs: tuple[str, ...] | None = None,
) -> str | None:
"""Return the commit SHA for the tracking master ref when available."""
root = (canonical_repo_root or "").strip()
if not root:
return None
for ref in remote_refs or REMOTE_MASTER_REFS:
res = subprocess.run(
["git", "-C", root, "rev-parse", "--verify", ref],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
sha = (res.stdout or "").strip()
if sha:
return sha
return None
resolve_tracking_master_sha = resolve_remote_master_sha
def assess_root_checkout_guard(
*,
workspace_path: str,
canonical_repo_root: str,
current_branch: str | None,
head_sha: str | None,
porcelain_status: str,
remote_master_sha: str | None,
resolved_role: str | None = None,
actual_role: str | None = None,
) -> dict:
"""Fail closed when the control checkout is not clean master/prgs/master.
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
is the *active profile* role (#540). The reconciler exemption honours either
signal so a ``comment_issue`` preflight (which stamps the task role as
``author``) cannot strip a genuine reconciler of its exemption. An actual
author profile classifies as ``author`` in both signals, so author blocking
on a contaminated control checkout is preserved.
Merger *strictness* (a merger must not be auto-exempted by working from a
``branches/`` worktree) stays keyed on the resolved task role: merge
operations resolve their own task role, and widening the merger test with
``actual_role`` would wrongly subject a merger operating from its clean
workspace under a non-merge task to full control-checkout checks.
"""
reasons: list[str] = []
root = os.path.realpath(canonical_repo_root)
workspace = os.path.realpath(workspace_path)
branch = (current_branch or "").strip()
dirty_files = parse_dirty_tracked_files(porcelain_status)
if resolved_role == "reconciler" or actual_role == "reconciler":
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
if resolved_role != "merger" and is_path_under_branches(workspace, root):
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
if dirty_files:
reasons.append(
"control checkout has tracked local edits before role work "
f"(dirty files: {', '.join(dirty_files)})"
)
if not branch:
reasons.append("control checkout is detached HEAD; expected branch 'master'")
elif branch not in BASE_BRANCHES:
reasons.append(
f"control checkout branch '{branch}' is not a stable base branch "
f"({'/'.join(sorted(BASE_BRANCHES))})"
)
if remote_master_sha and head_sha and head_sha != remote_master_sha:
reasons.append(
"control checkout HEAD does not match prgs/master "
f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})"
)
proven = not reasons
return _assessment(proven, reasons, root, workspace, branch or None, head_sha, dirty_files)
def format_root_checkout_guard_error(assessment: dict) -> str:
"""Single RuntimeError message for MCP preflight gates."""
root = assessment.get("canonical_repo_root") or "(unknown)"
workspace = assessment.get("workspace_path") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown root checkout violation"])
return (
f"Root checkout guard (#475): {reasons}. "
f"canonical repository root: {root}; workspace: {workspace}. "
f"{REMEDIATION}"
)
def _assessment(
proven: bool,
reasons: list[str],
canonical_repo_root: str,
workspace_path: str,
current_branch: str | None,
head_sha: str | None,
dirty_files: list[str],
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"canonical_repo_root": canonical_repo_root,
"workspace_path": workspace_path,
"current_branch": current_branch,
"head_sha": head_sha,
"dirty_files": dirty_files,
"remediation": REMEDIATION,
}
assess_root_checkout = assess_root_checkout_guard
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON="$ROOT_DIR/venv/bin/python"
if [[ ! -x "$PYTHON" ]]; then
echo "ERROR: expected virtualenv Python at $PYTHON" >&2
echo "Create the venv first, then run: venv/bin/python -m pytest" >&2
exit 1
fi
exec "$PYTHON" -m pytest "$@"
@@ -63,8 +63,14 @@ Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
- Current status:
- Safe next action:
- Safety statement:
- Workflow-load helper result:
```
The **Workflow-load helper result** field must carry structured output from
`gitea_load_review_workflow` (workflow_hash, final_report_schema_hash,
boundary_status). Narrative claims that workflow files were viewed locally are
not sufficient (#403).
### Already-landed handoff overrides
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
@@ -10,6 +10,10 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
and is blocked when it disagrees with the local git remote URL.
- Only an eligible, NON-author merger merges. If authenticated user == PR
author → STOP.
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
@@ -42,7 +46,15 @@ Steps:
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
Then run the cleanup template (worktree-cleanup.md):
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
- Record merge mutations separately from cleanup mutations in the controller handoff.
- Hand cleanup to a `prgs-reconciler` session — never raw `git branch -d`,
`git push --delete`, curl/API comment deletion, or local scripts.
- Reconciler cleanup must cite authorized MCP tools
(`gitea_reconcile_merged_cleanups`, `gitea_cleanup_post_merge_moot_lease`,
`gitea_delete_branch`, etc.) plus `gitea.branch.delete` capability proof.
Then run the cleanup template (worktree-cleanup.md) in a reconciler session:
- Verify expected file/commit presence on master (post-merge file-presence verification):
- Run: git fetch <remote> --prune; git checkout master; git pull <remote> master --ff-only
- Verify that the expected files added/modified in the PR are present on master (or absent if deleted).
@@ -0,0 +1,27 @@
# Template: post-merge cleanup (reconciler only)
Copy, fill the `<...>` fields, and paste as the task prompt. Run only after merge
is confirmed on remote master. Merger sessions must hand off here — never perform
this cleanup inline (#517).
```text
Task: MCP-native post-merge cleanup for PR #<pr> / issue #<n>.
Rules:
- Active profile must be reconciler (`prgs-reconciler`) with `gitea.branch.delete`.
- Use MCP tools only — no raw git branch delete, no API comment deletion scripts.
- Record cleanup mutations separately from merge mutations in the handoff.
Steps:
1. `gitea_resolve_task_capability(task="reconcile_merged_cleanups", remote=prgs)`
2. Confirm PR #<pr> merged on <remote>/master.
3. `gitea_cleanup_post_merge_moot_lease` if a reviewer lease remains (append-only).
4. `gitea_reconcile_merged_cleanups` for branch/worktree cleanup with dry-run first.
5. Report authorized cleanup tools used and reconciler capability proof.
Handoff ledger (required fields):
- Merge mutations: (none — merger already recorded gitea_merge_pr)
- Cleanup mutations: list exact MCP tools invoked
- Reconciler capability: profile + gitea.branch.delete proof
- Next actor: controller acceptance or none
```
@@ -35,6 +35,10 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
and is blocked when it disagrees with the local git remote URL.
- Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
report the starting worktree path and whether it was dirty. If unrelated
@@ -15,6 +15,10 @@ Rules (llm-project-workflow):
- Work only in an isolated branch worktree under branches/. The main checkout
is orchestration/status only.
- Do not self-review or self-merge.
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
and is blocked when it disagrees with the local git remote URL.
Steps:
0. Work Selection Rule — before any claim, branch, or file edits, acquire or
@@ -26,3 +26,43 @@ Steps:
Handoff: merge confirmed, issue closed, branch+worktree removed, checkout clean.
```
## Branches cleanup audit integrity (#404)
Any bulk or multi-path cleanup under `branches/` must capture auditable before/after
identity for every initial directory and registered worktree. Use
`worktree_cleanup_audit.capture_cleanup_snapshot` before and after cleanup, record
every intentional removal in a removal log (path, method, order, timestamp,
pre-removal proof), then run `reconcile_cleanup_audit` and
`assess_cleanup_audit_integrity`.
The cleanup report must include a reconciliation table:
* initial count
* removed count
* preserved count
* missing-unexplained count
* final count
Fail closed when:
* a preserved (active PR, dirty, claim/lease, or unsafe) worktree disappears without
a removal log entry or explicit explanation
* the removal log omits a removed clean-stale path
* final counts do not reconcile with initial minus removed
If another session removes or mutates a worktree during cleanup, record the path
under explained missing entries — never treat silent disappearance as success.
## Bulk `branches/` cleanup audit (#404)
Before removing multiple session-owned worktrees:
1. Call `gitea_capture_branches_worktree_snapshot` and record the before snapshot.
2. Remove only paths classified as `clean_stale_removable` with explicit per-path proof.
3. Log every removal with path, method, and timestamp/order.
4. Capture an after snapshot with the same tool.
5. Call `gitea_assess_worktree_cleanup_integrity` with before, after, and the removal log.
6. Fail closed when any protected path (active PR, dirty, claim/lease) disappears
without an explained state transition.
7. Final report must include the reconciliation table and `git worktree list` proof.
@@ -36,6 +36,44 @@ If available, load it first and report:
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 0A. Workflow-load and session boundary anchor (#403)
The MCP gate is the authority — not local file viewing.
Before any reviewer mutation:
1. Record pre-review commands with `gitea_record_pre_review_command` when they
are not automatically classified (inventory/diagnostic commands may be
recorded explicitly for proof).
2. Call `gitea_load_review_workflow` to establish workflow hash proof **and**
session boundary state in the same in-process session proof.
3. Do not claim the workflow was loaded by reading
`skills/llm-project-workflow/workflows/review-merge-pr.md` as a local file;
that narrative does not satisfy the validator.
Allowed before workflow load (classify as `read_only_inventory` or
`diagnostic`):
* `gitea_whoami`, `gitea_resolve_task_capability`, `gitea_list_prs`,
`gitea_view_pr`, `gitea_get_runtime_context`
* `git fetch` / `git remote update` for inventory
* `git status`, `git worktree list` (read-only)
Boundary violations (block downstream reviewer mutations even after load):
* validation commands (`pytest`, `python -m unittest`) in the main checkout
* local profile/credential/config inspection (`profiles.json`, `gitea-mcp.json`,
`.env`, keychain dumps)
* MCP repair (`pkill`, MCP config edits)
* git mutations before workflow load
Final reports must include a structured **Workflow-load helper result** block
copied from `gitea_load_review_workflow`, including at minimum:
* `workflow_hash`
* `final_report_schema_hash`
* `boundary_status` (`clean` or `violation`)
## 1. Start with live identity, profile, runtime, and capability checks
Prove:
@@ -796,6 +834,21 @@ inventory before continuing.
Final reports must include lease session id, acquisition proof, heartbeat
status, and release/blocked status.
## 26B-1. Merger lease adoption (#536)
When review and merge are **different sessions**, the merger must adopt the
reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`.
Before merge in a merger-only session:
1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`.
2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under
`branches/`, passing `expected_head_sha` pinned to the approved head.
3. Quote the adoption comment id and `adopted_from_session_id` in the handoff.
4. Proceed to `gitea_merge_pr` only after adoption succeeds.
Manual in-process lease seeding is rejected by mutation gates (fail closed).
## 26C. Conflict-fix lease and stale-head protection (#399)
Before validating, approving, or merging a PR:
@@ -871,6 +924,16 @@ Confirm:
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
Review, baseline, and merge-simulation worktrees created during this run are
transient and are removed automatically at successful completion once they are
clean, carry no open PR, and hold no active lease (#401). Use
`gitea_audit_worktree_cleanup` (read-only) to classify `branches/` entries; only
`clean_stale_removable` and `detached_review_leftover` may be removed, one-by-one,
after `git worktree list` proof plus per-worktree proof of path, branch/HEAD,
clean/dirty status, no active PR/lease, and the removal result. Dirty,
active-PR, active-issue, and leased worktrees are never deleted automatically; a
failed removal must be reported with the leftover path and reason.
Do not delete or mutate unrelated branches/worktrees.
Do not touch the main checkout except to update the stable branch after merge if explicitly allowed by the workflow.
@@ -915,6 +978,27 @@ If any gate fails, report:
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
## 28B. MCP-native cleanup only (#517)
Post-merge cleanup of leases, comments, branches, and worktrees must go through
explicit MCP tools — never raw git, curl/API scripts, or ad hoc helper scripts.
Merger and reviewer sessions must **not** perform cleanup inline. Record:
* **Merge mutations** — only `gitea_merge_pr` (or review mutations for review-only runs)
* **Cleanup mutations** — only authorized reconciler MCP tools, cited by exact tool name
Authorized cleanup tools include `gitea_reconcile_merged_cleanups`,
`gitea_cleanup_post_merge_moot_lease`, `gitea_delete_branch`, and
`gitea_cleanup_merged_pr_branch` (when available). Lease cleanup uses
append-only release comments — never delete another session's lease comment.
Hand cleanup to a `prgs-reconciler` session with `gitea.branch.delete` capability
proof. Raw `git branch -d`, `git push --delete`, and comment-deletion API calls
are blocked in final-report validation.
Template: `templates/post-merge-cleanup-handoff.md`.
## 29. Recovery handoff rules
If blocked, produce a recovery handoff with:
@@ -1013,6 +1097,26 @@ Use precise wording:
Do not collapse review, merge, cleanup, or external-state mutations into vague wording.
## 31B. Mutation-capability table (#405)
Every performed mutation requires exact capability proof resolved **before** that
mutation executes. Nearby capabilities never authorize a different operation —
`review_pr` does not authorize `merge_pr`, and `merge_pr` does not authorize
`delete_branch` / `gitea.branch.delete`.
When any mutation beyond a bare review occurs (merge, branch delete, issue
close/comment, etc.), the final report must include a **mutation-capability table**
with one row per performed mutation:
* mutation (tool/action name)
* exact task/capability resolved (for example `merge_pr` / `gitea.pr.merge`)
* result
* order/timestamp proof that capability was resolved before the mutation
If exact capability proof is missing, skip the mutation or stop the workflow —
never claim a performed mutation without its row. Post-hoc capability proof after
the mutation fails validation.
## 31A. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during reviewer runs unless the canonical workflow or operator explicitly requires it.
@@ -626,9 +626,14 @@ When pushing to an existing PR branch to resolve merge conflicts:
* 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.
* explicit `remote`, `org`, and `repo` when not using defaults
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
and produce a recovery handoff with the structured `reasons` and
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
unsafe or safe (#519).
4. Do not push when a reviewer holds an active lease on the same PR.
5. Do not force-push.
6. Do not push from the main checkout or wrong cwd.
Conflict-fix final reports must state:
@@ -699,6 +704,39 @@ Do not update the main checkout unless the canonical workflow explicitly allows
Any cleanup is a mutation and must be reported.
### 22A. Session-owned worktree cleanup and TTL (#401)
Every session-owned worktree created under `branches/` has ownership metadata:
path, workflow type, issue number, PR number, branch/head SHA, creator
identity/profile, created timestamp, last-used timestamp, and cleanup
eligibility.
Cleanup is classification-driven. `gitea_audit_worktree_cleanup` (read-only)
classifies every `branches/` entry as exactly one of:
* `active_open_pr` — branch has an open PR; never auto-removed.
* `active_issue_work` — active claim/lease or fresh issue worktree; never
auto-removed.
* `dirty_local_worktree` — uncommitted changes; never auto-removed.
* `clean_stale_removable` — clean, no PR, no lease; removable.
* `detached_review_leftover` — clean detached review/baseline/merge-simulation
worktree; removable.
* `unsafe_unknown` — protected base checkout or unknown workflow type; never
auto-removed.
Only `clean_stale_removable` and `detached_review_leftover` may be removed, and
only one-by-one after `git worktree list` proof plus per-worktree proof of:
worktree path, branch/HEAD, clean/dirty status, no active PR/lease, and the
removal result. Review, baseline, and merge-simulation worktrees are removed
automatically at successful workflow completion; issue/conflict-fix worktrees
are removed only after their TTL (`GITEA_WORKTREE_TTL_HOURS`, default 24h)
expires and no lock/lease is held.
Dirty, active-PR, active-issue, and leased worktrees are never deleted
automatically. If a removal fails, the final report must list the leftover
worktree path and the reason. Include the `git worktree list` output as final
cleanup verification.
## 23. Recovery handoff rules
If blocked, produce a recovery handoff with:
+4
View File
@@ -68,6 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.merge",
"role": "merger",
},
"adopt_merger_pr_lease": {
"permission": "gitea.pr.comment",
"role": "reviewer",
},
"blind_pr_queue_review": {
"permission": "gitea.pr.review",
"role": "reviewer",
+139
View File
@@ -0,0 +1,139 @@
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
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
_fetch_pr_lease_comments_safe,
gitea_assess_conflict_fix_push,
)
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "prgs-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.branch.push,gitea.pr.create",
}
HEAD_BEFORE = "dad1dc8d5108ab01ed83065334116d7425a4471c"
HEAD_AFTER = "3f3d6cb35d0fe225dcb236247f2a2d0ec193fa35"
OPEN_PR = {
"number": 508,
"state": "open",
"head": {"sha": HEAD_AFTER},
}
class TestFetchPrLeaseCommentsSafe(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_pr_lookup_404_returns_structured_failure(self, _auth, mock_api):
mock_api.side_effect = RuntimeError(
'HTTP 404: {"message":"issue does not exist","index":508}'
)
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertFalse(result["success"])
self.assertEqual(result["comments"], [])
self.assertTrue(result["reasons"])
self.assertIn("PR lookup failed", result["reasons"][0])
self.assertEqual(result["pr_lookup"], "failed")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_comment_fetch_404_after_pr_lookup(self, _auth, mock_api):
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
raise RuntimeError(
'HTTP 404: {"message":"issue does not exist","index":508}'
)
mock_api.side_effect = _api
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertFalse(result["success"])
self.assertIn("comment fetch failed", result["reasons"][0].lower())
self.assertEqual(result["pr_lookup"], "ok")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_success_returns_comments_and_head_sha(self, _auth, mock_api):
comment = {"id": 1, "body": "<!-- mcp-conflict-fix-lease:v1 -->"}
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
return [comment]
mock_api.side_effect = _api
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertTrue(result["success"])
self.assertEqual(result["comments"], [comment])
self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"])
class TestAssessConflictFixPushTool(unittest.TestCase):
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_returns_structured_block_when_pr_lookup_fails(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": False,
"comments": [],
"reasons": ["PR lookup failed"],
"pr_lookup": "failed",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertFalse(result["push_allowed"])
self.assertTrue(result.get("assessment_failed"))
self.assertEqual(result["pr_lookup"], "failed")
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_valid_push_assessment_when_pr_and_comments_resolve(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": True,
"comments": [],
"reasons": [],
"pr_lookup": "ok",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
"head_sha": HEAD_AFTER,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertTrue(result["push_allowed"])
self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -294,9 +294,13 @@ class TestGatedToolAudit(_AuditWiringBase):
super().setUp()
from tests.test_mcp_server import _init_reviewer_session, _install_owned_reviewer_lease
import reviewer_pr_lease
import review_workflow_boundary
import review_workflow_load
# Session init clears any prior session lease (#407) and loads workflow (#389).
_init_reviewer_session("prgs")
self.addCleanup(review_workflow_load.clear_review_workflow_load)
self.addCleanup(review_workflow_boundary.clear_pre_review_commands)
self._lease_patch = _install_owned_reviewer_lease(8)
self._lease_patch.start()
self._auth_identity_patch = patch(
+12 -2
View File
@@ -38,8 +38,18 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
@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):
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "a" * 40,
"porcelain_status": "",
},
)
def test_create_issue_stable_checkout_rejected(
self, _git, _remote_sha, _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):
+271
View File
@@ -0,0 +1,271 @@
"""Regression tests for #540: comment_issue preflight must not poison the
actual reconciler role for #274 branch-only / #475 root-checkout exemptions.
`resolve_task_capability("comment_issue")` stamps
``_preflight_resolved_role = "author"`` because ``comment_issue`` has
``required_role_kind = author``. Before the fix, ``_effective_workspace_role``
preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an
author inside the #274 branch-only mutation guard and the #475 root checkout
guard, blocking ``gitea_create_issue_comment`` from the control checkout.
The fix keys the role exemptions off the *actual profile role* as well, so the
exemption survives the poisoned task role while author profiles stay blocked.
"""
from __future__ import annotations
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 # noqa: E402
import root_checkout_guard as rcg # noqa: E402
FAKE_AUTH = "token test"
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
MASTER_SHA = "a" * 40
OTHER_SHA = "b" * 40
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"audit_label": "prgs-reconciler",
}
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.issue.create",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
],
"audit_label": "prgs-author",
}
REVIEWER_PROFILE = {
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"audit_label": "prgs-reviewer",
}
class TestActualProfileRole(unittest.TestCase):
"""`_actual_profile_role` ignores the poisoned preflight task role (#540)."""
def tearDown(self):
srv._preflight_resolved_role = None
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_profile_role_survives_author_task_stamp(self, _profile):
srv._preflight_resolved_role = "author" # comment_issue poison
self.assertEqual(srv._actual_profile_role(), "reconciler")
# _effective_workspace_role is still poisoned to author (unchanged #510)...
self.assertEqual(srv._effective_workspace_role(), "author")
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
def test_author_profile_role_is_author(self, _profile):
srv._preflight_resolved_role = "author"
self.assertEqual(srv._actual_profile_role(), "author")
@patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE)
def test_reviewer_profile_role_is_reviewer(self, _profile):
srv._preflight_resolved_role = "author"
self.assertEqual(srv._actual_profile_role(), "reviewer")
class TestBranchesOnlyExemptionRealRole(unittest.TestCase):
"""#274 branches-only exemption keys off the actual profile role (#540)."""
def tearDown(self):
srv._preflight_resolved_role = None
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_exempt_despite_author_task_stamp(self, _profile):
srv._preflight_resolved_role = "author" # poison
# Must return without raising and without resolving an author worktree.
with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx:
srv._enforce_branches_only_author_mutation()
ctx.assert_not_called()
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
def test_author_not_exempt(self, _profile):
srv._preflight_resolved_role = "author"
sentinel = RuntimeError("author-mutation-guard-reached")
def _blow_up(*_a, **_k):
raise sentinel
# Author is not exempt: the guard proceeds to resolve/assess the
# workspace (proven by reaching the patched context resolver).
with patch(
"gitea_mcp_server._resolve_namespace_mutation_context",
side_effect=_blow_up,
):
with self.assertRaises(RuntimeError) as raised:
srv._enforce_branches_only_author_mutation()
self.assertIs(raised.exception, sentinel)
class TestRootCheckoutGuardRealRole(unittest.TestCase):
"""#475 root guard honours the actual profile role too (#540)."""
def _assess(self, **kwargs):
defaults = {
"workspace_path": CONTROL_CHECKOUT_ROOT,
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
"current_branch": "feat/some-branch",
"head_sha": OTHER_SHA,
"porcelain_status": " M gitea_mcp_server.py\n",
"remote_master_sha": MASTER_SHA,
"resolved_role": "author", # poisoned task role
}
defaults.update(kwargs)
return rcg.assess_root_checkout_guard(**defaults)
def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self):
result = self._assess(actual_role="reconciler")
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_actual_author_still_blocked_on_contaminated_root(self):
result = self._assess(actual_role="author")
self.assertTrue(result["block"])
def test_merger_strictness_stays_keyed_on_resolved_task_role(self):
# When the merge task resolves the merger role, the branches/ auto
# exemption is denied and a clean control checkout is required.
blocked = self._assess(
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
current_branch="review-pr-1",
porcelain_status="",
head_sha=OTHER_SHA,
resolved_role="merger",
)
self.assertTrue(blocked["block"])
def test_actual_merger_does_not_over_tighten_non_merge_task(self):
# A merger profile whose current task did NOT resolve to merger keeps
# the branches/ workspace exemption (regression guard for #540): the
# actual_role signal must not force merger strictness here.
result = self._assess(
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
current_branch="review-pr-1",
porcelain_status="",
head_sha=OTHER_SHA,
resolved_role="reviewer",
actual_role="merger",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_backward_compatible_without_actual_role(self):
# No actual_role supplied: behaviour falls back to resolved_role.
result = self._assess(resolved_role="reconciler")
self.assertTrue(result["proven"])
class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
"""Integration: reconciler comment survives the poisoned author task role."""
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
srv._preflight_capability_baseline_porcelain = ""
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
srv._preflight_resolved_role = None
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch(
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value=MASTER_SHA,
)
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": MASTER_SHA,
"porcelain_status": "",
},
)
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_comment_from_control_checkout_succeeds(
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
):
srv._preflight_resolved_role = "author" # comment_issue poison
mock_api.return_value = {"id": 9001, "html_url": "https://x/y"}
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)
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
result = srv.gitea_create_issue_comment(
515, "canonical reconciler audit", remote="prgs"
)
self.assertTrue(result["success"])
self.assertEqual(result["comment_id"], 9001)
mock_api.assert_called_once()
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch(
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value=MASTER_SHA,
)
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": MASTER_SHA,
"porcelain_status": "",
},
)
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
def test_author_comment_from_control_checkout_blocked(
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
):
srv._preflight_resolved_role = "author"
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)
with self.assertRaises(RuntimeError):
srv.gitea_create_issue_comment(
515, "author note", remote="prgs"
)
mock_api.assert_not_called()
if __name__ == "__main__":
unittest.main()
+257
View File
@@ -0,0 +1,257 @@
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = {"Authorization": "token test-token"}
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
}
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_resolved_task = "comment_issue"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
srv._preflight_reviewer_violation_files = []
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self.addCleanup(self._restore_preflight_mode)
def _restore_preflight_mode(self):
srv._preflight_in_test_mode = self._orig_in_test
def _git_state(self, valid_worktree: str):
def side_effect(path):
real = os.path.realpath(path)
if real == os.path.realpath(CONTROL_CHECKOUT_ROOT):
return {
"current_branch": "master",
"head_sha": "a" * 40,
"porcelain_status": "",
}
if real == os.path.realpath(valid_worktree):
return {
"current_branch": "fix/issue-560-issue-comment-worktree-path",
"head_sha": "b" * 40,
"porcelain_status": "",
}
return {
"current_branch": "other",
"head_sha": "c" * 40,
"porcelain_status": "",
}
return side_effect
def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None):
def side_effect(cmd, *args, **kwargs):
result = MagicMock(returncode=0, stdout="")
cwd = ""
if isinstance(cmd, list) and "-C" in cmd:
cwd = os.path.realpath(cmd[cmd.index("-C") + 1])
if isinstance(cmd, list) and "--show-toplevel" in cmd:
if cwd == os.path.realpath(valid_worktree):
result.stdout = f"{valid_worktree}\n"
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
result.stdout = f"{outside_worktree}\n"
else:
result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n"
return result
if isinstance(cmd, list) and "--git-common-dir" in cmd:
if cwd == os.path.realpath(valid_worktree):
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
result.stdout = "/tmp/other-repo/.git\n"
else:
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
return result
return result
return side_effect
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
def test_comment_rejects_control_checkout_without_worktree_path(
self, _remote_sha, mock_api, _auth
):
valid_worktree = os.path.join(
CONTROL_CHECKOUT_ROOT,
"branches",
"issue-560-issue-comment-worktree-path",
)
mock_api.return_value = {"id": 42}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
side_effect=self._git_state(valid_worktree),
):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
remote="prgs",
)
self.assertIn("stable control checkout", str(ctx.exception))
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@patch("os.path.isdir", return_value=True)
@patch("os.path.exists", return_value=True)
def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo(
self, _exists, _isdir, _remote_sha, mock_api, _auth
):
valid_worktree = os.path.join(
CONTROL_CHECKOUT_ROOT,
"branches",
"issue-560-issue-comment-worktree-path",
)
mock_api.return_value = {"id": 43}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
side_effect=self._git_state(valid_worktree),
):
with patch(
"gitea_mcp_server.subprocess.run",
side_effect=self._subprocess(valid_worktree),
):
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
worktree_path=valid_worktree,
)
self.assertTrue(result["success"])
self.assertTrue(result["performed"])
self.assertEqual(result["comment_id"], 43)
method, url, _auth_arg, payload = mock_api.call_args[0]
self.assertEqual(method, "POST")
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
self.assertEqual(payload, {"body": "canonical evidence"})
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@patch("os.path.isdir", return_value=True)
@patch("os.path.exists", return_value=True)
def test_comment_rejects_outside_repo_worktree_path(
self, _exists, _isdir, _remote_sha, mock_api, _auth
):
valid_worktree = os.path.join(
CONTROL_CHECKOUT_ROOT,
"branches",
"issue-560-issue-comment-worktree-path",
)
outside_worktree = "/tmp/not-gitea-tools-worktree"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
side_effect=self._git_state(valid_worktree),
):
with patch(
"gitea_mcp_server.subprocess.run",
side_effect=self._subprocess(valid_worktree, outside_worktree),
):
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
remote="prgs",
worktree_path=outside_worktree,
)
self.assertIn("does not belong to the target repository", str(ctx.exception))
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
def test_comment_still_requires_capability_preflight(self, mock_api, _auth):
srv._preflight_capability_called = False
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
remote="prgs",
worktree_path=os.path.join(
CONTROL_CHECKOUT_ROOT,
"branches",
"issue-560-issue-comment-worktree-path",
),
)
self.assertIn("Task capability", str(ctx.exception))
mock_api.assert_not_called()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@patch("os.path.isdir", return_value=True)
@patch("os.path.exists", return_value=True)
def test_comment_still_requires_issue_comment_permission(
self, _exists, _isdir, _remote_sha, mock_api, _auth
):
valid_worktree = os.path.join(
CONTROL_CHECKOUT_ROOT,
"branches",
"issue-560-issue-comment-worktree-path",
)
denied_env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
side_effect=self._git_state(valid_worktree),
):
with patch(
"gitea_mcp_server.subprocess.run",
side_effect=self._subprocess(valid_worktree),
):
with patch.dict(os.environ, denied_env, clear=True):
result = srv.gitea_create_issue_comment(
issue_number=557,
body="canonical evidence",
remote="prgs",
worktree_path=valid_worktree,
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("permission_report", result)
self.assertEqual(
result["permission_report"]["missing_permission"],
"gitea.issue.comment",
)
mock_api.assert_not_called()
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -0,0 +1,124 @@
"""Hermetic tests for repository-root MCP operator menu (#478)."""
import os
import stat
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "mcp-menu.sh"
DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
REQUIRED_MENU_LABELS = (
"Project status / root checkout health",
"Author workflow prompts",
"Reviewer workflow prompts",
"Merger workflow prompts",
"Reconciler workflow prompts",
"Onboarding new project to this MCP workflow",
"Proxmox deployment menu placeholder",
"Create Proxmox LXC placeholder",
"Run tests",
"Exit",
)
DANGEROUS_PATTERNS = (
"git push --force",
"git push -f",
"--force-with-lease",
"delete-branch",
"gitea_delete_branch",
"issue-locks",
"issue_lock_store",
"curl ",
"wget ",
)
class TestMcpMenuScript(unittest.TestCase):
def setUp(self):
self.assertTrue(SCRIPT.is_file(), "mcp-menu.sh must exist at repo root")
self.content = SCRIPT.read_text(encoding="utf-8")
def test_script_exists_at_repo_root(self):
self.assertEqual(SCRIPT.name, "mcp-menu.sh")
self.assertEqual(SCRIPT.parent, REPO_ROOT)
def test_executable_bit_is_set(self):
mode = SCRIPT.stat().st_mode
self.assertTrue(mode & stat.S_IXUSR, "mcp-menu.sh must be executable by owner")
def test_shebang(self):
first_line = self.content.splitlines()[0]
self.assertEqual(first_line, "#!/usr/bin/env bash")
def test_uses_set_euo_pipefail(self):
self.assertIn("set -euo pipefail", self.content)
def test_no_dangerous_commands(self):
lowered = self.content.lower()
for pattern in DANGEROUS_PATTERNS:
with self.subTest(pattern=pattern):
self.assertNotIn(pattern.lower(), lowered)
def test_no_branch_deletion_verbs(self):
for token in ("git branch -D", "git branch -d", "push :refs"):
with self.subTest(token=token):
self.assertNotIn(token, self.content)
def test_contains_required_menu_labels(self):
for label in REQUIRED_MENU_LABELS:
with self.subTest(label=label):
self.assertIn(label, self.content)
def test_run_tests_prefers_run_tests_sh(self):
self.assertIn('run-tests.sh', self.content)
run_tests_idx = self.content.index("run_tests()")
run_tests_body = self.content[run_tests_idx : run_tests_idx + 800]
pytest_idx = run_tests_body.find("pytest")
run_tests_sh_idx = run_tests_body.find("run-tests.sh")
self.assertGreater(pytest_idx, 0)
self.assertGreater(run_tests_sh_idx, 0)
self.assertLess(run_tests_sh_idx, pytest_idx)
def test_run_tests_fail_closed_without_runner(self):
self.assertIn("fail closed", self.content.lower())
self.assertIn("exit 1", self.content)
def test_proxmox_entries_are_placeholders(self):
self.assertIn("TODO / issue-backed", self.content)
self.assertIn("NOT implemented", self.content)
def test_root_health_shows_required_fields(self):
health_fn = self._extract_function("show_root_checkout_health")
for snippet in (
"status --short --branch",
"rev-parse HEAD",
"prgs/master",
"WARNING: root checkout",
):
with self.subTest(snippet=snippet):
self.assertIn(snippet, health_fn)
def test_docs_mention_mcp_menu_sh(self):
self.assertTrue(DOCS.is_file(), "docs/mcp-menu.md must exist")
docs_text = DOCS.read_text(encoding="utf-8")
self.assertIn("./mcp-menu.sh", docs_text)
self.assertIn("placeholder", docs_text.lower())
def _extract_function(self, name: str) -> str:
marker = f"{name}() {{"
start = self.content.index(marker)
depth = 0
for idx in range(start, len(self.content)):
char = self.content[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return self.content[start : idx + 1]
self.fail(f"Could not parse function {name}")
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
"""Tests for MCP-native post-merge cleanup proof (#517)."""
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 mcp_native_cleanup_proof import ( # noqa: E402
assess_authorized_reconciler_cleanup_path,
assess_mcp_native_cleanup_proof,
assess_merger_cleanup_handoff_guidance,
assess_raw_branch_delete_report,
assess_raw_comment_delete_report,
)
def _authorized_cleanup_report(**overrides):
fields = {
"Merge mutations": "gitea_merge_pr on PR #100",
"Cleanup mutations": (
"gitea_reconcile_merged_cleanups deleted remote branch; "
"gitea_cleanup_post_merge_moot_lease released lease"
),
"Reconciler capability": "prgs-reconciler / gitea.branch.delete resolved",
"Next actor": "reconciler session complete",
}
fields.update(overrides)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
class TestRawBranchDeleteBlocked(unittest.TestCase):
def test_git_branch_d_blocked(self):
report = "Cleanup mutations: git branch -d feat/issue-1-x"
result = assess_raw_branch_delete_report(report)
self.assertTrue(result["block"])
self.assertIn("git branch -d", result["commands"][0])
def test_git_push_delete_in_ledger_blocked(self):
report = "- Git ref mutations: git push origin --delete feat/x"
result = assess_raw_branch_delete_report(report)
self.assertTrue(result["block"])
def test_narrative_git_push_delete_not_blocked(self):
report = "\n".join([
"## Validation",
"Workflow §28B forbids raw git push --delete for cleanup.",
"- Cleanup mutations: none",
"- Git ref mutations: none",
"- Worktree mutations: none",
])
result = assess_raw_branch_delete_report(report)
self.assertFalse(result["block"])
def test_authorized_mcp_path_passes(self):
report = _authorized_cleanup_report()
result = assess_raw_branch_delete_report(report)
self.assertFalse(result["block"])
class TestRawCommentDeleteBlocked(unittest.TestCase):
def test_curl_delete_comment_in_ledger_blocked(self):
report = (
"- Cleanup mutations: curl -X DELETE https://gitea.example/api/v1/repos/o/r/"
"issues/9/comments/42"
)
result = assess_raw_comment_delete_report(report)
self.assertTrue(result["block"])
def test_narrative_comment_delete_not_blocked(self):
report = "\n".join([
"Files reviewed: post_merge_cleanup_proof.py (raw comment delete patterns)",
"Validation note: never use delete_issue_comment for lease cleanup.",
"- Cleanup mutations: none",
"- Git ref mutations: none",
"- Worktree mutations: none",
])
result = assess_raw_comment_delete_report(report)
self.assertFalse(result["block"])
def test_delete_issue_comment_helper_in_ledger_blocked(self):
report = "- Cleanup mutations: delete_issue_comment purged lease comments"
result = assess_raw_comment_delete_report(report)
self.assertTrue(result["block"])
def test_moot_lease_append_only_passes(self):
report = _authorized_cleanup_report()
result = assess_raw_comment_delete_report(report)
self.assertFalse(result["block"])
class TestAuthorizedReconcilerPath(unittest.TestCase):
def test_cleanup_without_mcp_tool_blocked(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: removed branch manually after merge",
"- Reconciler capability: prgs-reconciler",
])
result = assess_authorized_reconciler_cleanup_path(report)
self.assertTrue(result["block"])
self.assertTrue(any("authorized MCP" in r for r in result["reasons"]))
def test_authorized_cleanup_succeeds(self):
result = assess_authorized_reconciler_cleanup_path(_authorized_cleanup_report())
self.assertFalse(result["block"])
def test_git_ref_cleanup_without_mcp_tool_blocked(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: git branch -D feat/issue-517-test",
"- Reconciler capability: prgs-reconciler",
])
result = assess_authorized_reconciler_cleanup_path(report)
self.assertTrue(result["block"])
self.assertTrue(
any("Git ref mutations cleanup must name" in r for r in result["reasons"])
)
def test_worktree_cleanup_without_mcp_tool_blocked(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: none",
"- Worktree mutations: git worktree remove branches/review-pr542",
"- Reconciler capability: prgs-reconciler",
])
result = assess_authorized_reconciler_cleanup_path(report)
self.assertTrue(result["block"])
self.assertTrue(
any("Worktree mutations cleanup must name" in r for r in result["reasons"])
)
def test_git_fetch_in_git_ref_mutations_not_cleanup_claim(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: git fetch prgs master",
])
result = assess_authorized_reconciler_cleanup_path(report)
self.assertFalse(result["block"])
class TestMergerHandoffGuidance(unittest.TestCase):
def test_merger_cleanup_without_handoff_blocked(self):
report = "Merger deleted remote branch and removed worktree after merge"
result = assess_merger_cleanup_handoff_guidance(report)
self.assertTrue(result["block"])
def test_merger_cleanup_with_reconciler_handoff_passes(self):
report = (
"Merger deleted remote branch; next actor: reconciler for worktree audit"
)
result = assess_merger_cleanup_handoff_guidance(report)
self.assertFalse(result["block"])
class TestCompositeVerifier(unittest.TestCase):
def test_full_authorized_report_passes(self):
result = assess_mcp_native_cleanup_proof(_authorized_cleanup_report())
self.assertFalse(result["block"], result["reasons"])
def test_raw_bypasses_fail_composite(self):
report = "\n".join([
"## Controller Handoff",
"- Merge mutations: gitea_merge_pr",
"- Cleanup mutations: git branch -D feat/x; curl -X DELETE .../comments/1",
])
result = assess_mcp_native_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertGreaterEqual(len(result["reasons"]), 2)
def test_narrative_cleanup_mentions_pass_when_ledgers_none(self):
report = "\n".join([
"## Review summary",
"Validated workflow §28B MCP-native cleanup guidance.",
"Files reviewed describe raw git branch -d and delete_issue_comment blocks.",
"",
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: none",
"- Worktree mutations: none",
])
result = assess_mcp_native_cleanup_proof(report)
self.assertFalse(result["block"], result["reasons"])
def test_git_ref_cleanup_bypass_blocked_in_composite(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: git branch -D feat/issue-517-test",
"- Reconciler capability: prgs-reconciler",
])
result = assess_mcp_native_cleanup_proof(report)
self.assertTrue(result["block"])
self.assertTrue(
any("Git ref mutations cleanup must name" in r for r in result["reasons"])
)
class TestFinalReportValidatorIntegration(unittest.TestCase):
def test_validator_blocks_raw_branch_delete_in_review_report(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: git branch -d feat/issue-517-test",
])
result = assess_final_report_validator(report, "review_pr")
rules = {f["rule_id"] for f in result["findings"]}
self.assertIn("shared.mcp_native_cleanup_proof", rules)
def test_validator_passes_authorized_cleanup_report(self):
result = assess_final_report_validator(_authorized_cleanup_report(), "review_pr")
blocked = [f for f in result["findings"] if f.get("severity") == "block"]
mcp_blocks = [
f for f in blocked if f.get("rule_id") == "shared.mcp_native_cleanup_proof"
]
self.assertEqual(mcp_blocks, [])
def test_validator_passes_narrative_cleanup_mentions_with_none_ledgers(self):
report = "\n".join([
"## Review summary",
"Workflow §28B documents raw git push --delete and delete_issue_comment blocks.",
"",
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: none",
"- Worktree mutations: none",
])
result = assess_final_report_validator(report, "review_pr")
mcp_blocks = [
f for f in result["findings"]
if f.get("rule_id") == "shared.mcp_native_cleanup_proof"
and f.get("severity") == "block"
]
self.assertEqual(mcp_blocks, [])
def test_validator_blocks_git_ref_cleanup_bypass(self):
report = "\n".join([
"## Controller Handoff",
"- Cleanup mutations: none",
"- Git ref mutations: git branch -D feat/issue-517-test",
"- Reconciler capability: prgs-reconciler",
])
result = assess_final_report_validator(report, "reconcile_already_landed")
rules = {f["rule_id"] for f in result["findings"] if f.get("severity") == "block"}
self.assertIn("shared.mcp_native_cleanup_proof", rules)
if __name__ == "__main__":
unittest.main()
+172 -3
View File
@@ -127,6 +127,7 @@ def _install_owned_reviewer_lease(
session_id=_DEFAULT_LEASE_SESSION,
head_sha="abc123",
):
import merger_lease_adoption as mla
import reviewer_pr_lease
reviewer_pr_lease.clear_session_lease()
@@ -135,7 +136,11 @@ def _install_owned_reviewer_lease(
"session_id": session_id,
"candidate_head": head_sha,
"target_branch": "master",
})
"comment_id": 9001,
}, lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE,
comment_id=9001,
))
return patch(
"mcp_server._fetch_pr_comments",
return_value=[
@@ -1069,8 +1074,8 @@ class TestMergePR(unittest.TestCase):
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
with patch.dict(os.environ, env, clear=True):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8),
expected_head_sha=new_sha, remote="prgs")
pr_number=8, confirmation=self._confirm(8), remote="prgs",
expected_head_sha=new_sha)
self.assertFalse(r["performed"])
self.assertTrue(r.get("approval_visible"))
self.assertFalse(r.get("approval_at_current_head"))
@@ -3972,3 +3977,167 @@ class TestPreflightVerification(unittest.TestCase):
self.assertIn("worktree_path argument", msg)
self.assertIn("task_file.py", msg)
self.assertIn("author namespace", msg)
class TestIssue546Deadlock(unittest.TestCase):
def setUp(self):
import reviewer_pr_lease
import review_workflow_load
import mcp_server
reviewer_pr_lease.clear_session_lease()
review_workflow_load.clear_review_workflow_load()
mcp_server._preflight_capability_called = False
mcp_server._preflight_resolved_role = None
mcp_server._REVIEW_DECISION_LOCK = None
self.auth_user_patch = patch("mcp_server._authenticated_username", return_value="reviewer-bot")
self.auth_user_patch.start()
self.addCleanup(self.auth_user_patch.stop)
self.verify_purity_patch = patch("mcp_server.verify_preflight_purity", return_value=None)
self.verify_purity_patch.start()
self.addCleanup(self.verify_purity_patch.stop)
self.verify_workspace_patch = patch("mcp_server._verify_role_mutation_workspace", return_value="/workspace")
self.verify_workspace_patch.start()
self.addCleanup(self.verify_workspace_patch.stop)
self.get_profile_patch = patch("mcp_server.get_profile", return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.review", "gitea.pr.approve"],
"forbidden_operations": [],
})
self.get_profile_patch.start()
self.addCleanup(self.get_profile_patch.stop)
self.pr_work_lease_patch = patch(
"mcp_server._pr_work_lease_reviewer_block",
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
)
self.pr_work_lease_patch.start()
self.addCleanup(self.pr_work_lease_patch.stop)
self.auth_header_patch = patch("mcp_server.get_auth_header", return_value="token test")
self.auth_header_patch.start()
self.addCleanup(self.auth_header_patch.stop)
def test_workflow_no_deadlock(self):
import mcp_server
import reviewer_pr_lease
# 1. Resolve capability
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
self.assertTrue(res["allowed_in_current_session"])
# 2. Load review workflow
res = mcp_server.gitea_load_review_workflow()
self.assertTrue(res["success"])
# Mock API requests for lease comments and PR retrieval
comments = []
def mock_api_side(method, url, auth, data=None):
if "/api/v1/user" in url:
return {"login": "reviewer-bot"}
if "/pulls/" in url:
if "/reviews" in url:
if method == "POST":
return {"id": 2001, "state": "APPROVED"}
return [{"id": 2001, "user": {"login": "reviewer-bot"}, "state": "APPROVED", "commit_id": "abc123"}]
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
if "/comments" in url:
if method == "POST":
comments.append({"id": 1001, "body": data["body"], "user": {"login": "reviewer-bot"}})
return {"id": 1001}
return comments
return {}
with patch("mcp_server.api_request", side_effect=mock_api_side):
# 3. Acquire reviewer lease
res = mcp_server.gitea_acquire_reviewer_pr_lease(
pr_number=550,
worktree="/workspace",
candidate_head="abc123",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["comment_id"], 1001)
# verify lease is recorded in session
session_lease = reviewer_pr_lease.get_session_lease()
self.assertIsNotNone(session_lease)
self.assertEqual(session_lease["pr_number"], 550)
# 4. Resolve capability again (simulating subsequent tool call preflight check/token refresh)
# This should NOT clear the session lease or decision lock!
mcp_server._preflight_capability_called = False # simulate clearance from prior mutation
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
self.assertTrue(res["allowed_in_current_session"])
self.assertIsNotNone(reviewer_pr_lease.get_session_lease()) # Preserved!
# 5. Mark final review decision APPROVED
res = mcp_server.gitea_mark_final_review_decision(
pr_number=550,
action="approve",
expected_head_sha="abc123",
remote="prgs",
)
self.assertTrue(res["marked_ready"])
# 6. Submit review (should succeed without deadlock)
res = mcp_server.gitea_submit_pr_review(
pr_number=550,
action="approve",
body="APPROVED",
expected_head_sha="abc123",
final_review_decision_ready=True,
remote="prgs",
worktree_path="/workspace",
)
self.assertTrue(res["performed"])
def test_release_reviewer_pr_lease(self):
import mcp_server
import reviewer_pr_lease
# Resolve capability and load workflow
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
mcp_server.gitea_load_review_workflow()
comments = [_reviewer_lease_comment(550, session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123")]
posted_comments = []
def mock_api_side(method, url, auth, data=None):
if "/api/v1/user" in url:
return {"login": "reviewer-bot"}
if "/pulls/" in url:
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
if "/comments" in url:
if method == "POST":
new_c = {"id": 1002, "body": data["body"], "user": {"login": "reviewer-bot"}}
comments.append(new_c)
posted_comments.append(new_c)
return {"id": 1002}
return comments
return {}
# Set session lease in memory
import merger_lease_adoption as mla
reviewer_pr_lease.record_session_lease({
"pr_number": 550,
"session_id": _DEFAULT_LEASE_SESSION,
"candidate_head": "abc123",
"target_branch": "master",
"comment_id": 9001,
}, lease_provenance=mla.build_lease_provenance(source=mla.SOURCE_ACQUIRE, comment_id=9001))
with patch("mcp_server.api_request", side_effect=mock_api_side):
# Release lease
res = mcp_server.gitea_release_reviewer_pr_lease(pr_number=550, remote="prgs")
self.assertTrue(res["success"])
self.assertTrue(res["released"])
self.assertEqual(res["comment_id"], 1002)
# verify lease is cleared in session
self.assertIsNone(reviewer_pr_lease.get_session_lease())
# verify comment phase is released
self.assertIn("phase: released", posted_comments[0]["body"])
+431
View File
@@ -0,0 +1,431 @@
"""Merger lease adoption / recovery (#536)."""
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 merger_lease_adoption as mla
import reviewer_pr_lease as leases
def _lease_comment(
pr_number: int,
session_id: str,
*,
phase: str = "claimed",
profile: str = "prgs-reviewer",
identity: str = "sysadmin",
candidate_head: str = "a" * 40,
comment_id: int = 100,
) -> dict:
body = leases.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=pr_number,
issue_number=536,
reviewer_identity=identity,
profile=profile,
session_id=session_id,
worktree="branches/review-pr536",
phase=phase,
candidate_head=candidate_head,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=datetime.now(timezone.utc),
)
return {"id": comment_id, "body": body, "user": {"login": identity}}
HEAD = "f" * 40
class TestSanctionedProvenance(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
def test_manual_session_seed_rejected_by_mutation_gate(self):
comments = [_lease_comment(536, "reviewer-session", candidate_head=HEAD)]
leases.record_session_lease({
"pr_number": 536,
"session_id": "merger-session",
"candidate_head": HEAD,
"comment_id": 999,
})
result = leases.assess_mutation_lease_gate(
pr_number=536,
comments=comments,
reviewer_identity="sysadmin",
session_id="merger-session",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertTrue(result["block"])
self.assertTrue(
any("sanctioned provenance" in r for r in result["reasons"])
)
def test_sanctioned_acquire_provenance_allows_mutation(self):
comments = [_lease_comment(536, "my-session", candidate_head=HEAD)]
leases.record_session_lease(
{
"pr_number": 536,
"session_id": "my-session",
"candidate_head": HEAD,
"comment_id": 101,
},
lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE,
comment_id=101,
),
)
result = leases.assess_mutation_lease_gate(
pr_number=536,
comments=comments,
reviewer_identity="sysadmin",
session_id="my-session",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertFalse(result["block"])
class TestAdoptAssessment(unittest.TestCase):
def test_adopt_allowed_for_merger_with_reviewer_lease_and_approval(self):
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
result = mla.assess_adopt_merger_lease(
comments,
pr_number=536,
adopter_identity="sysadmin",
adopter_profile="prgs-merger",
adopter_session_id="merger-sess",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=536,
worktree="branches/merge-pr536",
expected_head_sha=HEAD,
live_head_sha=HEAD,
approval_at_head=True,
pr_open=True,
)
self.assertTrue(result["adopt_allowed"])
self.assertIn(mla.ADOPTION_MARKER, result["adoption_body"])
self.assertIn("adopted_from_session_id: reviewer-sess", result["adoption_body"])
def test_adopt_blocked_without_approval(self):
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
result = mla.assess_adopt_merger_lease(
comments,
pr_number=536,
adopter_identity="sysadmin",
adopter_profile="prgs-merger",
adopter_session_id="merger-sess",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=536,
worktree="branches/merge-pr536",
expected_head_sha=HEAD,
live_head_sha=HEAD,
approval_at_head=False,
pr_open=True,
)
self.assertFalse(result["adopt_allowed"])
self.assertTrue(any("APPROVED" in r for r in result["reasons"]))
def test_adopt_blocked_for_non_merger_profile(self):
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
result = mla.assess_adopt_merger_lease(
comments,
pr_number=536,
adopter_identity="sysadmin",
adopter_profile="prgs-reviewer",
adopter_session_id="merger-sess",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=536,
worktree="branches/review-pr536",
expected_head_sha=HEAD,
live_head_sha=HEAD,
approval_at_head=True,
pr_open=True,
)
self.assertFalse(result["adopt_allowed"])
self.assertTrue(any("merger profile" in r for r in result["reasons"]))
def test_adopt_blocked_when_pr_not_open(self):
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
result = mla.assess_adopt_merger_lease(
comments,
pr_number=536,
adopter_identity="sysadmin",
adopter_profile="prgs-merger",
adopter_session_id="merger-sess",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=536,
worktree="branches/merge-pr536",
expected_head_sha=HEAD,
live_head_sha=HEAD,
approval_at_head=True,
pr_open=False,
)
self.assertFalse(result["adopt_allowed"])
class TestMergerHandoffMutationGate(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
def test_cross_session_merge_blocked_without_adoption_comment(self):
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
provenance = mla.build_lease_provenance(
source=mla.SOURCE_ADOPT,
comment_id=7001,
adopted_from_session_id="reviewer-sess",
)
leases.record_session_lease(
{
"pr_number": 536,
"session_id": "merger-sess",
"candidate_head": HEAD,
"comment_id": 7001,
"phase": "adopted",
},
lease_provenance=provenance,
)
result = leases.assess_mutation_lease_gate(
pr_number=536,
comments=[reviewer_comment],
reviewer_identity="sysadmin",
session_id="merger-sess",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertTrue(result["block"])
self.assertTrue(any("owned by session_id=reviewer-sess" in r for r in result["reasons"]))
def test_cross_session_merge_allowed_after_adoption_comment(self):
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
adoption_body = mla.format_adoption_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=536,
issue_number=536,
adopter_identity="sysadmin",
adopter_profile="prgs-merger",
adopter_session_id="merger-sess",
worktree="branches/merge-pr536",
candidate_head=HEAD,
target_branch="master",
target_branch_sha="b" * 40,
adopted_from_session_id="reviewer-sess",
adopted_from_profile="prgs-reviewer",
adopted_from_reviewer_identity="sysadmin",
adopted_from_comment_id=100,
)
adoption_comment = {
"id": 7001,
"body": adoption_body,
"user": {"login": "sysadmin"},
}
provenance = mla.build_lease_provenance(
source=mla.SOURCE_ADOPT,
comment_id=7001,
adopted_from_session_id="reviewer-sess",
)
leases.record_session_lease(
{
"pr_number": 536,
"session_id": "merger-sess",
"candidate_head": HEAD,
"comment_id": 7001,
"phase": "adopted",
},
lease_provenance=provenance,
)
result = leases.assess_mutation_lease_gate(
pr_number=536,
comments=[reviewer_comment, adoption_comment],
reviewer_identity="sysadmin",
session_id="merger-sess",
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertFalse(result["block"])
class TestAdoptTool(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
# Patch only the role workspace verifier (which internally does the single
# verify_preflight_purity); the direct duplicate call was removed to prevent
# capability state clearing. See fix for #548.
patch("mcp_server._verify_role_mutation_workspace", return_value=None).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", "adopt_merger_pr_lease")
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", "reviewer", resolved_task="adopt_merger_pr_lease"
)
def tearDown(self):
patch.stopall()
leases.clear_session_lease()
@patch("mcp_server._authenticated_username", return_value="sysadmin")
@patch("mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0")
@patch("mcp_server.get_profile")
@patch("mcp_server.api_request")
def test_adopt_tool_posts_proof_and_records_sanctioned_session(
self, mock_api, mock_profile, _mock_auth, _mock_user
):
mock_profile.return_value = {
"profile_name": "prgs-merger",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.pr.merge",
],
"forbidden_operations": [],
}
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
posted_bodies: list[str] = []
def _side(method, url, auth=None, payload=None, *a, **k):
m = (method or "").upper()
if m == "POST":
if payload and payload.get("body"):
posted_bodies.append(payload["body"])
return {"id": 7001}
if "/pulls/" in url and "/files" not in url:
return {
"state": "open",
"number": 536,
"head": {"sha": HEAD},
"merged": False,
}
if "/reviews" in url:
return []
if "/comments" in url:
return [reviewer_comment]
return {}
mock_api.side_effect = _side
from mcp_server import gitea_adopt_merger_pr_lease
with patch.object(
__import__("mcp_server"),
"gitea_get_pr_review_feedback",
return_value={
"approval_at_current_head": True,
"latest_approved_head_sha": HEAD,
},
):
with patch.dict(
os.environ,
{
"GITEA_PROFILE_NAME": "prgs-merger",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.merge",
},
clear=True,
):
result = gitea_adopt_merger_pr_lease(
pr_number=536,
worktree="branches/merge-pr536",
expected_head_sha=HEAD,
issue_number=536,
remote="prgs",
)
self.assertTrue(result["success"])
self.assertTrue(result["adopted"])
self.assertEqual(result["adopted_from_session_id"], "reviewer-sess")
self.assertEqual(result["adoption_comment_id"], 7001)
self.assertEqual(len(posted_bodies), 1)
self.assertIn(mla.ADOPTION_MARKER, posted_bodies[0])
session = leases.get_session_lease()
self.assertTrue(mla.is_sanctioned_session_lease(session))
adoption_comment = {
"id": 7001,
"body": posted_bodies[0],
"user": {"login": "sysadmin"},
}
gate = leases.assess_mutation_lease_gate(
pr_number=536,
comments=[reviewer_comment, adoption_comment],
reviewer_identity="sysadmin",
session_id=result["session_id"],
mutation="merge",
live_head_sha=HEAD,
pinned_head_sha=HEAD,
)
self.assertFalse(gate["block"])
def test_adopt_preserves_preflight_capability_state_no_duplicate_clear(self):
"""Prove fix for #548: adopt calls _verify (single purity) and does not
trigger duplicate verify that would clear capability state, avoiding
need for temp local patches or raw fallbacks.
"""
from unittest.mock import patch, MagicMock
import mcp_server as mcp_server_mod
# fresh preflight state (as controller/reconciler would have from resolve)
mcp_server_mod.record_preflight_check("whoami")
mcp_server_mod.record_preflight_check(
"capability", "merger", resolved_task="adopt_merger_pr_lease"
)
initial_cap_called = mcp_server_mod._preflight_capability_called
verify_patch = patch("mcp_server.verify_preflight_purity", wraps=mcp_server_mod.verify_preflight_purity)
def _role_side(*a, **k):
# simulate the real _verify which calls verify once with path
mcp_server_mod.verify_preflight_purity(k.get("remote") or a[0] if a else None, worktree_path=k.get("worktree") or "branches/work", task=k.get("task"))
return "branches/work"
role_patch = patch("mcp_server._verify_role_mutation_workspace", side_effect=_role_side)
with verify_patch as vmock, role_patch, \
patch("mcp_server.get_auth_header", return_value="token test"), \
patch("mcp_server._authenticated_username", return_value="sysadmin"):
# minimal mocks to let adopt reach end without full api
with patch("mcp_server.get_profile") as gp:
gp.return_value = {
"profile_name": "prgs-merger",
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"],
"forbidden_operations": [],
}
with patch("mcp_server.api_request") as api:
def _side(url, *a, **k):
if "/pulls/" in str(url) and "/comments" not in str(url):
return {"state": "open", "head": {"sha": "deadbeef"*5}, "merged": False, "number": 999}
if "/comments" in str(url):
return [] # list of comments for fetch
return {"id": 42}
api.side_effect = _side
with patch("mcp_server.gitea_get_pr_review_feedback", return_value={"approval_at_current_head": True}):
with patch("merger_lease_adoption.assess_adopt_merger_lease", return_value={
"adopt_allowed": True,
"active_lease": {"session_id": "rev", "profile": "prgs-reviewer", "reviewer_identity": "sysadmin", "issue_number": 548, "target_branch": "master"},
"adoption_body": "<!-- mcp-review-lease-adoption:v1 --> adopted",
}):
res = mcp_server_mod.gitea_adopt_merger_pr_lease(
pr_number=999,
worktree="branches/merge-test",
expected_head_sha="deadbeef"*5,
issue_number=548,
remote="prgs",
)
self.assertTrue(res.get("success"))
# verify_preflight_purity should have been invoked exactly once (via the _verify_role path)
# not twice (old dup would have cleared state mid-way, requiring temp patches)
self.assertEqual(vmock.call_count, 1)
# capability state management: the single verify consumes (clears) as designed;
# prior state was valid, no unexpected clear between "checks" or hidden reset
# (the removed dup line was the source of the #548 clearing bug)
self.assertTrue(initial_cap_called)
# final may be set by other records in flow, but key is single invocation and success without bypasses
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -95,8 +95,12 @@ class PermissionReportBase(unittest.TestCase):
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
self._write_config(CONFIG)
import review_workflow_load
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
def tearDown(self):
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server.review_workflow_load.clear_review_workflow_load()
@@ -26,6 +26,11 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
# Single comments GET only — no pre-flight PR lookup (#519 isolation).
mock_api.assert_called_once()
_method, url, _auth_arg = mock_api.call_args[0][:3]
self.assertIn("/issues/12/comments", url)
self.assertNotIn("/pulls/", url)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -73,4 +78,4 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
unittest.main()
+178
View File
@@ -0,0 +1,178 @@
"""Regression coverage for the remote/repo mismatch guard (#530).
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
(the hardcoded ``REMOTES`` default) instead of ``Scaled-Tech-Consulting/Gitea-Tools``,
which is what the local git remote actually points at. That silent mismatch caused
false 404s and risked mutating the wrong repository. The guard fails closed when the
MCP-resolved org/repo disagrees with the local git remote URL and the caller did not
pass explicit ``org``/``repo``.
"""
import os
import unittest
from unittest import mock
import remote_repo_guard
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
class TestAssessRemoteRepoMatch(unittest.TestCase):
def test_explicit_org_and_repo_skips_guard(self):
# Caller supplied both org and repo explicitly: never block, even on mismatch.
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=True,
)
self.assertTrue(assessment["proven"])
self.assertFalse(assessment["block"])
self.assertEqual(assessment["reasons"], [])
def test_missing_local_remote_url_skips_guard(self):
# Best-effort lookup: no local remote URL (non-checkout usage) => do not block.
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=None,
org_explicit=False,
repo_explicit=False,
)
self.assertTrue(assessment["proven"])
self.assertFalse(assessment["block"])
def test_matching_repo_is_proven(self):
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Gitea-Tools",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=False,
repo_explicit=False,
)
self.assertTrue(assessment["proven"])
self.assertFalse(assessment["block"])
def test_regression_prgs_default_timesheet_vs_local_gitea_tools(self):
# The exact #530 scenario: default repo Timesheet, local remote Gitea-Tools.
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=False,
repo_explicit=False,
)
self.assertFalse(assessment["proven"])
self.assertTrue(assessment["block"])
self.assertTrue(assessment["reasons"])
self.assertEqual(assessment["resolved_repo"], "Timesheet")
self.assertEqual(assessment["resolved_org"], "Scaled-Tech-Consulting")
def test_only_org_explicit_still_checks_repo(self):
# Repo left as default => still guarded even if org was explicit.
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=False,
)
self.assertTrue(assessment["block"])
def test_case_insensitive_match(self):
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="scaled-tech-consulting",
resolved_repo="gitea-tools",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=False,
repo_explicit=False,
)
self.assertTrue(assessment["proven"])
def test_format_error_mentions_resolved_and_local(self):
assessment = remote_repo_guard.assess_remote_repo_match(
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=False,
repo_explicit=False,
)
message = remote_repo_guard.format_remote_repo_guard_error(assessment)
self.assertIn("Timesheet", message)
self.assertIn("Gitea-Tools", message)
self.assertIn("org=", message)
self.assertIn("repo=", message)
class TestResolveServerWiring(unittest.TestCase):
"""The guard is wired into gitea_mcp_server._resolve, so every read/lookup/
mutation tool that resolves a target fails closed on a repo mismatch.
Under pytest the guard is bypassed unless GITEA_FORCE_REMOTE_REPO_CHECK is set,
so these tests set the force flag and patch the local remote lookup + REMOTES.
"""
def setUp(self):
import gitea_mcp_server
self.server = gitea_mcp_server
force = mock.patch.dict(
os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"}, clear=False
)
force.start()
self.addCleanup(force.stop)
url_patch = mock.patch.object(
gitea_mcp_server,
"_local_git_remote_url",
return_value=LOCAL_GITEA_TOOLS_URL,
)
url_patch.start()
self.addCleanup(url_patch.stop)
def _set_prgs_default_repo(self, repo):
original = dict(self.server.REMOTES["prgs"])
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
self.server.REMOTES["prgs"] = {**original, "repo": repo}
def test_resolve_blocks_on_default_repo_mismatch(self):
self._set_prgs_default_repo("Timesheet")
with self.assertRaises(RuntimeError) as ctx:
self.server._resolve("prgs", None, None, None)
self.assertIn("Timesheet", str(ctx.exception))
self.assertIn("Gitea-Tools", str(ctx.exception))
def test_resolve_allows_explicit_org_and_repo(self):
self._set_prgs_default_repo("Timesheet")
# Explicit org/repo is authoritative even if it does not match local remote.
host, org, repo = self.server._resolve(
"prgs", None, "Scaled-Tech-Consulting", "Timesheet"
)
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Timesheet"))
def test_resolve_allows_matching_default(self):
self._set_prgs_default_repo("Gitea-Tools")
host, org, repo = self.server._resolve("prgs", None, None, None)
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Gitea-Tools"))
def test_lookup_tool_cannot_silently_query_different_repo(self):
# gitea_view_issue resolves the target via _resolve first, so a bare
# remote=prgs pointing at the wrong default repo fails closed before any
# API call is made.
self._set_prgs_default_repo("Timesheet")
with self.assertRaises(RuntimeError):
self.server.gitea_view_issue(issue_number=1, remote="prgs")
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
"""Tests for workflow-load session boundary tracking (#403)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import final_report_validator
import review_workflow_boundary
import review_workflow_load
import mcp_server
class TestPreReviewClassification(unittest.TestCase):
def setUp(self):
review_workflow_boundary.clear_pre_review_commands()
review_workflow_load.clear_review_workflow_load()
def test_inventory_command_allowed(self):
result = review_workflow_boundary.classify_pre_review_command(
"gitea_list_prs remote=prgs",
cwd="/tmp",
project_root="/repo/Gitea-Tools",
)
self.assertEqual(
result["classification"],
review_workflow_boundary.CLASSIFICATION_READ_ONLY_INVENTORY,
)
def test_main_checkout_pytest_is_boundary_violation(self):
root = "/repo/Gitea-Tools"
result = review_workflow_boundary.classify_pre_review_command(
"python -m pytest tests/",
cwd=root,
project_root=root,
)
self.assertEqual(
result["classification"],
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
)
def test_profiles_json_inspection_is_boundary_violation(self):
result = review_workflow_boundary.classify_pre_review_command(
"cat profiles.json",
cwd="/repo/Gitea-Tools",
project_root="/repo/Gitea-Tools",
)
self.assertEqual(
result["classification"],
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
)
class TestWorkflowLoadBoundaryGate(unittest.TestCase):
def setUp(self):
review_workflow_boundary.clear_pre_review_commands()
review_workflow_load.clear_review_workflow_load()
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", "reviewer")
def _root(self) -> str:
return str(__import__("pathlib").Path(__file__).resolve().parent.parent)
def test_boundary_violation_blocks_mutation_after_load(self):
root = self._root()
review_workflow_boundary.record_pre_review_command(
"python -m pytest tests/",
cwd=root,
project_root=root,
)
res = mcp_server.gitea_load_review_workflow()
self.assertFalse(res["success"])
self.assertEqual(res["boundary_status"], "violation")
blocked = mcp_server.gitea_mark_final_review_decision(
42, "approve", remote="prgs")
self.assertFalse(blocked["marked_ready"])
joined = " ".join(blocked["reasons"]).lower()
self.assertTrue(
"validation" in joined or "boundary" in joined or "workflow" in joined
)
def test_clean_inventory_then_load_passes(self):
root = self._root()
review_workflow_boundary.record_pre_review_command(
"gitea_list_prs remote=prgs",
cwd=root,
project_root=root,
)
res = mcp_server.gitea_load_review_workflow()
self.assertTrue(res["success"])
self.assertEqual(res["boundary_status"], "clean")
blockers = review_workflow_load.review_workflow_load_blockers(root)
self.assertEqual(blockers, [])
def test_file_view_narrative_fails_validator_without_helper(self):
report = (
"## Controller Handoff\n"
"- Task: review-merge-pr\n"
"- I read the canonical workflow review-merge-pr.md before review.\n"
)
findings = final_report_validator.assess_final_report_validator(
report,
task_kind="review_pr",
)
self.assertTrue(any(
f["rule_id"] == "reviewer.workflow_load_boundary"
for f in findings.get("findings") or []
))
def test_helper_result_passes_validator(self):
root = self._root()
review_workflow_load.record_review_workflow_load(root)
helper = review_workflow_boundary.workflow_load_helper_result(
review_workflow_load._REVIEW_WORKFLOW_LOAD,
root,
)
report = (
"## Controller Handoff\n"
"- Task: review-merge-pr\n"
f"- Workflow-load helper result: workflow_hash: {helper['workflow_hash']}; "
f"boundary_status: {helper['boundary_status']}\n"
)
findings = final_report_validator.assess_final_report_validator(
report,
task_kind="review_pr",
)
boundary_findings = [
f for f in (findings.get("findings") or [])
if f.get("rule_id") == "reviewer.workflow_load_boundary"
]
self.assertEqual(boundary_findings, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,141 @@
"""Tests for exact per-mutation capability proof in reviewer reports (#405)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
from review_proofs import assess_mutation_capability_proof as proofs_assess
from final_report_validator import assess_final_report_validator
REVIEW_ONLY = """
Review decision: approved
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | resolved before review
"""
MERGE_PROVEN = """
Review decision: approved
Merge result: merged 0123456789ab
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | resolved before merge
"""
MERGE_AND_DELETE_PROVEN = """
Review decision: approved
Merge result: merged 0123456789ab
Remote branch deleted: feat/issue-x
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
- gitea_delete_branch | delete_branch (gitea.branch.delete) | deleted | before delete
"""
class TestModule(unittest.TestCase):
def test_review_only_with_capability_passes(self):
r = assess_mutation_capability_proof(REVIEW_ONLY)
self.assertTrue(r["proven"], r["reasons"])
def test_merge_with_exact_capability_passes(self):
r = assess_mutation_capability_proof(MERGE_PROVEN)
self.assertTrue(r["proven"], r["reasons"])
def test_merge_and_delete_fully_proven_passes(self):
r = assess_mutation_capability_proof(MERGE_AND_DELETE_PROVEN)
self.assertTrue(r["proven"], r["reasons"])
def test_review_pr_does_not_authorize_merge(self):
report = """
Review decision: approved
Merge result: merged 0123456789ab
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
"""
r = assess_mutation_capability_proof(report)
self.assertFalse(r["proven"])
self.assertTrue(any("merge" in x.lower() for x in r["reasons"]), r["reasons"])
def test_merge_pr_does_not_authorize_branch_deletion(self):
report = """
Review decision: approved
Merge result: merged 0123456789ab
Remote branch deleted: feat/issue-x
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
"""
r = assess_mutation_capability_proof(report)
self.assertFalse(r["proven"])
self.assertTrue(any("delet" in x.lower() for x in r["reasons"]), r["reasons"])
def test_delete_skipped_when_capability_missing_passes(self):
report = """
Review decision: approved
Merge result: merged 0123456789ab
Branch deletion: skipped delete_branch capability not available
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
"""
r = assess_mutation_capability_proof(report)
self.assertTrue(r["proven"], r["reasons"])
def test_missing_table_when_merging_blocks(self):
report = """
Review decision: approved
Merge result: merged 0123456789ab
merge_pr gitea.pr.merge resolved
"""
r = assess_mutation_capability_proof(report)
self.assertFalse(r["proven"])
self.assertTrue(any("table" in x.lower() for x in r["reasons"]), r["reasons"])
def test_post_hoc_proof_blocks(self):
report = """
Review decision: approved
Merge result: merged 0123456789ab
Mutation capability table:
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | capability resolved after merge
"""
r = assess_mutation_capability_proof(report)
self.assertFalse(r["proven"])
self.assertTrue(any("after" in x.lower() for x in r["reasons"]), r["reasons"])
def test_review_without_capability_blocks(self):
report = "Review decision: approved\nreview submitted\n"
r = assess_mutation_capability_proof(report)
self.assertFalse(r["proven"])
def test_no_mutation_no_requirement(self):
r = assess_mutation_capability_proof("Selected PR: #1\nSkipped, no action.")
self.assertTrue(r["proven"], r["reasons"])
class TestWiring(unittest.TestCase):
def test_review_proofs_wrapper_matches_module(self):
self.assertEqual(
proofs_assess(MERGE_PROVEN)["proven"],
assess_mutation_capability_proof(MERGE_PROVEN)["proven"],
)
def test_final_report_validator_flags_nearby_capability_merge(self):
report = """
## Controller Handoff
- Task: review_pr
Review decision: approved
Merge result: merged 0123456789ab
Mutation capability table:
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
"""
result = assess_final_report_validator(report, "review_pr")
rule_ids = [f["rule_id"] for f in result.get("findings", [])]
self.assertIn("reviewer.mutation_capability_proof", rule_ids)
if __name__ == "__main__":
unittest.main()
+11 -2
View File
@@ -7,6 +7,7 @@ from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import merger_lease_adoption as mla
import reviewer_pr_lease as leases
@@ -123,7 +124,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
"session_id": "my-session",
"candidate_head": head,
"target_branch": "master",
})
"comment_id": 101,
}, lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE,
comment_id=101,
))
result = leases.assess_mutation_lease_gate(
pr_number=382,
comments=comments,
@@ -143,7 +148,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
"pr_number": 382,
"session_id": "my-session",
"candidate_head": reviewed,
})
"comment_id": 102,
}, lease_provenance=mla.build_lease_provenance(
source=mla.SOURCE_ACQUIRE,
comment_id=102,
))
result = leases.assess_mutation_lease_gate(
pr_number=382,
comments=comments,
+161
View File
@@ -0,0 +1,161 @@
"""Tests for root checkout guard (#475)."""
from __future__ import annotations
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 # noqa: E402
import root_checkout_guard as rcg # noqa: E402
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
MASTER_SHA = "a" * 40
OTHER_SHA = "b" * 40
class TestAssessRootCheckoutGuard(unittest.TestCase):
def _assess(self, **kwargs):
defaults = {
"workspace_path": CONTROL_ROOT,
"canonical_repo_root": CONTROL_ROOT,
"current_branch": "master",
"head_sha": MASTER_SHA,
"porcelain_status": "",
"remote_master_sha": MASTER_SHA,
"resolved_role": "author",
}
defaults.update(kwargs)
return rcg.assess_root_checkout_guard(**defaults)
def test_clean_master_control_checkout_allowed(self):
result = self._assess()
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_branches_worktree_allowed_for_author(self):
result = self._assess(
workspace_path=BRANCHES_WORKTREE,
current_branch="feat/issue-475-root-checkout-guard",
head_sha=OTHER_SHA,
resolved_role="author",
)
self.assertTrue(result["proven"])
def test_branches_worktree_allowed_for_reviewer(self):
result = self._assess(
workspace_path=f"{CONTROL_ROOT}/branches/review-pr-1",
current_branch="review-pr-1",
resolved_role="reviewer",
)
self.assertTrue(result["proven"])
def test_reconciler_always_allowed(self):
result = self._assess(
current_branch="feat/some-branch",
head_sha=OTHER_SHA,
porcelain_status=" M gitea_mcp_server.py\n",
resolved_role="reconciler",
)
self.assertTrue(result["proven"])
def test_feature_branch_on_control_checkout_blocked(self):
result = self._assess(
current_branch="feat/issue-99-example",
head_sha=OTHER_SHA,
)
self.assertTrue(result["block"])
self.assertIn("not a stable base branch", result["reasons"][0])
def test_detached_head_blocked(self):
result = self._assess(current_branch=None)
self.assertTrue(result["block"])
self.assertIn("detached HEAD", result["reasons"][0])
def test_dirty_control_checkout_blocked(self):
result = self._assess(porcelain_status=" M gitea_mcp_server.py\n")
self.assertTrue(result["block"])
self.assertIn("tracked local edits", result["reasons"][0])
def test_head_behind_prgs_master_blocked(self):
result = self._assess(
head_sha=OTHER_SHA,
remote_master_sha=MASTER_SHA,
)
self.assertTrue(result["block"])
self.assertIn("does not match prgs/master", result["reasons"][0])
def test_merger_requires_clean_control_checkout(self):
result = self._assess(
workspace_path=BRANCHES_WORKTREE,
current_branch="feat/issue-475-root-checkout-guard",
head_sha=OTHER_SHA,
resolved_role="merger",
)
self.assertTrue(result["block"])
class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "reviewer"
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._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
@patch("gitea_mcp_server._resolve_author_mutation_context")
def test_reviewer_from_contaminated_root_blocked(
self, mock_ctx, mock_git, _remote_sha, _porcelain,
):
srv._preflight_capability_baseline_porcelain = ""
mock_ctx.return_value = {
"workspace_path": CONTROL_ROOT,
"canonical_repo_root": CONTROL_ROOT,
"process_project_root": CONTROL_ROOT,
}
mock_git.return_value = {
"current_branch": "feat/hijacked-root",
"head_sha": OTHER_SHA,
"porcelain_status": "",
}
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity("prgs", worktree_path=CONTROL_ROOT)
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
@patch("gitea_mcp_server._resolve_author_mutation_context")
def test_reviewer_from_branches_worktree_allowed(
self, mock_ctx, mock_git, _remote_sha, _porcelain,
):
srv._preflight_capability_baseline_porcelain = ""
mock_ctx.return_value = {
"workspace_path": BRANCHES_WORKTREE,
"canonical_repo_root": CONTROL_ROOT,
"process_project_root": BRANCHES_WORKTREE,
}
mock_git.return_value = {
"current_branch": "feat/issue-475-root-checkout-guard",
"head_sha": OTHER_SHA,
"porcelain_status": "",
}
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Contract checks for the root-level `run-tests.sh` convenience runner (#473).
`run-tests.sh` is the canonical full-validation entry point. It must invoke the
project virtualenv interpreter, forward extra args to pytest, fail closed when
the venv Python is missing, and stay repo-local (no network, no lock files).
These checks pin that behavior so it cannot silently regress, and confirm the
developer testing guide names the runner.
"""
import stat
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "run-tests.sh"
GUIDE = REPO_ROOT / "docs" / "developer-testing-guidelines.md"
def _script_text() -> str:
return SCRIPT.read_text(encoding="utf-8")
def test_run_tests_script_exists():
assert SCRIPT.is_file(), "run-tests.sh must exist at the repository root"
def test_run_tests_script_is_executable():
mode = SCRIPT.stat().st_mode
assert mode & stat.S_IXUSR, "run-tests.sh must be executable (chmod +x)"
def test_run_tests_script_has_strict_bash_flags():
text = _script_text()
assert text.startswith("#!/usr/bin/env bash"), "must use the bash shebang"
assert "set -euo pipefail" in text, "must set -euo pipefail"
def test_run_tests_script_uses_venv_pytest_and_forwards_args():
text = _script_text()
# Resolves the venv interpreter relative to the script's own directory.
assert "venv/bin/python" in text, "must use the project virtualenv Python"
assert "-m pytest" in text, "must run pytest via the module"
assert '"$@"' in text, "must forward extra CLI args to pytest"
def test_run_tests_script_fails_closed_without_venv():
text = _script_text()
# Missing venv must be an explicit, non-zero-exit error, not a silent
# fallback to the wrong interpreter.
assert "if [[ ! -x" in text, "must guard on an executable venv Python"
assert "exit 1" in text, "must exit non-zero when the venv is missing"
assert "ERROR" in text, "must print a clear error message"
def test_run_tests_script_stays_repo_local():
text = _script_text()
# No network calls, no lock-file writes from the runner itself.
for forbidden in ("curl", "wget", "gitea_issue_lock.json"):
assert forbidden not in text, f"runner must not reference {forbidden!r}"
def test_guide_names_canonical_runner():
text = " ".join(GUIDE.read_text(encoding="utf-8").split())
assert "./run-tests.sh" in text, "testing guide must name ./run-tests.sh"
assert "./run-tests.sh tests/test_mcp_server.py -q" in text, (
"testing guide must show the focused-validation example"
)
+9
View File
@@ -36,10 +36,13 @@ def _lock(mutations=None, correction=False):
def _seed(mutations=None, correction=False):
import review_workflow_load
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
mcp_server._save_review_decision_lock(_lock(mutations, correction))
mcp_server.gitea_load_review_workflow()
APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1,
"review_state": "approve"}
RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
@@ -94,6 +97,8 @@ class TestTerminalHardStopReasons(unittest.TestCase):
class TestMergeHardStopWiring(unittest.TestCase):
def tearDown(self):
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
mcp_server._save_review_decision_lock(None)
mcp_server.review_workflow_load.clear_review_workflow_load()
@@ -116,6 +121,8 @@ class TestMergeHardStopWiring(unittest.TestCase):
class TestMarkFinalHardStopWiring(unittest.TestCase):
def tearDown(self):
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
mcp_server._save_review_decision_lock(None)
mcp_server.review_workflow_load.clear_review_workflow_load()
@@ -149,6 +156,8 @@ def _mark(action, pr_number=6, **kwargs):
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
def tearDown(self):
import review_workflow_load
review_workflow_load.clear_review_workflow_load()
mcp_server._save_review_decision_lock(None)
mcp_server.review_workflow_load.clear_review_workflow_load()
+22 -2
View File
@@ -126,17 +126,37 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
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):
@mock.patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@mock.patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "a" * 40,
"porcelain_status": "",
},
)
def test_stable_checkout_still_rejected(self, _git, _remote_sha):
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("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
@mock.patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "a" * 40,
"porcelain_status": "",
},
)
@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):
def test_non_branches_worktree_rejected(
self, mock_run, mock_exists, mock_isdir, _git, _remote_sha,
):
outside = "/tmp/outside-repo-checkout"
mock_run.return_value = MagicMock(
returncode=0,
+532
View File
@@ -0,0 +1,532 @@
"""Tests for session-owned worktree cleanup audit, TTL, and integrity (#401, #404)."""
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 worktree_cleanup_audit as wca # noqa: E402
NOW = datetime(2026, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
def _iso(dt):
return dt.isoformat().replace("+00:00", "Z")
class TestWorkflowTypeInference(unittest.TestCase):
def test_review_pr_path(self):
self.assertEqual(
wca.infer_workflow_type("branches/review-pr376", "review-pr376"),
wca.WORKFLOW_REVIEW,
)
def test_baseline_path(self):
self.assertEqual(
wca.infer_workflow_type("branches/baseline-master-issue-401"),
wca.WORKFLOW_BASELINE,
)
def test_merge_simulation_path(self):
self.assertEqual(
wca.infer_workflow_type("branches/merge-sim-pr380"),
wca.WORKFLOW_MERGE_SIMULATION,
)
def test_issue_work_branch(self):
self.assertEqual(
wca.infer_workflow_type(
"branches/issue-401-worktree", "feat/issue-401-worktree"
),
wca.WORKFLOW_ISSUE_WORK,
)
def test_conflict_fix_path(self):
self.assertEqual(
wca.infer_workflow_type("branches/conflict-fix-pr376"),
wca.WORKFLOW_CONFLICT_FIX,
)
def test_unknown_path(self):
self.assertEqual(
wca.infer_workflow_type("branches/scratchpad"), wca.WORKFLOW_UNKNOWN
)
class TestMetadata(unittest.TestCase):
def test_metadata_fields_present(self):
meta = wca.build_worktree_metadata(
path="branches/issue-401-worktree",
branch="feat/issue-401-worktree",
head_sha="abc123",
creator="jcwalker3",
profile="prgs-author",
created_at=_iso(NOW),
last_used_at=_iso(NOW),
)
for field in (
"path",
"workflow_type",
"issue_number",
"pr_number",
"branch",
"head_sha",
"creator",
"profile",
"created_at",
"last_used_at",
"cleanup_eligibility",
):
self.assertIn(field, meta)
self.assertEqual(meta["issue_number"], 401)
self.assertEqual(meta["workflow_type"], wca.WORKFLOW_ISSUE_WORK)
def test_review_metadata_auto_removable_flag(self):
meta = wca.build_worktree_metadata(path="branches/review-pr42")
self.assertTrue(meta["auto_remove_on_success"])
class TestTTL(unittest.TestCase):
def test_expired(self):
old = _iso(NOW - timedelta(hours=48))
self.assertTrue(wca.is_ttl_expired(last_used_at=old, now=NOW, ttl_hours=24))
def test_not_expired(self):
recent = _iso(NOW - timedelta(hours=1))
self.assertFalse(
wca.is_ttl_expired(last_used_at=recent, now=NOW, ttl_hours=24)
)
def test_unknown_timestamp_fails_safe(self):
self.assertFalse(wca.is_ttl_expired(last_used_at=None, now=NOW))
self.assertFalse(
wca.is_ttl_expired(last_used_at="not-a-date", now=NOW)
)
class TestClassification(unittest.TestCase):
def test_successful_review_cleanup(self):
# Scenario 1: clean review worktree -> removable.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_REVIEW, is_dirty=False
)
self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE)
self.assertTrue(wca.is_removable(cls))
def test_dirty_worktree_preserved(self):
# Scenario 3: dirty worktree is never removable.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_REVIEW, is_dirty=True
)
self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL)
self.assertFalse(wca.is_removable(cls))
def test_active_pr_worktree_preserved(self):
# Scenario 4: open PR wins over an otherwise-removable review worktree.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_REVIEW,
is_dirty=False,
has_open_pr=True,
)
self.assertEqual(cls, wca.CLASS_ACTIVE_OPEN_PR)
self.assertFalse(wca.is_removable(cls))
def test_stale_clean_issue_worktree_removable(self):
# Scenario 5: clean issue worktree, TTL expired, no lock -> removable.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_ISSUE_WORK,
is_dirty=False,
ttl_expired=True,
)
self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE)
self.assertTrue(wca.is_removable(cls))
def test_fresh_clean_issue_worktree_preserved(self):
# Clean issue worktree not yet TTL-expired stays active.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_ISSUE_WORK,
is_dirty=False,
ttl_expired=False,
)
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
self.assertFalse(wca.is_removable(cls))
def test_detached_review_worktree_classified(self):
# Scenario 6: detached review worktree -> detached_review_leftover.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_REVIEW,
is_dirty=False,
is_detached=True,
)
self.assertEqual(cls, wca.CLASS_DETACHED_REVIEW_LEFTOVER)
self.assertTrue(wca.is_removable(cls))
def test_ttl_expired_but_dirty_preserved(self):
# Scenario 7: dirty wins over TTL expiry.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_ISSUE_WORK,
is_dirty=True,
ttl_expired=True,
)
self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL)
self.assertFalse(wca.is_removable(cls))
def test_lease_protected_worktree_preserved(self):
# Scenario 8: active lease is never removable.
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_REVIEW,
is_dirty=False,
has_active_lease=True,
)
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
self.assertFalse(wca.is_removable(cls))
def test_active_issue_lock_preserved(self):
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_ISSUE_WORK,
is_dirty=False,
has_active_issue_lock=True,
ttl_expired=True,
)
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
self.assertFalse(wca.is_removable(cls))
def test_protected_base_worktree_never_removable(self):
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_ISSUE_WORK,
is_dirty=False,
is_protected=True,
ttl_expired=True,
)
self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN)
self.assertFalse(wca.is_removable(cls))
def test_unknown_workflow_type_unsafe(self):
cls = wca.classify_worktree(
workflow_type=wca.WORKFLOW_UNKNOWN, is_dirty=False, ttl_expired=True
)
self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN)
self.assertFalse(wca.is_removable(cls))
class TestRemovalDecision(unittest.TestCase):
def test_safe_removal_proof(self):
decision = wca.assess_worktree_removal(
path="branches/review-pr42",
branch="review-pr42",
head_sha="abc123",
is_dirty=False,
has_open_pr=False,
has_active_lease=False,
classification=wca.CLASS_CLEAN_STALE_REMOVABLE,
)
self.assertTrue(decision["safe_to_remove"])
self.assertEqual(decision["block_reasons"], [])
self.assertTrue(decision["clean"])
self.assertTrue(decision["no_active_pr"])
self.assertTrue(decision["no_active_lease"])
def test_dirty_blocks_removal(self):
decision = wca.assess_worktree_removal(
path="branches/review-pr42",
branch="review-pr42",
head_sha="abc123",
is_dirty=True,
has_open_pr=False,
has_active_lease=False,
classification=wca.CLASS_DIRTY_LOCAL,
)
self.assertFalse(decision["safe_to_remove"])
self.assertIn("worktree has uncommitted changes", decision["block_reasons"])
def test_open_pr_and_lease_block_removal(self):
decision = wca.assess_worktree_removal(
path="branches/review-pr42",
branch="review-pr42",
head_sha="abc123",
is_dirty=False,
has_open_pr=True,
has_active_lease=True,
classification=wca.CLASS_ACTIVE_OPEN_PR,
)
self.assertFalse(decision["safe_to_remove"])
self.assertIn("worktree branch has an open PR", decision["block_reasons"])
self.assertIn("worktree has an active lease", decision["block_reasons"])
class TestSuccessCleanupPlan(unittest.TestCase):
def test_review_worktree_removed_at_success(self):
# Scenario 1 end-to-end: clean review worktree removed at success.
meta = wca.build_worktree_metadata(path="branches/review-pr42")
plan = wca.plan_success_cleanup(
metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False
)
self.assertTrue(plan["remove"])
def test_failed_review_leaves_worktree_reported(self):
# Scenario 2: dirty review worktree preserved and reported at failure.
meta = wca.build_worktree_metadata(path="branches/review-pr42")
plan = wca.plan_success_cleanup(
metadata=meta, is_dirty=True, has_open_pr=False, has_active_lease=False
)
self.assertFalse(plan["remove"])
self.assertIn("uncommitted", plan["reason"])
report = wca.cleanup_failure_report(meta["path"], plan["reason"])
self.assertFalse(report["removed"])
self.assertEqual(report["path"], "branches/review-pr42")
def test_issue_worktree_preserved_by_policy(self):
meta = wca.build_worktree_metadata(
path="branches/issue-401-worktree", branch="feat/issue-401-worktree"
)
plan = wca.plan_success_cleanup(
metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False
)
self.assertFalse(plan["remove"])
self.assertIn("preserved by policy", plan["reason"])
class TestPorcelainParser(unittest.TestCase):
def test_parse_branch_and_detached(self):
text = (
"worktree /repo\n"
"HEAD 1111111111111111111111111111111111111111\n"
"branch refs/heads/master\n"
"\n"
"worktree /repo/branches/review-pr42\n"
"HEAD 2222222222222222222222222222222222222222\n"
"detached\n"
)
entries = wca.parse_worktree_porcelain(text)
self.assertEqual(len(entries), 2)
self.assertEqual(entries[0]["branch"], "master")
self.assertFalse(entries[0]["detached"])
self.assertIsNone(entries[1]["branch"])
self.assertTrue(entries[1]["detached"])
class TestAuditReportAccuracy(unittest.TestCase):
"""Scenario 9: cleanup report accuracy over a mixed branches/ directory."""
PORCELAIN = (
"worktree /repo\n"
"HEAD 1111111111111111111111111111111111111111\n"
"branch refs/heads/master\n"
"\n"
"worktree /repo/branches/review-pr42\n"
"HEAD 2222222222222222222222222222222222222222\n"
"branch refs/heads/review-pr42\n"
"\n"
"worktree /repo/branches/issue-400-open-pr\n"
"HEAD 3333333333333333333333333333333333333333\n"
"branch refs/heads/feat/issue-400-open-pr\n"
"\n"
"worktree /repo/branches/issue-401-dirty\n"
"HEAD 4444444444444444444444444444444444444444\n"
"branch refs/heads/feat/issue-401-dirty\n"
)
def _fake_dirty(self, path):
if path.endswith("issue-401-dirty"):
return {"exists": True, "dirty": True, "dirty_files": [" M x.py"]}
return {"exists": True, "dirty": False, "dirty_files": []}
def test_mixed_directory_classified(self):
with patch.object(
wca,
"list_worktrees",
return_value=wca.parse_worktree_porcelain(self.PORCELAIN),
), patch.object(
wca, "read_worktree_dirty", side_effect=self._fake_dirty
), patch.object(
wca, "git_worktree_list", return_value="(mocked)"
):
report = wca.audit_branches_directory(
"/repo",
open_pr_branches={"feat/issue-400-open-pr"},
)
by_path = {wt["path"]: wt for wt in report["worktrees"]}
# main checkout on master -> protected -> unsafe/unknown, not removable
self.assertEqual(
by_path["/repo"]["classification"], wca.CLASS_UNSAFE_UNKNOWN
)
# clean review worktree -> removable
self.assertEqual(
by_path["/repo/branches/review-pr42"]["classification"],
wca.CLASS_CLEAN_STALE_REMOVABLE,
)
# open PR branch -> preserved
self.assertEqual(
by_path["/repo/branches/issue-400-open-pr"]["classification"],
wca.CLASS_ACTIVE_OPEN_PR,
)
# dirty worktree -> preserved
self.assertEqual(
by_path["/repo/branches/issue-401-dirty"]["classification"],
wca.CLASS_DIRTY_LOCAL,
)
# exactly one removable candidate (the clean review worktree)
self.assertEqual(report["removable_count"], 1)
self.assertEqual(
report["removable_candidates"][0]["path"],
"/repo/branches/review-pr42",
)
self.assertEqual(report["total"], 4)
self.assertEqual(report["git_worktree_list"], "(mocked)")
def _integrity_entry(
path: str,
*,
classification: str,
registered: bool = True,
preserve: bool | None = None,
) -> dict:
preserve_flag = preserve if preserve is not None else classification in {
"active_open_pr",
"active_issue_work",
"dirty_local_worktree",
"unsafe_unknown",
}
return {
"path": path,
"classification": classification,
"preserve": preserve_flag,
"registered_worktree": registered,
"worktree_state": {"exists": True, "clean": classification == "clean_stale_removable"},
"worktree_record": {"branch": "feat/x"} if registered else None,
}
def _integrity_snapshot(entries: list[dict]) -> dict:
return {"entries": entries}
class TestParseWorktreePorcelain(unittest.TestCase):
def test_parses_multiple_worktrees(self):
text = "\n".join([
"worktree /proj/branches/foo",
"HEAD abcdef0123456789abcdef0123456789abcdef0",
"branch refs/heads/feat/foo",
"",
"worktree /proj",
"HEAD 1111111111111111111111111111111111111111",
"branch refs/heads/master",
])
parsed = wca.parse_worktree_list_porcelain(text)
self.assertEqual(len(parsed), 2)
self.assertEqual(parsed[0]["branch"], "feat/foo")
class TestClassifyEntry(unittest.TestCase):
def test_active_pr_classification(self):
result = wca.classify_branches_entry(
rel_path="branches/feat-issue-1-x",
worktree_record={"branch": "feat/issue-1-x"},
worktree_state={"exists": True, "clean": True, "dirty_files": []},
open_pr_branches={"feat/issue-1-x"},
)
self.assertEqual(result, "active_open_pr")
def test_dirty_classification(self):
result = wca.classify_branches_entry(
rel_path="branches/dirty-one",
worktree_record={"branch": "feat/dirty-one"},
worktree_state={"exists": True, "dirty_files": ["a.py"]},
open_pr_branches=set(),
)
self.assertEqual(result, "dirty_local_worktree")
class TestCleanupIntegrity(unittest.TestCase):
def test_preserved_worktree_remains(self):
path = "branches/keep-me"
before = _integrity_snapshot([
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
])
after = _integrity_snapshot([
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
])
result = wca.assess_worktree_cleanup_integrity(before=before, after=after)
self.assertTrue(result["integrity_passed"])
def test_intentional_removal_passes(self):
path = "branches/remove-me"
before = _integrity_snapshot([
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
])
after = _integrity_snapshot([])
result = wca.assess_worktree_cleanup_integrity(
before=before,
after=after,
removals=[{
"path": path,
"method": "git worktree remove",
"pre_removal_proof": "clean status",
}],
)
self.assertTrue(result["integrity_passed"])
def test_dirty_worktree_disappears_fails(self):
path = "branches/dirty-wt"
before = _integrity_snapshot([_integrity_entry(path, classification="dirty_local_worktree")])
after = _integrity_snapshot([])
recon = wca.reconcile_cleanup_audit(before, after)
result = wca.assess_cleanup_audit_integrity(recon)
self.assertFalse(result["proven"])
def test_active_pr_worktree_disappears_fails(self):
path = "branches/review-pr99"
before = _integrity_snapshot([_integrity_entry(path, classification="active_open_pr")])
after = _integrity_snapshot([])
result = wca.assess_worktree_cleanup_integrity(before=before, after=after)
self.assertFalse(result["integrity_passed"])
def test_explained_missing_allowed(self):
path = "branches/review-pr382"
before = _integrity_snapshot([
_integrity_entry(path, classification="detached_review_leftover", preserve=False),
])
after = _integrity_snapshot([])
result = wca.assess_worktree_cleanup_integrity(
before=before,
after=after,
explained_missing={path: "removed concurrently by sibling session"},
)
self.assertTrue(result["integrity_passed"])
def test_removal_log_omits_clean_stale_fails(self):
path = "branches/a"
before = _integrity_snapshot([
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
])
after = _integrity_snapshot([])
recon = wca.reconcile_cleanup_audit(before, after, removal_log=[])
self.assertFalse(recon["removal_log_complete"])
class TestCleanupReportProof(unittest.TestCase):
def test_complete_report_passes(self):
report = "\n".join([
"Cleanup audit reconciliation table:",
"Initial count: 10",
"Removed count: 3",
"Preserved count: 7",
"Missing-unexplained count: 0",
"Final count: 7",
"Final verification: git worktree list proof attached",
])
result = wca.assess_cleanup_audit_final_report(report)
self.assertTrue(result["proven"])
def test_incomplete_report_fails(self):
result = wca.assess_cleanup_audit_final_report("removed some worktrees")
self.assertFalse(result["proven"])
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff