Compare commits
43
Commits
1e76ce267c
...
f1449ff5c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1449ff5c8 | ||
|
|
08bcc03f17 | ||
|
|
6e589e73ea | ||
|
|
8c171fb47b | ||
|
|
18e7d7b6e1 | ||
|
|
78fce0dbb6 | ||
|
|
fd2bc018ff | ||
|
|
535da1bc5a | ||
|
|
dfbee45309 | ||
|
|
a4e014e62b | ||
|
|
eeadb1bbea | ||
|
|
749ca04388 | ||
|
|
ea51b0196a | ||
|
|
cc63ab74e6 | ||
|
|
cfce823dd7 | ||
|
|
7231f27c1c | ||
|
|
e27b6ddefb | ||
|
|
cefebd7643 | ||
|
|
3d540f6fba | ||
|
|
2bb45c0f80 | ||
|
|
0b62d4e06a | ||
|
|
08ea214526 | ||
|
|
6b45212659 | ||
|
|
ccdff1df27 | ||
|
|
af938064cd | ||
|
|
2faf18a802 | ||
|
|
236c8b779f | ||
|
|
564fee9639 | ||
|
|
d56baa635e | ||
|
|
c54ac0d4de | ||
|
|
4561d7ad12 | ||
|
|
3d11e1f12b | ||
|
|
182fcbf598 | ||
|
|
cedf451a2b | ||
|
|
be592d6d4d | ||
|
|
685e627984 | ||
|
|
831183308d | ||
|
|
2c47208b0d | ||
|
|
551ee7d70c | ||
|
|
ddaf380db2 | ||
|
|
5512ba7710 | ||
|
|
0d41760ea3 | ||
|
|
8a8cffbdf5 |
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
# profile's values. Leave unset for pure env-based configuration.
|
||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||
GITEA_MCP_PROFILE=prgs
|
||||
|
||||
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
|
||||
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
|
||||
# merger process) are ignored.
|
||||
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
|
||||
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
|
||||
|
||||
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
|
||||
explicitly authorized with exact capability proof, safety proof, and
|
||||
before/after snapshots. Cleanup mutations must be classified in final reports;
|
||||
audit reports must not claim ``no mutations`` when cleanup occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
|
||||
|
||||
PHASE_AUDIT = "audit"
|
||||
PHASE_CLEANUP = "cleanup"
|
||||
|
||||
# Tasks that enter audit phase on capability resolution (read-only default).
|
||||
AUDIT_PHASE_TASKS = frozenset({
|
||||
"reconcile-landed-pr",
|
||||
"reconcile_landed_pr",
|
||||
"reconcile_issue_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
})
|
||||
|
||||
# Mutation tasks forbidden during audit phase (fail closed).
|
||||
AUDIT_FORBIDDEN_TASKS = frozenset({
|
||||
"delete_branch",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"claim_issue",
|
||||
"close_pr",
|
||||
"close_issue",
|
||||
"create_issue",
|
||||
"merge_pr",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"comment_pr",
|
||||
"comment_issue",
|
||||
"set_issue_labels",
|
||||
})
|
||||
|
||||
# Shell/git commands audit phase must not run.
|
||||
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
|
||||
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
|
||||
r"gitea_delete_branch|delete_remote_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NO_MUTATIONS_RE = re.compile(
|
||||
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_OCCURRED_RE = re.compile(
|
||||
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
|
||||
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
|
||||
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_STATE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_PHASE_AUTH_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BEFORE_AFTER_RE = re.compile(
|
||||
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_SAFETY_PROOF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_session: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _blank_session() -> dict[str, Any]:
|
||||
return {
|
||||
"phase": PHASE_AUDIT,
|
||||
"entered_from_task": None,
|
||||
"cleanup_authorized": False,
|
||||
"cleanup_authorization": {},
|
||||
}
|
||||
|
||||
|
||||
def current_phase() -> str | None:
|
||||
"""Return active reconciliation phase or None when unset."""
|
||||
if not _session:
|
||||
return None
|
||||
return _session.get("phase")
|
||||
|
||||
|
||||
def active_record() -> dict[str, Any] | None:
|
||||
"""Return a copy of the session record, if any."""
|
||||
return dict(_session) if _session else None
|
||||
|
||||
|
||||
def clear_phase() -> None:
|
||||
"""Clear reconciliation phase state."""
|
||||
global _session
|
||||
_session = None
|
||||
|
||||
|
||||
def enter_audit_phase(task: str) -> dict[str, Any]:
|
||||
"""Enter read-only audit phase for a reconciliation task."""
|
||||
global _session
|
||||
normalized = (task or "").strip().lower()
|
||||
_session = _blank_session()
|
||||
_session["entered_from_task"] = normalized
|
||||
return dict(_session)
|
||||
|
||||
|
||||
def authorize_cleanup_phase(
|
||||
*,
|
||||
operator_approved: bool = False,
|
||||
workflow_authorized: bool = False,
|
||||
delete_capability_proven: bool = False,
|
||||
safety_proof: dict[str, Any] | None = None,
|
||||
before_after_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize cleanup phase after explicit approval and safety proofs."""
|
||||
reasons: list[str] = []
|
||||
if not (operator_approved or workflow_authorized):
|
||||
reasons.append(
|
||||
"cleanup phase requires operator approval or explicit workflow "
|
||||
"authorization"
|
||||
)
|
||||
if not delete_capability_proven:
|
||||
reasons.append(
|
||||
"cleanup phase requires exact delete_branch capability proof "
|
||||
"(gitea.branch.delete)"
|
||||
)
|
||||
safety = dict(safety_proof or {})
|
||||
if not safety.get("safe_to_delete_remote") and not safety.get(
|
||||
"safe_to_remove_worktree"
|
||||
):
|
||||
reasons.append(
|
||||
"cleanup phase requires proof that branch/worktree is safe to remove"
|
||||
)
|
||||
snapshot = dict(before_after_snapshot or {})
|
||||
if not snapshot.get("before") or not snapshot.get("after"):
|
||||
reasons.append(
|
||||
"cleanup phase requires before/after state snapshot"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"authorized": False,
|
||||
"phase": current_phase() or PHASE_AUDIT,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remain in audit-only mode or supply operator approval, "
|
||||
"delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot before cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = _blank_session()
|
||||
_session["phase"] = PHASE_CLEANUP
|
||||
_session["cleanup_authorized"] = True
|
||||
_session["cleanup_authorization"] = {
|
||||
"operator_approved": operator_approved,
|
||||
"workflow_authorized": workflow_authorized,
|
||||
"delete_capability_proven": delete_capability_proven,
|
||||
"safety_proof": safety,
|
||||
"before_after_snapshot": snapshot,
|
||||
}
|
||||
return {
|
||||
"authorized": True,
|
||||
"phase": PHASE_CLEANUP,
|
||||
"reasons": [],
|
||||
"cleanup_authorization": dict(_session["cleanup_authorization"]),
|
||||
"safe_next_action": "proceed with authorized cleanup mutations only",
|
||||
}
|
||||
|
||||
|
||||
def check_audit_task_enters_phase(task: str) -> bool:
|
||||
"""Return whether resolving *task* should enter audit phase."""
|
||||
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
|
||||
|
||||
|
||||
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed when a mutation task runs during audit phase."""
|
||||
normalized = (task or "").strip().lower()
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
if normalized in AUDIT_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in audit-only reconciliation "
|
||||
"mode: switch to an explicit cleanup phase with operator approval "
|
||||
"and exact delete_branch capability proof before cleanup mutations"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
|
||||
"""Fail closed when cleanup execution is attempted without authorization."""
|
||||
phase = current_phase()
|
||||
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
|
||||
return True, []
|
||||
if phase is None:
|
||||
return False, [
|
||||
"cleanup execution requires an active reconciliation session; "
|
||||
"resolve a reconciliation audit task first"
|
||||
]
|
||||
return False, [
|
||||
"cleanup execution forbidden in audit-only reconciliation mode; "
|
||||
"call gitea_authorize_reconciliation_cleanup_phase with operator "
|
||||
"approval, delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot"
|
||||
]
|
||||
|
||||
|
||||
def classify_cleanup_mutation(action: str) -> str:
|
||||
"""Map a cleanup action to the required mutation ledger category (#419)."""
|
||||
normalized = (action or "").strip().lower()
|
||||
if "delete_remote" in normalized or normalized in {
|
||||
"delete_branch",
|
||||
"gitea_delete_branch",
|
||||
}:
|
||||
return "external-state"
|
||||
if "branch" in normalized and "delete" in normalized:
|
||||
return "git-ref"
|
||||
if "worktree" in normalized or "remove_local" in normalized:
|
||||
return "cleanup"
|
||||
return "cleanup"
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
|
||||
"""Validate audit/cleanup reconciliation reports (fail closed)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
|
||||
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
|
||||
|
||||
if cleanup_occurred and claims_no_mutations:
|
||||
reasons.append(
|
||||
"report claims no mutations but documents cleanup mutations; "
|
||||
"audit-only reports must not perform cleanup and cleanup reports "
|
||||
"must not claim no mutations"
|
||||
)
|
||||
|
||||
if cleanup_occurred:
|
||||
if not _CLEANUP_PHASE_AUTH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without "
|
||||
"'Cleanup phase authorized: true'"
|
||||
)
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without delete_branch capability "
|
||||
"proof"
|
||||
)
|
||||
if not _BEFORE_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without before/after state snapshot"
|
||||
)
|
||||
if not _SAFETY_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without branch/worktree safety proof"
|
||||
)
|
||||
|
||||
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
|
||||
if not _EXTERNAL_STATE_RE.search(text):
|
||||
reasons.append(
|
||||
"remote branch deletion must be classified under "
|
||||
"External-state mutations"
|
||||
)
|
||||
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
|
||||
if not _GIT_REF_RE.search(text):
|
||||
reasons.append(
|
||||
"local branch deletion must be classified under "
|
||||
"Git ref mutations"
|
||||
)
|
||||
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
|
||||
if not _CLEANUP_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"worktree removal must be classified under Cleanup mutations"
|
||||
)
|
||||
|
||||
if (
|
||||
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
|
||||
or "reconcile-landed-pr" in text.lower()
|
||||
):
|
||||
if cleanup_occurred and "audit phase" in text.lower():
|
||||
if "cleanup phase" not in text.lower():
|
||||
reasons.append(
|
||||
"report mixes audit phase with cleanup mutations without "
|
||||
"documenting cleanup phase transition"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"cleanup_occurred": cleanup_occurred,
|
||||
"claims_no_mutations": claims_no_mutations,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "fix audit/cleanup report: separate audit from cleanup phase, "
|
||||
"classify mutations, and do not claim no mutations after cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
|
||||
"""Block shell commands that perform cleanup during audit phase."""
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
cmd = (command or "").strip()
|
||||
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
|
||||
return False, [
|
||||
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
|
||||
"authorize cleanup phase before branch/worktree deletion or push"
|
||||
]
|
||||
return True, []
|
||||
@@ -12,6 +12,8 @@ import subprocess
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
|
||||
@@ -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.
|
||||
@@ -823,6 +881,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Namespace workspace binding (#510)
|
||||
|
||||
Each MCP namespace resolves its **own** active task workspace. Foreign role
|
||||
worktree environment variables must not poison another namespace's purity
|
||||
checks.
|
||||
|
||||
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|
||||
|-----------|------------------------------------------------------------------|---------------|
|
||||
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
|
||||
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
|
||||
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
|
||||
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
|
||||
|
||||
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
|
||||
MCP processes ignore it even when it points at a dirty author WIP tree.
|
||||
|
||||
### Safe reconnect / rebind procedure
|
||||
|
||||
When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
### CTH guidance for workspace binding blockers
|
||||
|
||||
When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -118,6 +119,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",
|
||||
@@ -568,6 +585,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):
|
||||
@@ -1024,6 +1062,70 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
||||
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
||||
if not report_text.strip():
|
||||
return []
|
||||
findings: list[dict[str, str]] = []
|
||||
has_helper = bool(_WORKFLOW_LOAD_HELPER_RE.search(report_text))
|
||||
has_hash = bool(_WORKFLOW_LOAD_HASH_RE.search(report_text))
|
||||
has_boundary = bool(_WORKFLOW_LOAD_BOUNDARY_RE.search(report_text))
|
||||
has_narrative_only = bool(_WORKFLOW_FILE_VIEW_NARRATIVE_RE.search(report_text))
|
||||
|
||||
if has_narrative_only and not has_helper:
|
||||
findings.append(validator_finding(
|
||||
"reviewer.workflow_load_boundary",
|
||||
"block",
|
||||
"Workflow-load helper result",
|
||||
(
|
||||
"canonical workflow file-view narrative without structured "
|
||||
"gitea_load_review_workflow helper result"
|
||||
),
|
||||
(
|
||||
"include Workflow-load helper result with workflow_hash and "
|
||||
"boundary_status from gitea_load_review_workflow"
|
||||
),
|
||||
))
|
||||
return findings
|
||||
|
||||
if has_helper and (not has_hash or not has_boundary):
|
||||
missing = []
|
||||
if not has_hash:
|
||||
missing.append("workflow_hash")
|
||||
if not has_boundary:
|
||||
missing.append("boundary_status")
|
||||
findings.append(validator_finding(
|
||||
"reviewer.workflow_load_boundary",
|
||||
"block",
|
||||
"Workflow-load helper result",
|
||||
(
|
||||
"workflow-load helper result incomplete; missing "
|
||||
+ ", ".join(missing)
|
||||
),
|
||||
(
|
||||
"copy workflow_load_helper_result fields from "
|
||||
"gitea_load_review_workflow into the final report"
|
||||
),
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
|
||||
from audit_reconciliation_mode import assess_audit_reconciliation_report
|
||||
|
||||
result = assess_audit_reconciliation_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reconcile.audit_cleanup_boundary",
|
||||
result.get("reasons") or [],
|
||||
field="Audit/cleanup phase",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "separate audit from authorized cleanup and classify mutations",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_review_mutation(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -1057,12 +1159,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,
|
||||
@@ -1084,15 +1204,18 @@ _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_post_merge_cleanup_proof,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_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,
|
||||
@@ -1100,6 +1223,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_audit_reconciliation_boundary,
|
||||
],
|
||||
"author_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
@@ -1113,6 +1237,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,
|
||||
|
||||
+1118
-56
File diff suppressed because it is too large
Load Diff
Executable
+232
@@ -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
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Namespace-scoped MCP workspace binding (#510).
|
||||
|
||||
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
||||
active task workspace. Foreign role worktree environment variables must not
|
||||
poison workspace purity checks in another namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"author": AUTHOR_WORKTREE_ENV,
|
||||
"reviewer": REVIEWER_WORKTREE_ENV,
|
||||
"merger": MERGER_WORKTREE_ENV,
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
role_kind: str | None,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map profile/task role to a workspace namespace key."""
|
||||
role = (role_kind or "author").strip().lower()
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
||||
text = (env.get(key) or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_namespace_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
|
||||
def resolve_namespace_mutation_context(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
*,
|
||||
role_kind: str,
|
||||
resolved_workspace: str,
|
||||
binding_source: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Detect when a foreign role env would have hijacked workspace binding."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if role == "author":
|
||||
return {"would_pollute": False, "ignored_bindings": []}
|
||||
|
||||
ignored: list[str] = []
|
||||
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
||||
if author_path:
|
||||
author_real = os.path.realpath(os.path.abspath(author_path))
|
||||
resolved_real = os.path.realpath(resolved_workspace)
|
||||
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
||||
ignored.append(
|
||||
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
||||
)
|
||||
return {
|
||||
"would_pollute": bool(ignored),
|
||||
"ignored_bindings": ignored,
|
||||
}
|
||||
|
||||
|
||||
def assess_metadata_only_worktree_binding(
|
||||
*,
|
||||
role_kind: str,
|
||||
declared_worktree_path: str | None,
|
||||
mutation_workspace: str,
|
||||
process_project_root: str,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||
declared = (declared_worktree_path or "").strip()
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
mutation_root = os.path.realpath(mutation_workspace)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if not declared:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||
if declared_root == mutation_root:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
if declared_root != process_root and mutation_root == process_root:
|
||||
return {
|
||||
"block": True,
|
||||
"metadata_only": True,
|
||||
"reasons": [
|
||||
f"worktree_path is metadata-only for {role} mutations: preflight "
|
||||
f"inspected '{declared_root}' but mutation tools would still "
|
||||
f"validate MCP server process root '{process_root}'"
|
||||
],
|
||||
"declared_worktree_path": declared_root,
|
||||
"mutation_workspace": mutation_root,
|
||||
"process_project_root": process_root,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
|
||||
def format_namespace_workspace_binding_error(
|
||||
*,
|
||||
role_kind: str,
|
||||
workspace_path: str,
|
||||
binding_source: str,
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
]
|
||||
if ignored_bindings:
|
||||
parts.append(
|
||||
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
||||
)
|
||||
if dirty_files:
|
||||
parts.append(
|
||||
"Dirty tracked files in active task workspace: "
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def assess_namespace_mutation_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
worktree: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
role = ctx["workspace_role_kind"]
|
||||
process_root = ctx["process_project_root"]
|
||||
|
||||
metadata = assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
declared_worktree_path=worktree_path,
|
||||
mutation_workspace=mutation_workspace,
|
||||
process_project_root=process_root,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=mutation_workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
and mutation_workspace != process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"mutation_workspace": mutation_workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": ctx["canonical_repo_root"],
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -5115,6 +5115,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
|
||||
return _assess(report_text or "")
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str | None) -> dict:
|
||||
"""#419: validate audit vs cleanup reconciliation report boundaries."""
|
||||
from audit_reconciliation_mode import (
|
||||
assess_audit_reconciliation_report as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text or "")
|
||||
|
||||
|
||||
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
|
||||
|
||||
_NOT_APPLICABLE_VALUE = re.compile(
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import review_workflow_boundary as boundary
|
||||
|
||||
WORKFLOW_REL_PATH = (
|
||||
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
||||
)
|
||||
SCHEMA_REL_PATH = (
|
||||
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
||||
)
|
||||
TASK_MODE = "review-merge-pr"
|
||||
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
||||
|
||||
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
||||
|
||||
|
||||
def compute_content_hash(text: str) -> str:
|
||||
"""Short deterministic hash for workflow/schema version proof."""
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
||||
root = Path(project_root)
|
||||
workflow = root / WORKFLOW_REL_PATH
|
||||
schema = root / SCHEMA_REL_PATH
|
||||
if not workflow.is_file():
|
||||
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
||||
if not schema.is_file():
|
||||
raise FileNotFoundError(f"final report schema missing: {schema}")
|
||||
return workflow, schema
|
||||
|
||||
|
||||
def build_canonical_workflow_metadata(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Load workflow + schema from disk and compute proof metadata."""
|
||||
workflow_path, schema_path = _canonical_paths(project_root)
|
||||
workflow_text = _read_text(workflow_path)
|
||||
schema_text = _read_text(schema_path)
|
||||
workflow_hash = compute_content_hash(workflow_text)
|
||||
schema_hash = compute_content_hash(schema_text)
|
||||
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
||||
return {
|
||||
"workflow_source": WORKFLOW_REL_PATH,
|
||||
"workflow_path": str(workflow_path),
|
||||
"task_mode": TASK_MODE,
|
||||
"workflow_hash": workflow_hash,
|
||||
"workflow_version": workflow_hash,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"final_report_schema_hash": schema_hash,
|
||||
"prompt_conflicts_with_workflow": conflict,
|
||||
"prompt_conflict_reasons": conflict_reasons,
|
||||
"load_tool": LOAD_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
||||
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
||||
if not (prompt_text or "").strip():
|
||||
return False, []
|
||||
text = prompt_text.lower()
|
||||
reasons: list[str] = []
|
||||
conflicting = (
|
||||
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
||||
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
||||
(r"\bauthor/coder\b", "author/coder mode"),
|
||||
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
||||
)
|
||||
for pattern, label in conflicting:
|
||||
if re.search(pattern, text):
|
||||
reasons.append(
|
||||
f"active prompt appears to request {label} while loading "
|
||||
f"{TASK_MODE} workflow"
|
||||
)
|
||||
return bool(reasons), reasons
|
||||
|
||||
|
||||
def record_review_workflow_load(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Record in-process workflow load proof for the current MCP session."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
meta = build_canonical_workflow_metadata(
|
||||
project_root, prompt_text=prompt_text)
|
||||
boundary_state = boundary.assess_boundary_status(project_root)
|
||||
_REVIEW_WORKFLOW_LOAD = {
|
||||
**meta,
|
||||
"session_pid": os.getpid(),
|
||||
"loaded": True,
|
||||
"boundary_status": boundary_state.get("boundary_status"),
|
||||
"boundary_clean": boundary_state.get("boundary_clean"),
|
||||
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
||||
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
||||
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
||||
}
|
||||
return dict(_REVIEW_WORKFLOW_LOAD)
|
||||
|
||||
|
||||
def clear_review_workflow_load() -> None:
|
||||
"""Test helper and review_pr session reset."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
_REVIEW_WORKFLOW_LOAD = None
|
||||
boundary.clear_pre_review_commands()
|
||||
|
||||
|
||||
def workflow_load_status(project_root: str | None = None) -> dict:
|
||||
"""Non-throwing status for capability/runtime reports."""
|
||||
load = _REVIEW_WORKFLOW_LOAD
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_load_valid": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"reasons": [
|
||||
f"{LOAD_TOOL_NAME} has not been called in this session "
|
||||
"(fail closed for reviewer mutations)"
|
||||
],
|
||||
}
|
||||
reasons = _session_validation_reasons(load, project_root)
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
if boundary_reasons:
|
||||
reasons = list(reasons) + boundary_reasons
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_load_valid": not reasons,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"task_mode": load.get("task_mode"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"prompt_conflicts_with_workflow": load.get(
|
||||
"prompt_conflicts_with_workflow"),
|
||||
"session_pid": load.get("session_pid"),
|
||||
"boundary_status": load.get("boundary_status"),
|
||||
"boundary_clean": load.get("boundary_clean"),
|
||||
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
||||
load, project_root),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _session_validation_reasons(
|
||||
load: dict,
|
||||
project_root: str | None,
|
||||
) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
if load.get("session_pid") != os.getpid():
|
||||
reasons.append(
|
||||
"workflow load proof was recorded in a different process "
|
||||
"(fail closed)"
|
||||
)
|
||||
return reasons
|
||||
if load.get("prompt_conflicts_with_workflow"):
|
||||
reasons.extend(load.get("prompt_conflict_reasons") or [
|
||||
"active prompt conflicts with loaded review-merge workflow"
|
||||
])
|
||||
if project_root:
|
||||
try:
|
||||
current = build_canonical_workflow_metadata(project_root)
|
||||
except OSError as exc:
|
||||
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
||||
return reasons
|
||||
if current["workflow_hash"] != load.get("workflow_hash"):
|
||||
reasons.append(
|
||||
"stored workflow hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
if current["final_report_schema_hash"] != load.get(
|
||||
"final_report_schema_hash"):
|
||||
reasons.append(
|
||||
"stored final-report schema hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def review_workflow_load_blockers(
|
||||
project_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed."""
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
if boundary_reasons and _REVIEW_WORKFLOW_LOAD is None:
|
||||
return boundary_reasons
|
||||
status = workflow_load_status(project_root)
|
||||
if not status.get("workflow_load_proof_present"):
|
||||
return list(status.get("reasons") or []) + boundary_reasons
|
||||
if not status.get("workflow_load_valid"):
|
||||
return list(status.get("reasons") or [])
|
||||
return []
|
||||
|
||||
|
||||
def recovery_handoff_without_replay() -> list[str]:
|
||||
"""Safe next-step lines that must not include approve/merge replay."""
|
||||
return [
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory.",
|
||||
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
||||
"or gitea_merge_pr until workflow-load proof is present.",
|
||||
"Do not include approve/merge replay commands in the recovery handoff.",
|
||||
]
|
||||
+139
-6
@@ -23,6 +23,7 @@ _ACTIVE_PHASES = frozenset({
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
"adopted",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
@@ -217,12 +218,24 @@ def assess_acquire_lease(
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
pr_merged_or_closed: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when another session holds an active lease."""
|
||||
"""Fail closed when another session holds an active lease.
|
||||
|
||||
When *pr_merged_or_closed* is true the PR has already merged/closed, so any
|
||||
reviewer-lease acquisition or adoption for merge work is moot: fail closed
|
||||
with a ``post_merge_moot`` reason and never mint a lease body (#515).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
post_merge_moot = bool(pr_merged_or_closed)
|
||||
if post_merge_moot:
|
||||
reasons.append(
|
||||
f"post_merge_moot: PR #{pr_number} is already merged/closed; reviewer "
|
||||
"lease adoption for merge is moot (fail closed)"
|
||||
)
|
||||
if existing:
|
||||
owner_session = (existing.get("session_id") or "").strip()
|
||||
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
|
||||
@@ -270,12 +283,120 @@ def assess_acquire_lease(
|
||||
"existing_lease": existing,
|
||||
"lease_body": body,
|
||||
"session_id": session_id,
|
||||
"post_merge_moot": post_merge_moot,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
|
||||
def assess_post_merge_moot_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
pr_merged: bool = False,
|
||||
pr_state: str | None = None,
|
||||
merge_commit_sha: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess a reviewer lease left lingering on an already-merged/closed PR (#515).
|
||||
|
||||
Read-first and fail-safe:
|
||||
|
||||
- Only treats a lease as moot when the live PR state is merged/closed.
|
||||
- Never proposes touching an *active* lease while the PR is still open
|
||||
(``cleanup_allowed`` stays false and a refusal reason is returned).
|
||||
- When the PR is merged/closed and a lease is still active, ``cleanup_allowed``
|
||||
is true and a terminal ``phase: released`` lease body (``blocker:
|
||||
post-merge-moot``) is provided so the moot lease can be neutralised by an
|
||||
append-only comment — never by deleting a foreign session's comment, and
|
||||
never by adopting or merging.
|
||||
|
||||
Posting the released body makes that lease terminal, so a subsequent call
|
||||
finds no active lease and reports nothing left to clean (idempotent).
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
merged_or_closed = bool(pr_merged) or (
|
||||
str(pr_state or "").strip().lower() == "closed"
|
||||
)
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
# The newest lease comment is authoritative: once a terminal marker
|
||||
# (released/done/blocked) is the latest entry, the lease is resolved even if
|
||||
# an earlier non-terminal comment from the same session still lingers. This
|
||||
# keeps post-merge cleanup idempotent.
|
||||
entries = _lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
newest_terminal = bool(newest) and (
|
||||
(newest.get("phase") or "").strip().lower() in _TERMINAL_PHASES
|
||||
)
|
||||
reasons: list[str] = []
|
||||
cleanup_allowed = False
|
||||
release_body: str | None = None
|
||||
is_moot = bool(active) and merged_or_closed and not newest_terminal
|
||||
|
||||
if not merged_or_closed:
|
||||
if active:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; refusing to touch active reviewer "
|
||||
"lease (fail closed)"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is still open; no post-merge lease cleanup applicable"
|
||||
)
|
||||
elif newest_terminal:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} reviewer lease already released/terminal; nothing to clean"
|
||||
)
|
||||
elif active:
|
||||
cleanup_allowed = True
|
||||
release_body = format_lease_body(
|
||||
repo=active.get("repo") or "",
|
||||
pr_number=pr_number,
|
||||
issue_number=active.get("issue_number"),
|
||||
reviewer_identity=active.get("reviewer_identity") or "",
|
||||
profile=active.get("profile") or "unknown",
|
||||
session_id=active.get("session_id") or "",
|
||||
worktree=active.get("worktree") or "",
|
||||
phase="released",
|
||||
candidate_head=active.get("candidate_head"),
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
last_activity=now,
|
||||
blocker="post-merge-moot",
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is merged/closed but no active reviewer lease remains; "
|
||||
"nothing to clean"
|
||||
)
|
||||
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"pr_merged_or_closed": merged_or_closed,
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
"active_lease": active,
|
||||
"is_moot": is_moot,
|
||||
"cleanup_allowed": cleanup_allowed,
|
||||
"release_body": release_body,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(
|
||||
lease: dict[str, Any],
|
||||
*,
|
||||
lease_provenance: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record the in-session lease mirror for mutation gates.
|
||||
|
||||
*lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare
|
||||
manual seeding without provenance cannot satisfy merger/reviewer mutation
|
||||
gates.
|
||||
"""
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = dict(lease)
|
||||
stored = dict(lease)
|
||||
if lease_provenance:
|
||||
stored["lease_provenance"] = dict(lease_provenance)
|
||||
_SESSION_LEASE = stored
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
@@ -308,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:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""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,
|
||||
) -> dict:
|
||||
"""Fail closed when the control checkout is not clean master/prgs/master."""
|
||||
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":
|
||||
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
|
||||
@@ -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 reviewer merges. If authenticated user == PR
|
||||
author → STOP.
|
||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||
@@ -41,7 +45,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.
|
||||
|
||||
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
|
||||
* include safe next action (profile switch, human close, or dedicated reconciler
|
||||
profile)
|
||||
|
||||
## 15A. Audit vs cleanup phase (#419)
|
||||
|
||||
Reconciliation audits are **read-only** unless a separate cleanup phase is
|
||||
explicitly authorized.
|
||||
|
||||
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
|
||||
fails closed):
|
||||
|
||||
* ``gitea_delete_branch``
|
||||
* ``git branch -D``
|
||||
* ``git worktree remove``
|
||||
* pushes
|
||||
* issue/PR mutations
|
||||
* file edits
|
||||
|
||||
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
|
||||
``dry_run=True``) stays in audit phase. Execution requires:
|
||||
|
||||
1. Operator approval or workflow authorization
|
||||
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
|
||||
3. Proof branch/worktree is safe to remove
|
||||
4. Before/after state snapshot
|
||||
|
||||
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
|
||||
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
|
||||
Classify cleanup mutations as:
|
||||
|
||||
* remote branch deletion → **External-state mutations**
|
||||
* local branch deletion → **Git ref mutations**
|
||||
* worktree removal → **Cleanup mutations**
|
||||
|
||||
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
|
||||
these boundaries in final reports.
|
||||
|
||||
## 16. Mutation classification
|
||||
|
||||
Use precise mutation categories in the final report:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -68,6 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"adopt_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"blind_pr_queue_review": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
@@ -104,6 +108,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.read",
|
||||
"role": "author",
|
||||
},
|
||||
"reconciliation_cleanup": {
|
||||
"permission": "gitea.branch.delete",
|
||||
"role": "author",
|
||||
},
|
||||
"work_issue": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
|
||||
@@ -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()
|
||||
+10
-4
@@ -157,6 +157,7 @@ class _AuditWiringBase(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
@@ -291,12 +292,15 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import _install_owned_reviewer_lease
|
||||
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
|
||||
|
||||
# init_review_decision_lock clears any prior session lease (#407).
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
# 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(
|
||||
@@ -337,6 +341,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||
expected_head_sha="abc123", remote="prgs")
|
||||
self.assertTrue(r["performed"])
|
||||
@@ -356,6 +361,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||
self.assertFalse(r["performed"])
|
||||
recs = self._records()
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for audit vs cleanup reconciliation mode (#419)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import audit_reconciliation_mode as arm
|
||||
import mcp_server
|
||||
from audit_reconciliation_mode import (
|
||||
AUDIT_FORBIDDEN_TASKS,
|
||||
PHASE_AUDIT,
|
||||
PHASE_CLEANUP,
|
||||
assess_audit_command_allowed,
|
||||
assess_audit_reconciliation_report,
|
||||
authorize_cleanup_phase,
|
||||
check_audit_mutation_allowed,
|
||||
check_cleanup_execution_allowed,
|
||||
classify_cleanup_mutation,
|
||||
clear_phase,
|
||||
enter_audit_phase,
|
||||
)
|
||||
from final_report_validator import assess_final_report_validator
|
||||
from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "prgs-author-delete",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-author-delete",
|
||||
}
|
||||
|
||||
READ_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
||||
"forbidden_operations": ["gitea.branch.delete"],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
READ_ENV = {
|
||||
"GITEA_MCP_CONFIG": os.path.join(
|
||||
os.path.dirname(__file__), "..", "profiles.json"
|
||||
),
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
}
|
||||
|
||||
|
||||
def _authorize_cleanup(**kwargs):
|
||||
defaults = {
|
||||
"operator_approved": True,
|
||||
"delete_capability_proven": True,
|
||||
"safety_proof": {"safe_to_delete_remote": True},
|
||||
"before_after_snapshot": {
|
||||
"before": "remote branch exists",
|
||||
"after": "remote branch absent",
|
||||
},
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return authorize_cleanup_phase(**defaults)
|
||||
|
||||
|
||||
def _cleanup_report(**overrides):
|
||||
base = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"Workflow source: workflows/reconcile-landed-pr.md\n"
|
||||
"Audit phase: read-only assessment\n"
|
||||
"Cleanup phase authorized: true\n"
|
||||
"Delete-branch capability proven: true\n"
|
||||
"Branch safe to remove: true\n"
|
||||
"Before/after state snapshot: remote branch feat/x present → absent\n"
|
||||
"External-state mutations: deleted remote branch feat/x\n"
|
||||
"Git ref mutations: none\n"
|
||||
"Cleanup mutations: removed worktree branches/feat-x\n"
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
base = base.replace(key, value)
|
||||
return base
|
||||
|
||||
|
||||
class TestAuditPhaseGates(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_phase()
|
||||
enter_audit_phase("reconcile-landed-pr")
|
||||
|
||||
def tearDown(self):
|
||||
clear_phase()
|
||||
|
||||
def test_audit_blocks_delete_branch_task(self):
|
||||
allowed, reasons = check_audit_mutation_allowed("delete_branch")
|
||||
self.assertFalse(allowed)
|
||||
self.assertTrue(reasons)
|
||||
|
||||
def test_audit_blocks_worktree_shell_commands(self):
|
||||
allowed, reasons = assess_audit_command_allowed(
|
||||
"git worktree remove branches/feat-x"
|
||||
)
|
||||
self.assertFalse(allowed)
|
||||
self.assertTrue(reasons)
|
||||
|
||||
def test_audit_blocks_local_branch_delete_command(self):
|
||||
allowed, reasons = assess_audit_command_allowed("git branch -D feat/x")
|
||||
self.assertFalse(allowed)
|
||||
|
||||
def test_audit_allows_read_tasks(self):
|
||||
allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr")
|
||||
self.assertTrue(allowed)
|
||||
|
||||
def test_forbidden_set_covers_issue_and_pr_mutations(self):
|
||||
for task in ("close_pr", "comment_issue", "commit_files", "push_branch"):
|
||||
self.assertIn(task, AUDIT_FORBIDDEN_TASKS)
|
||||
|
||||
|
||||
class TestCleanupAuthorization(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_phase()
|
||||
enter_audit_phase("reconcile_merged_cleanups")
|
||||
|
||||
def tearDown(self):
|
||||
clear_phase()
|
||||
|
||||
def test_cleanup_without_approval_blocked(self):
|
||||
result = authorize_cleanup_phase(
|
||||
delete_capability_proven=True,
|
||||
safety_proof={"safe_to_delete_remote": True},
|
||||
before_after_snapshot={"before": "a", "after": "b"},
|
||||
)
|
||||
self.assertFalse(result["authorized"])
|
||||
|
||||
def test_cleanup_without_capability_proof_blocked(self):
|
||||
result = _authorize_cleanup(delete_capability_proven=False)
|
||||
self.assertFalse(result["authorized"])
|
||||
|
||||
def test_cleanup_without_snapshot_blocked(self):
|
||||
result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""})
|
||||
self.assertFalse(result["authorized"])
|
||||
|
||||
def test_authorized_cleanup_switches_phase(self):
|
||||
result = _authorize_cleanup()
|
||||
self.assertTrue(result["authorized"])
|
||||
self.assertEqual(result["phase"], PHASE_CLEANUP)
|
||||
|
||||
def test_cleanup_execution_allowed_only_after_authorization(self):
|
||||
self.assertFalse(check_cleanup_execution_allowed()[0])
|
||||
_authorize_cleanup()
|
||||
self.assertTrue(check_cleanup_execution_allowed()[0])
|
||||
|
||||
|
||||
class TestReportVerifier(unittest.TestCase):
|
||||
def test_false_no_mutations_after_cleanup_blocked(self):
|
||||
report = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"No mutations performed.\n"
|
||||
"delete_remote_branch feat/dup\n"
|
||||
)
|
||||
result = assess_audit_reconciliation_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertIn("no mutations", result["reasons"][0].lower())
|
||||
|
||||
def test_cleanup_without_authorization_fields_blocked(self):
|
||||
report = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"remove_local_worktree branches/feat-x\n"
|
||||
)
|
||||
result = assess_audit_reconciliation_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_authorized_cleanup_report_passes(self):
|
||||
result = assess_audit_reconciliation_report(_cleanup_report())
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_mutation_classification_enforced(self):
|
||||
report = (
|
||||
"Task mode: reconcile-landed-pr\n"
|
||||
"Cleanup phase authorized: true\n"
|
||||
"Delete-branch capability proven: true\n"
|
||||
"Branch safe to remove: true\n"
|
||||
"Before/after state snapshot: present\n"
|
||||
"remove_local_worktree branches/feat-x\n"
|
||||
)
|
||||
result = assess_audit_reconciliation_report(report)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_proofs_export_matches_module(self):
|
||||
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
||||
self.assertEqual(
|
||||
proofs_assess(report)["proven"],
|
||||
assess_audit_reconciliation_report(report)["proven"],
|
||||
)
|
||||
|
||||
def test_final_report_validator_includes_boundary_rule(self):
|
||||
report = "No mutations performed.\ndelete_remote_branch feat/dup"
|
||||
result = assess_final_report_validator(
|
||||
report_text=report,
|
||||
task_kind="reconcile_already_landed",
|
||||
)
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reconcile.audit_cleanup_boundary", rule_ids)
|
||||
|
||||
|
||||
class TestMutationClassification(unittest.TestCase):
|
||||
def test_remote_delete_is_external_state(self):
|
||||
self.assertEqual(
|
||||
classify_cleanup_mutation("delete_remote_branch"),
|
||||
"external-state",
|
||||
)
|
||||
|
||||
def test_worktree_remove_is_cleanup(self):
|
||||
self.assertEqual(
|
||||
classify_cleanup_mutation("remove_local_worktree"),
|
||||
"cleanup",
|
||||
)
|
||||
|
||||
|
||||
class TestMcpGates(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_phase()
|
||||
enter_audit_phase("reconcile_merged_cleanups")
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch(
|
||||
"mcp_server.get_auth_header", return_value="token test"
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
clear_phase()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
||||
self.mock_api.assert_not_called()
|
||||
|
||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_reconcile_merged_cleanups(
|
||||
dry_run=False,
|
||||
execute_confirmed=False,
|
||||
remote="prgs",
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
||||
@patch("mcp_server.get_profile", return_value=READ_PROFILE)
|
||||
def test_cleanup_auth_fails_without_delete_capability(self, _profile):
|
||||
result = mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
||||
operator_approved=True,
|
||||
delete_capability_proven=True,
|
||||
safe_to_delete_remote=True,
|
||||
before_state="exists",
|
||||
after_state="gone",
|
||||
)
|
||||
self.assertFalse(result["authorized"])
|
||||
self.assertFalse(result["delete_capability_verified"])
|
||||
|
||||
@patch.dict(os.environ, READ_ENV, clear=True)
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
def test_delete_branch_allowed_after_cleanup_authorization(self, _profile):
|
||||
mcp_server.gitea_authorize_reconciliation_cleanup_phase(
|
||||
operator_approved=True,
|
||||
delete_capability_proven=True,
|
||||
safe_to_delete_remote=True,
|
||||
before_state="exists",
|
||||
after_state="gone",
|
||||
)
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
self.mock_api.return_value = {}
|
||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
|
||||
|
||||
class TestTaskCapabilityMap(unittest.TestCase):
|
||||
def test_reconciliation_cleanup_maps_delete_permission(self):
|
||||
self.assertEqual(
|
||||
required_permission("reconciliation_cleanup"),
|
||||
"gitea.branch.delete",
|
||||
)
|
||||
self.assertEqual(required_role("reconciliation_cleanup"), "author")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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):
|
||||
|
||||
@@ -22,6 +22,7 @@ from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server # noqa: E402
|
||||
from mcp_server import ( # noqa: E402
|
||||
gitea_check_pr_eligibility,
|
||||
gitea_merge_pr,
|
||||
|
||||
@@ -87,6 +87,14 @@ def test_reconcile_landed_workflow_contract():
|
||||
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
|
||||
assert "RECOVERY_HANDOFF_ONLY" in text
|
||||
assert "resolve_partial_reconciliation_plan" in text
|
||||
assert "check_audit_mutation_allowed" in text
|
||||
assert "gitea_authorize_reconciliation_cleanup_phase" in text
|
||||
|
||||
|
||||
def test_audit_reconciliation_verifier_exported():
|
||||
from review_proofs import assess_audit_reconciliation_report
|
||||
|
||||
assert callable(assess_audit_reconciliation_report)
|
||||
|
||||
|
||||
def test_create_issue_workflow_contract():
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import issue_lock_store
|
||||
import mcp_server
|
||||
from mcp_server import gitea_create_pr, gitea_lock_issue
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
|
||||
ISSUE_WRITE_ENV = {
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
}
|
||||
|
||||
CREATE_PR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "author-test",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||
),
|
||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _clean_master_git_state_for_lock():
|
||||
return {
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "",
|
||||
"base_equivalent": True,
|
||||
"inspected_git_root": "/scratch/wt",
|
||||
"base_branch": "origin/master",
|
||||
}
|
||||
|
||||
|
||||
def _registered_tool_names() -> set[str]:
|
||||
manager = mcp_server.mcp._tool_manager
|
||||
tools = getattr(manager, "_tools", None) or {}
|
||||
return set(tools.keys())
|
||||
|
||||
|
||||
class TestLockIssueMcpRegistration(unittest.TestCase):
|
||||
def test_gitea_lock_issue_registered_as_public_mcp_tool(self):
|
||||
names = _registered_tool_names()
|
||||
self.assertIn("gitea_lock_issue", names)
|
||||
|
||||
def test_internal_list_open_pulls_not_exposed_as_mcp_tool(self):
|
||||
names = _registered_tool_names()
|
||||
self.assertNotIn("_list_open_pulls", names)
|
||||
|
||||
|
||||
class TestCreatePrLockRegistrationFlow(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
self._env_patcher = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
**CREATE_PR_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
self._env_patcher.start()
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._env_patcher.stop()
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_still_fails_closed_without_issue_lock(self, _auth, _role):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: X Closes #521",
|
||||
head="feat/issue-521-lock-issue-registration",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertIn("Issue lock is missing", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_proceeds_after_valid_issue_lock(
|
||||
self, _auth, _role, _api, _git_state, mock_api_request
|
||||
):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"}
|
||||
lock_res = gitea_lock_issue(
|
||||
issue_number=521,
|
||||
branch_name="feat/issue-521-lock-issue-registration",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
self.assertTrue(lock_res["success"])
|
||||
lock_path = lock_res["lock_file_path"]
|
||||
self.assertTrue(os.path.exists(lock_path))
|
||||
lock = issue_lock_store.read_lock_file(lock_path)
|
||||
self.assertEqual(lock["issue_number"], 521)
|
||||
|
||||
res = gitea_create_pr(
|
||||
title="fix: restore lock tool registration Closes #521",
|
||||
head="feat/issue-521-lock-issue-registration",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
self.assertEqual(res["number"], 521)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+190
-10
@@ -58,6 +58,12 @@ _NO_BLOCKER_FEEDBACK = {
|
||||
}
|
||||
|
||||
|
||||
def _init_reviewer_session(remote="prgs"):
|
||||
"""Seed review decision lock and required workflow-load proof (#389)."""
|
||||
init_review_decision_lock(remote, "review_pr")
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
|
||||
def _mark_request_changes_ready(pr_number=8, **kwargs):
|
||||
"""Mark a request_changes decision ready with the #332 duplicate-
|
||||
suppression feedback fetch stubbed to 'no existing blocker'."""
|
||||
@@ -121,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()
|
||||
@@ -129,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=[
|
||||
@@ -179,6 +190,7 @@ def _seed_ready_review_decision(
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
})
|
||||
_m.gitea_load_review_workflow()
|
||||
|
||||
|
||||
# Issue-write tools are profile-gated (#69).
|
||||
@@ -671,6 +683,7 @@ class TestMergePR(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import reviewer_pr_lease
|
||||
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||
self._lease_patch.start()
|
||||
self._auth_identity_patch = patch(
|
||||
@@ -1049,7 +1062,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), 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"))
|
||||
@@ -1876,7 +1890,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import reviewer_pr_lease
|
||||
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
_init_reviewer_session("prgs")
|
||||
self._lease_patch = _install_owned_reviewer_lease(
|
||||
self.PR, head_sha=self.SHA,
|
||||
)
|
||||
@@ -1996,7 +2010,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import reviewer_pr_lease
|
||||
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
_init_reviewer_session("prgs")
|
||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||
self._lease_patch.start()
|
||||
self._auth_identity_patch = patch(
|
||||
@@ -2416,7 +2430,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
os.remove(spoof_path)
|
||||
|
||||
def test_mark_final_decision_rejects_remote_mismatch(self):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
_init_reviewer_session("prgs")
|
||||
r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools", expected_head_sha="abc123")
|
||||
self.assertFalse(r["marked_ready"])
|
||||
self.assertTrue(any("does not match locked remote" in x for x in r["reasons"]))
|
||||
@@ -2500,6 +2514,7 @@ if __name__ == "__main__":
|
||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
@@ -3879,7 +3894,7 @@ class TestPreflightVerification(unittest.TestCase):
|
||||
os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n"
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
|
||||
self.assertIn("forbidden from modifying tracked workspace files", str(ctx.exception))
|
||||
self.assertIn("reviewer_edit.py", str(ctx.exception))
|
||||
|
||||
# Foreign pre-existing dirty state does not block when unchanged.
|
||||
@@ -3945,7 +3960,172 @@ class TestPreflightVerification(unittest.TestCase):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(worktree_path=worktree)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("active task workspace root", msg)
|
||||
self.assertIn("inspected git root", msg)
|
||||
self.assertIn("dirty files: task_file.py", msg)
|
||||
self.assertIn("dirty scope:", msg)
|
||||
self.assertIn("resolved workspace", msg)
|
||||
self.assertIn(worktree, msg)
|
||||
self.assertIn("worktree_path argument", msg)
|
||||
self.assertIn("task_file.py", msg)
|
||||
self.assertIn("author namespace", msg)
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for namespace-scoped MCP workspace binding (#510)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if REPO_ROOT.parent.name == "branches":
|
||||
CONTROL_ROOT = str(REPO_ROOT.parent.parent)
|
||||
else:
|
||||
CONTROL_ROOT = str(REPO_ROOT)
|
||||
|
||||
AUTHOR_DIRTY = f"{CONTROL_ROOT}/branches/mcp-author-worktree"
|
||||
MERGER_CLEAN = f"{CONTROL_ROOT}/branches/merge-pr487-submit"
|
||||
REVIEWER_CLEAN = f"{CONTROL_ROOT}/branches/review-pr487-submit"
|
||||
RECONCILER_CLEAN = f"{CONTROL_ROOT}/branches/reconcile-pr487"
|
||||
MCP_PROCESS_ROOT = CONTROL_ROOT
|
||||
|
||||
|
||||
class TestNamespaceWorkspaceModule(unittest.TestCase):
|
||||
def test_author_env_ignored_for_merger_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="merger",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.MERGER_WORKTREE_ENV: MERGER_CLEAN,
|
||||
},
|
||||
profile_name="gitea-merger",
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(MERGER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.MERGER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_author_env_ignored_for_reviewer_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.REVIEWER_WORKTREE_ENV: REVIEWER_CLEAN,
|
||||
},
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(REVIEWER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.REVIEWER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_author_env_ignored_for_reconciler_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reconciler",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.RECONCILER_WORKTREE_ENV: RECONCILER_CLEAN,
|
||||
},
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(RECONCILER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.RECONCILER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_merger_profile_maps_to_merger_namespace(self):
|
||||
role = nwb.normalize_role_kind("reviewer", profile_name="gitea-merger")
|
||||
self.assertEqual(role, "merger")
|
||||
|
||||
def test_error_message_includes_path_and_binding_source(self):
|
||||
msg = nwb.format_namespace_workspace_binding_error(
|
||||
role_kind="merger",
|
||||
workspace_path=AUTHOR_DIRTY,
|
||||
binding_source=f"{nwb.AUTHOR_WORKTREE_ENV} environment variable",
|
||||
dirty_files=["gitea_mcp_server.py"],
|
||||
ignored_bindings=[f"{nwb.AUTHOR_WORKTREE_ENV}={AUTHOR_DIRTY} (ignored for merger namespace)"],
|
||||
)
|
||||
self.assertIn(AUTHOR_DIRTY, msg)
|
||||
self.assertIn("via", msg.lower())
|
||||
self.assertIn(nwb.AUTHOR_WORKTREE_ENV, msg)
|
||||
self.assertIn("Do not clean or reset foreign role worktrees", msg)
|
||||
|
||||
|
||||
class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._saved = {
|
||||
"whoami_called": srv._preflight_whoami_called,
|
||||
"capability_called": srv._preflight_capability_called,
|
||||
"resolved_role": srv._preflight_resolved_role,
|
||||
"whoami_violation": srv._preflight_whoami_violation,
|
||||
"capability_violation": srv._preflight_capability_violation,
|
||||
"in_test": srv._preflight_in_test_mode,
|
||||
}
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
|
||||
self._env_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_whoami_called = self._saved["whoami_called"]
|
||||
srv._preflight_capability_called = self._saved["capability_called"]
|
||||
srv._preflight_resolved_role = self._saved["resolved_role"]
|
||||
srv._preflight_whoami_violation = self._saved["whoami_violation"]
|
||||
srv._preflight_capability_violation = self._saved["capability_violation"]
|
||||
srv._preflight_in_test_mode = self._saved["in_test"]
|
||||
self._env_patch.stop()
|
||||
for key in (
|
||||
nwb.AUTHOR_WORKTREE_ENV,
|
||||
nwb.MERGER_WORKTREE_ENV,
|
||||
nwb.REVIEWER_WORKTREE_ENV,
|
||||
nwb.RECONCILER_WORKTREE_ENV,
|
||||
nwb.ACTIVE_WORKTREE_ENV,
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def _merger_profile(self):
|
||||
return {
|
||||
"profile_name": "gitea-merger",
|
||||
"allowed_operations": ["gitea.pr.merge", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
|
||||
def _reviewer_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.pr.approve", "gitea.pr.review", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
|
||||
def _reconciler_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": ["gitea.pr.close", "gitea.read"],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
],
|
||||
}
|
||||
|
||||
def _author_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"],
|
||||
}
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_merger_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_reviewer_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.REVIEWER_WORKTREE_ENV] = REVIEWER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reviewer_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=REVIEWER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_reconciler_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.RECONCILER_WORKTREE_ENV] = RECONCILER_CLEAN
|
||||
srv._preflight_resolved_role = "reconciler"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reconciler_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=RECONCILER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_active_task_workspace_still_blocks_mutations(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
dirty = " M namespace_workspace_binding.py\n"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
with mock.patch("gitea_mcp_server._get_workspace_porcelain", return_value=dirty):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
||||
self.assertIn(MERGER_CLEAN, str(ctx.exception))
|
||||
self.assertIn("binding", str(ctx.exception).lower())
|
||||
|
||||
def test_root_workspace_mutation_still_blocked_for_author(self):
|
||||
srv._preflight_resolved_role = "author"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._author_profile()):
|
||||
with mock.patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={"current_branch": "master"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_pr487_style_merge_binds_clean_merger_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
resolved = srv._verify_role_mutation_workspace("prgs")
|
||||
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
|
||||
@@ -95,10 +95,15 @@ 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()
|
||||
gitea_config._active_profile_override = None
|
||||
self._dir.cleanup()
|
||||
|
||||
@@ -250,6 +255,8 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
||||
return {"login": "author-user"}
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_merge_pr(
|
||||
pr_number=42, confirmation="MERGE PR 42",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Post-merge moot reviewer-lease handling (#515).
|
||||
|
||||
Covers:
|
||||
a. Reviewer-lease acquisition/adoption is refused on an already-merged/closed
|
||||
PR (fail closed, no mutation).
|
||||
b. The post-merge moot cleanup path is safe and idempotent.
|
||||
c. An active foreign lease on an *open* PR is never force-cleaned.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import reviewer_pr_lease as leases # noqa: E402
|
||||
from mcp_server import ( # noqa: E402
|
||||
gitea_acquire_reviewer_pr_lease,
|
||||
gitea_cleanup_post_merge_moot_lease,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
MERGER_ENV = {
|
||||
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
||||
}
|
||||
PR = 487
|
||||
ISSUE = 485
|
||||
SESSION = "97274-676d20a825c4"
|
||||
|
||||
|
||||
def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed",
|
||||
candidate_head="a" * 40):
|
||||
body = leases.format_lease_body(
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
pr_number=pr_number,
|
||||
issue_number=ISSUE,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id=session_id,
|
||||
worktree="branches/review-pr487",
|
||||
phase=phase,
|
||||
candidate_head=candidate_head,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
last_activity=datetime.now(timezone.utc),
|
||||
)
|
||||
return {"id": 6603, "body": body, "user": {"login": "sysadmin"}}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pure logic
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestAcquireRefusedOnMergedPR(unittest.TestCase):
|
||||
def test_acquire_refused_when_pr_merged_or_closed(self):
|
||||
result = leases.assess_acquire_lease(
|
||||
[_lease_comment()],
|
||||
pr_number=PR,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-merger",
|
||||
session_id="new-session",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=ISSUE,
|
||||
worktree="branches/merge-pr487",
|
||||
candidate_head="a" * 40,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
pr_merged_or_closed=True,
|
||||
)
|
||||
self.assertFalse(result["acquire_allowed"])
|
||||
self.assertTrue(result["post_merge_moot"])
|
||||
self.assertIsNone(result["lease_body"])
|
||||
self.assertTrue(any("post_merge_moot" in r for r in result["reasons"]))
|
||||
|
||||
def test_acquire_still_allowed_on_open_pr_without_flag(self):
|
||||
result = leases.assess_acquire_lease(
|
||||
[],
|
||||
pr_number=PR,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id="s1",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=ISSUE,
|
||||
worktree="branches/review-pr487",
|
||||
candidate_head="a" * 40,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
)
|
||||
self.assertTrue(result["acquire_allowed"])
|
||||
self.assertFalse(result["post_merge_moot"])
|
||||
|
||||
|
||||
class TestPostMergeMootAssessment(unittest.TestCase):
|
||||
def test_merged_pr_with_active_lease_is_moot_and_cleanable(self):
|
||||
a = leases.assess_post_merge_moot_lease(
|
||||
[_lease_comment()],
|
||||
pr_number=PR,
|
||||
pr_merged=True,
|
||||
pr_state="closed",
|
||||
merge_commit_sha="c" * 40,
|
||||
)
|
||||
self.assertTrue(a["pr_merged_or_closed"])
|
||||
self.assertTrue(a["is_moot"])
|
||||
self.assertTrue(a["cleanup_allowed"])
|
||||
self.assertIsNotNone(a["release_body"])
|
||||
self.assertIn("phase: released", a["release_body"])
|
||||
self.assertIn("blocker: post-merge-moot", a["release_body"])
|
||||
|
||||
def test_open_pr_active_lease_never_cleaned(self):
|
||||
a = leases.assess_post_merge_moot_lease(
|
||||
[_lease_comment()],
|
||||
pr_number=PR,
|
||||
pr_merged=False,
|
||||
pr_state="open",
|
||||
)
|
||||
self.assertFalse(a["pr_merged_or_closed"])
|
||||
self.assertFalse(a["is_moot"])
|
||||
self.assertFalse(a["cleanup_allowed"])
|
||||
self.assertIsNone(a["release_body"])
|
||||
self.assertTrue(any("still open" in r for r in a["reasons"]))
|
||||
|
||||
def test_merged_pr_without_lease_nothing_to_clean(self):
|
||||
a = leases.assess_post_merge_moot_lease(
|
||||
[], pr_number=PR, pr_merged=True, pr_state="closed")
|
||||
self.assertTrue(a["pr_merged_or_closed"])
|
||||
self.assertFalse(a["is_moot"])
|
||||
self.assertFalse(a["cleanup_allowed"])
|
||||
self.assertTrue(any("nothing to clean" in r for r in a["reasons"]))
|
||||
|
||||
def test_cleanup_is_idempotent(self):
|
||||
"""After the released marker is posted, a re-assess finds nothing to clean."""
|
||||
first = leases.assess_post_merge_moot_lease(
|
||||
[_lease_comment()], pr_number=PR, pr_merged=True, pr_state="closed")
|
||||
self.assertTrue(first["cleanup_allowed"])
|
||||
released_comment = {
|
||||
"id": 7000, "body": first["release_body"], "user": {"login": "sysadmin"}}
|
||||
second = leases.assess_post_merge_moot_lease(
|
||||
[_lease_comment(), released_comment],
|
||||
pr_number=PR, pr_merged=True, pr_state="closed")
|
||||
self.assertFalse(second["is_moot"])
|
||||
self.assertFalse(second["cleanup_allowed"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Server tools
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999):
|
||||
"""Build an api_request side effect keyed on method + url."""
|
||||
calls = {"post": []}
|
||||
|
||||
def _side(method, url, auth=None, payload=None, *a, **k):
|
||||
m = (method or "").upper()
|
||||
if m == "POST":
|
||||
calls["post"].append({"url": url, "payload": payload})
|
||||
return {"id": posted_id}
|
||||
if "/comments" in url:
|
||||
return list(comments)
|
||||
if "/pulls/" in url:
|
||||
pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40}
|
||||
if pr_merged:
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-07-08T07:46:04Z"
|
||||
return pr
|
||||
if "/issues/" in url:
|
||||
return {"state": "closed" if pr_merged else "open", "number": ISSUE}
|
||||
return {}
|
||||
|
||||
return _side, calls
|
||||
|
||||
|
||||
class TestAcquireToolRefusesMergedPR(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||
@patch("mcp_server._authenticated_username", return_value="sysadmin")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.api_request")
|
||||
def test_acquire_tool_fails_closed_on_merged_pr_without_posting(
|
||||
self, mock_api, _auth, _user, _purity):
|
||||
side, calls = _api_side_effect(
|
||||
pr_state="closed", pr_merged=True, comments=[])
|
||||
mock_api.side_effect = side
|
||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||
result = gitea_acquire_reviewer_pr_lease(
|
||||
pr_number=PR,
|
||||
worktree="branches/merge-pr487",
|
||||
candidate_head="a" * 40,
|
||||
issue_number=ISSUE,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["acquired"])
|
||||
self.assertTrue(result.get("post_merge_moot"))
|
||||
self.assertEqual(calls["post"], [], "must not post a lease comment")
|
||||
|
||||
|
||||
class TestCleanupTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.api_request")
|
||||
def test_read_only_reports_moot_without_mutating(self, mock_api, _auth):
|
||||
side, calls = _api_side_effect(
|
||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||
mock_api.side_effect = side
|
||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||
result = gitea_cleanup_post_merge_moot_lease(
|
||||
pr_number=PR, apply=False, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["pr_merged_or_closed"])
|
||||
self.assertTrue(result["lease_moot"])
|
||||
self.assertFalse(result["cleanup_performed"])
|
||||
self.assertTrue(result["no_merge_or_adoption"])
|
||||
self.assertEqual(result["mode"], "read_only")
|
||||
self.assertEqual(calls["post"], [])
|
||||
|
||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.api_request")
|
||||
def test_apply_posts_released_marker_on_merged_pr(
|
||||
self, mock_api, _auth, _purity):
|
||||
side, calls = _api_side_effect(
|
||||
pr_state="closed", pr_merged=True, comments=[_lease_comment()])
|
||||
mock_api.side_effect = side
|
||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||
result = gitea_cleanup_post_merge_moot_lease(
|
||||
pr_number=PR, apply=True, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["cleanup_performed"])
|
||||
self.assertEqual(result["released_comment_id"], 9999)
|
||||
self.assertEqual(len(calls["post"]), 1)
|
||||
self.assertIn("phase: released", calls["post"][0]["payload"]["body"])
|
||||
self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"])
|
||||
|
||||
@patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.api_request")
|
||||
def test_apply_refuses_to_clean_open_pr(self, mock_api, _auth, _purity):
|
||||
side, calls = _api_side_effect(
|
||||
pr_state="open", pr_merged=False, comments=[_lease_comment()])
|
||||
mock_api.side_effect = side
|
||||
with patch.dict(os.environ, MERGER_ENV, clear=True):
|
||||
result = gitea_cleanup_post_merge_moot_lease(
|
||||
pr_number=PR, apply=True, remote="prgs")
|
||||
self.assertFalse(result["cleanup_performed"])
|
||||
self.assertFalse(result["pr_merged_or_closed"])
|
||||
self.assertEqual(calls["post"], [], "never force-clean an open PR lease")
|
||||
self.assertTrue(
|
||||
any("still open" in r for r in result.get("cleanup_skipped_reason", [])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ from mcp_server import (
|
||||
gitea_view_pr,
|
||||
gitea_review_pr,
|
||||
gitea_check_pr_eligibility,
|
||||
gitea_load_review_workflow,
|
||||
)
|
||||
import gitea_config
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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,153 @@
|
||||
"""Tests for canonical review workflow load proof (#389)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import review_workflow_load
|
||||
import mcp_server
|
||||
|
||||
|
||||
class TestReviewWorkflowLoadModule(unittest.TestCase):
|
||||
def setUp(self):
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
|
||||
def test_load_records_hash_and_schema(self):
|
||||
root = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
||||
recorded = review_workflow_load.record_review_workflow_load(root)
|
||||
self.assertEqual(
|
||||
recorded["workflow_source"],
|
||||
review_workflow_load.WORKFLOW_REL_PATH,
|
||||
)
|
||||
self.assertEqual(recorded["task_mode"], "review-merge-pr")
|
||||
self.assertRegex(recorded["workflow_hash"], r"^[0-9a-f]{12}$")
|
||||
self.assertEqual(
|
||||
recorded["final_report_schema_path"],
|
||||
review_workflow_load.SCHEMA_REL_PATH,
|
||||
)
|
||||
status = review_workflow_load.workflow_load_status(root)
|
||||
self.assertTrue(status["workflow_load_proof_present"])
|
||||
self.assertTrue(status["workflow_load_valid"])
|
||||
|
||||
def test_stale_session_pid_blocks(self):
|
||||
root = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
||||
review_workflow_load.record_review_workflow_load(root)
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD["session_pid"] = 0
|
||||
blockers = review_workflow_load.review_workflow_load_blockers(root)
|
||||
self.assertTrue(any("different process" in b for b in blockers))
|
||||
|
||||
def test_prompt_conflict_detected(self):
|
||||
conflict, reasons = review_workflow_load.assess_prompt_conflict(
|
||||
"Run work-issue author implementation only")
|
||||
self.assertTrue(conflict)
|
||||
self.assertTrue(reasons)
|
||||
|
||||
|
||||
class TestReviewWorkflowLoadGates(unittest.TestCase):
|
||||
def setUp(self):
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", "reviewer")
|
||||
|
||||
def _load_workflow(self):
|
||||
return mcp_server.gitea_load_review_workflow()
|
||||
|
||||
def test_mcp_helper_returns_required_fields(self):
|
||||
res = self._load_workflow()
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["loaded"])
|
||||
self.assertIn("workflow_source", res)
|
||||
self.assertIn("workflow_hash", res)
|
||||
self.assertIn("final_report_schema_path", res)
|
||||
self.assertIn("final_report_schema_hash", res)
|
||||
|
||||
def test_mark_final_blocked_without_load(self):
|
||||
res = mcp_server.gitea_mark_final_review_decision(
|
||||
42, "approve", remote="prgs")
|
||||
self.assertFalse(res["marked_ready"])
|
||||
self.assertTrue(any(
|
||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
||||
self.assertTrue(any(
|
||||
"approve/merge replay" in r.lower() or "Do not call" in r
|
||||
for r in res["reasons"]))
|
||||
|
||||
def test_submit_review_blocked_without_load(self):
|
||||
with patch("mcp_server.gitea_check_pr_eligibility") as elig:
|
||||
elig.return_value = {
|
||||
"eligible": True,
|
||||
"authenticated_user": "rev",
|
||||
"profile_name": "prgs-reviewer",
|
||||
"pr_author": "author",
|
||||
"head_sha": "abc123",
|
||||
"reasons": [],
|
||||
}
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
42,
|
||||
"approve",
|
||||
remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertTrue(any(
|
||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
||||
|
||||
def test_merge_blocked_without_load(self):
|
||||
res = mcp_server.gitea_merge_pr(
|
||||
42,
|
||||
confirmation="MERGE PR 42",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertTrue(any(
|
||||
"gitea_load_review_workflow" in r for r in res["reasons"]))
|
||||
|
||||
def test_resolve_capability_reports_missing_load(self):
|
||||
with patch.object(mcp_server, "_ensure_matching_profile"):
|
||||
with patch.object(
|
||||
mcp_server.gitea_config, "is_runtime_switching_enabled",
|
||||
return_value=False):
|
||||
with patch.object(
|
||||
mcp_server, "_authenticated_username",
|
||||
return_value="rev"):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
"review_pr", remote="prgs")
|
||||
proof = res.get("workflow_load_proof") or {}
|
||||
self.assertFalse(proof.get("workflow_load_valid"))
|
||||
self.assertTrue(any(
|
||||
"gitea_load_review_workflow" in g
|
||||
for g in res.get("task_role_guidance") or []))
|
||||
|
||||
def test_init_review_lock_clears_prior_load(self):
|
||||
self._load_workflow()
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
blockers = review_workflow_load.review_workflow_load_blockers(
|
||||
str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
self.assertTrue(blockers)
|
||||
|
||||
def test_dry_run_allowed_without_load(self):
|
||||
with patch("mcp_server.get_auth_header", return_value="Basic dGVzdA=="), \
|
||||
patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
||||
patch("mcp_server.gitea_check_pr_eligibility") as elig:
|
||||
elig.return_value = {
|
||||
"eligible": True,
|
||||
"authenticated_user": "rev",
|
||||
"profile_name": "prgs-reviewer",
|
||||
"pr_author": "author",
|
||||
"head_sha": "abc123",
|
||||
"reasons": [],
|
||||
}
|
||||
res = mcp_server.gitea_dry_run_pr_review(
|
||||
42, "approve", remote="prgs")
|
||||
self.assertNotIn(
|
||||
"gitea_load_review_workflow",
|
||||
" ".join(res.get("reasons") or []),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -36,7 +36,11 @@ 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,
|
||||
@@ -48,6 +52,7 @@ RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
|
||||
class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_no_lock_no_reasons(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
@@ -92,7 +97,10 @@ 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()
|
||||
|
||||
def test_merge_blocked_after_request_changes(self):
|
||||
_seed([RC_A])
|
||||
@@ -113,7 +121,10 @@ 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()
|
||||
|
||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||
_seed([RC_A])
|
||||
@@ -145,7 +156,10 @@ 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()
|
||||
|
||||
def test_duplicate_request_changes_blocked_at_same_head(self):
|
||||
_seed()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user