Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9f1f2940 | ||
|
|
719de6fb7a | ||
|
|
0ee89fe347 | ||
|
|
4bcdd350f6 | ||
|
|
565363d5f4 | ||
|
|
9a2e585a9e | ||
|
|
81822da04d | ||
|
|
b7e0c537f3 | ||
|
|
2a3d30fb3a | ||
|
|
1ffa911dbc | ||
|
|
5ad764b359 | ||
|
|
0e791325f8 | ||
|
|
826d632173 | ||
|
|
9d86fc44e4 | ||
|
|
8db26c9a15 | ||
|
|
ae9ed8a908 | ||
|
|
f1449ff5c8 | ||
|
|
08bcc03f17 | ||
|
|
6e589e73ea | ||
|
|
8c171fb47b | ||
|
|
18e7d7b6e1 | ||
|
|
bd22135906 | ||
|
|
78fce0dbb6 | ||
|
|
fd2bc018ff | ||
|
|
535da1bc5a | ||
|
|
dfbee45309 | ||
|
|
a4e014e62b | ||
|
|
eeadb1bbea | ||
|
|
c64809134f | ||
|
|
749ca04388 | ||
|
|
ea51b0196a | ||
|
|
cc63ab74e6 | ||
|
|
9438855fdd | ||
|
|
cfce823dd7 | ||
|
|
7231f27c1c | ||
|
|
e27b6ddefb | ||
|
|
cefebd7643 | ||
|
|
3d540f6fba | ||
|
|
2bb45c0f80 | ||
|
|
0b62d4e06a | ||
|
|
7d5c7df82f | ||
|
|
08ea214526 | ||
|
|
6b45212659 | ||
|
|
ccdff1df27 | ||
|
|
af938064cd | ||
|
|
2faf18a802 | ||
|
|
236c8b779f | ||
|
|
564fee9639 | ||
|
|
d56baa635e | ||
|
|
4561d7ad12 | ||
|
|
182fcbf598 | ||
|
|
33d01ba65e | ||
|
|
cedf451a2b | ||
|
|
be592d6d4d | ||
|
|
673cb77004 | ||
|
|
685e627984 | ||
|
|
831183308d | ||
|
|
b15494deb9 | ||
|
|
2c47208b0d | ||
|
|
551ee7d70c | ||
|
|
ddaf380db2 | ||
|
|
8b551c565f | ||
|
|
66d37dd3cb | ||
|
|
09e83fd034 | ||
|
|
5512ba7710 | ||
|
|
0d41760ea3 | ||
|
|
b9e5cd0f57 | ||
|
|
8a8cffbdf5 |
@@ -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, []
|
||||
@@ -0,0 +1,190 @@
|
||||
# Bootstrap Review Path for Self-Hosted MCP Workflow Fixes (#557)
|
||||
|
||||
## Purpose
|
||||
|
||||
Gitea-Tools MCP workflow fixes can create a **bootstrap deadlock**: the live
|
||||
MCP daemons still run the broken code from `master`, so canonical reviewer /
|
||||
merger tools cannot complete the review that would land the fix.
|
||||
|
||||
This document defines a **narrow, controller-authorized bootstrap path** so
|
||||
such fixes can land without weakening normal review/merge gates.
|
||||
|
||||
It is **not** a general bypass. Normal PRs must still use the full
|
||||
reviewer → merger MCP workflow.
|
||||
|
||||
## When this path applies
|
||||
|
||||
All of the following must be true:
|
||||
|
||||
1. The PR changes **Gitea-Tools MCP workflow / preflight / lease / gate** code
|
||||
that the live daemon must load to review itself (self-hosted control plane).
|
||||
2. Canonical review or merge tools **fail closed** because of that defect
|
||||
(documented tool errors, not “inconvenience”).
|
||||
3. A **controller** records an explicit bootstrap authorization on the PR or
|
||||
linked issue (see below).
|
||||
4. The PR source is clean, testable, and free of unrelated scope.
|
||||
|
||||
Example (historical): PR #553 / Issue #546 (reviewer preflight capability/lease
|
||||
deadlock). Live daemons on unpatched `master` could not complete canonical
|
||||
review of the fix PR.
|
||||
|
||||
## Durable authorization (required)
|
||||
|
||||
**No manual remote merge, force-merge, or API merge of a bootstrap PR is
|
||||
allowed** unless a controller has posted a durable authorization record on
|
||||
Gitea.
|
||||
|
||||
### Authorization record (issue or PR comment)
|
||||
|
||||
The controller comment **must** include a machine-readable marker and the
|
||||
fields below:
|
||||
|
||||
```text
|
||||
## BOOTSTRAP REVIEW AUTHORIZATION (#557)
|
||||
|
||||
Status: APPROVED
|
||||
PR: <number>
|
||||
Issue: <number>
|
||||
Controller: <gitea username>
|
||||
Reason: live MCP runtime cannot canonically review this self-hosted workflow fix
|
||||
Scope: narrow bootstrap only — does not weaken normal PR gates
|
||||
|
||||
Validation evidence:
|
||||
- git diff --check: clean
|
||||
- targeted pytest: <command> → pass
|
||||
- full suite attribution (if non-zero): baseline compared to prgs/master
|
||||
|
||||
Duplicate-PR audit: <none | list and disposition>
|
||||
Mutation ledger audit: <summary or path to proof>
|
||||
Contamination audit: <clean | list contaminated comment/lease IDs and disposition>
|
||||
|
||||
Allowed verification actions used: <list>
|
||||
Forbidden actions NOT used: root checkout edits; raw curl mutation; direct
|
||||
module import of gitea_mcp_server for mutations; in-memory gate restoration
|
||||
|
||||
Post-land plan:
|
||||
- restart MCP daemons for author/reviewer/merger/reconciler profiles
|
||||
- re-verify with canonical tools (see Post-bootstrap steps)
|
||||
```
|
||||
|
||||
Without this record, bootstrap merge is **forbidden**. Agents must stop with
|
||||
`BLOCKED + DIAGNOSE` rather than improvise a merge.
|
||||
|
||||
## Review gates and proof (still required)
|
||||
|
||||
Bootstrap does **not** skip technical review. It only allows verification and
|
||||
landing when the **live MCP mutation path** cannot complete the review.
|
||||
|
||||
Before authorization, the controller (or a delegated read-only verifier in an
|
||||
isolated worktree) must have:
|
||||
|
||||
| Gate | Requirement |
|
||||
|------|-------------|
|
||||
| Diff hygiene | `git diff --check` clean on the PR tip vs `prgs/master` |
|
||||
| Tests | Targeted tests for the fix pass; full suite either green or failures attributed to baseline `prgs/master` |
|
||||
| Duplicate PR | Open-PR inventory shows no competing open PR for the same issue (or supersession is documented) |
|
||||
| Mutation ledger | Any claimed mutations are ledger-consistent; no silent root edits |
|
||||
| Contamination audit | PR/issue comments and leases classified as clean vs workflow-contaminated |
|
||||
| Scope | Diff limited to the bootstrap issue; no drive-by refactors |
|
||||
|
||||
### Contamination audit (comments / leases)
|
||||
|
||||
Comments or leases created via **raw curl**, **direct Python import of MCP
|
||||
modules**, or **token extraction** are **workflow-contaminated**. They must be
|
||||
listed and must **not** be treated as canonical review proof.
|
||||
|
||||
Only comments posted via **sanctioned MCP reviewer tools** (after the fix is
|
||||
landed and daemons restarted, or during a clean path that did not bypass gates)
|
||||
count as canonical review state.
|
||||
|
||||
Historical example on PR #553:
|
||||
|
||||
| Comment | Disposition |
|
||||
|---------|-------------|
|
||||
| #7247 test / raw API | contaminated |
|
||||
| #7251 lease metadata / raw API | contaminated |
|
||||
| #7252 lease metadata / direct import | contaminated |
|
||||
| #7265 lease metadata / MCP reviewer tools | workflow-clean |
|
||||
|
||||
## Allowed verification actions
|
||||
|
||||
These may run **outside** the broken live mutation path to establish facts:
|
||||
|
||||
- Read-only MCP tools (`gitea_view_pr`, `gitea_list_prs`, `gitea_whoami`,
|
||||
`gitea_get_profile`, inventory, eligibility **reads**).
|
||||
- Isolated `branches/` worktree checkout of the PR head (never root).
|
||||
- `git fetch`, `git log`, `git diff`, `git merge-tree` (read-only analysis).
|
||||
- Targeted `pytest` / `py_compile` inside that worktree.
|
||||
- Controller posting of the bootstrap authorization comment via a healthy
|
||||
MCP profile **if available**; if comment mutation is also deadlocked, a
|
||||
**human operator** posts the authorization in the Gitea UI (still durable on
|
||||
the thread).
|
||||
|
||||
## Forbidden actions (always)
|
||||
|
||||
Even under bootstrap:
|
||||
|
||||
- Editing or committing in the **project root / control checkout**.
|
||||
- Treating root as a worktree for implementation or review mutations.
|
||||
- Raw `curl` / REST mutation with extracted tokens as a normal workflow.
|
||||
- `import gitea_mcp_server` (or sibling modules) from a shell to call mutation
|
||||
helpers and bypass preflight.
|
||||
- In-memory restoration of capability / lease / decision state to “finish”
|
||||
a blocked chain.
|
||||
- Force-push, history rewrite, or deleting evidence comments.
|
||||
- Weakening normal gates in code “temporarily” without a tracked issue/PR.
|
||||
- Self-review or self-merge by the PR author identity.
|
||||
|
||||
## Landing procedure (after APPROVED authorization)
|
||||
|
||||
1. Confirm the authorization record is present and complete on the PR or issue.
|
||||
2. Merge **only** with an independent merger identity when possible.
|
||||
- Preferred: restart/replace the MCP merger daemon with a build that includes
|
||||
the fix (or a one-shot process started from the PR worktree binary) so
|
||||
`gitea_merge_pr` can run under normal gates.
|
||||
- If the live merger daemon still cannot load the fix, a **human operator**
|
||||
may merge via the Gitea UI **only** after the authorization record exists.
|
||||
3. Never merge from contaminated proof alone.
|
||||
|
||||
## Post-bootstrap steps
|
||||
|
||||
### 1. Restart MCP daemons
|
||||
|
||||
After the fix lands on `prgs/master`:
|
||||
|
||||
1. Stop author / reviewer / merger / reconciler MCP server processes for this
|
||||
repo (IDE MCP pool + any long-lived terminals).
|
||||
2. Confirm no stale PID still serves the old code.
|
||||
3. Restart each profile so it loads the updated `master` tree (or installed
|
||||
package path).
|
||||
4. Call `gitea_whoami` / `gitea_get_profile` on each namespace and record
|
||||
profile name + identity.
|
||||
|
||||
### 2. Re-verify with canonical tools
|
||||
|
||||
Example pattern after PR #553-class fixes (lease release / #550-style follow-up):
|
||||
|
||||
1. From a **reviewer** MCP session: acquire/release or inspect the relevant
|
||||
lease with canonical tools only.
|
||||
2. Confirm no direct-import or raw-API path is required.
|
||||
3. Post a short controller or reconciler note: bootstrap complete; normal gates
|
||||
restored.
|
||||
|
||||
If verification still requires a bypass, open a new issue — do **not** extend
|
||||
this bootstrap authorization silently.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- This path does **not** authorize skipping tests, duplicate audits, or
|
||||
contamination audits.
|
||||
- This path does **not** authorize root checkout implementation work.
|
||||
- This path does **not** replace BLOCKED + DIAGNOSE when a non-bootstrap
|
||||
failure occurs (#552).
|
||||
- This path does **not** grant permanent “operator exception” culture.
|
||||
|
||||
## Related
|
||||
|
||||
- Root checkout policy: `docs/llm-workflow-runbooks.md` (Global LLM Worktree Rule, #475)
|
||||
- BLOCKED + DIAGNOSE: issue #552 / skill workflows
|
||||
- Reviewer lease / preflight deadlock class: issues #546, #548, #550
|
||||
- Durable session proofs across daemon pools: issue #559
|
||||
@@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||
|
||||
## 1. Standard test commands
|
||||
|
||||
### Canonical runner: `./run-tests.sh`
|
||||
|
||||
The canonical full-validation command is the root-level runner. It invokes the
|
||||
project virtualenv interpreter and passes any extra arguments straight through
|
||||
to `pytest`:
|
||||
|
||||
```bash
|
||||
# Full validation
|
||||
./run-tests.sh
|
||||
|
||||
# Focused validation (extra args forward to pytest)
|
||||
./run-tests.sh tests/test_mcp_server.py -q
|
||||
```
|
||||
|
||||
`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
|
||||
setup message if the virtualenv Python is missing (so a session never silently
|
||||
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
|
||||
forms below remain valid and equivalent.
|
||||
|
||||
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Controller Issue-Acceptance Gate
|
||||
|
||||
A merged PR does not automatically prove an issue is fully satisfied. After
|
||||
merge, a controller must audit the linked issue against its acceptance criteria
|
||||
and post a durable handoff before the issue is treated as complete.
|
||||
|
||||
## Workflow position
|
||||
|
||||
1. Author implements the issue and opens a PR.
|
||||
2. Reviewer reviews the PR.
|
||||
3. Merger merges the approved PR.
|
||||
4. **Controller performs issue-acceptance audit.**
|
||||
5. Controller posts a `## Controller Issue Acceptance` comment with either:
|
||||
- `STATE: accepted` and checked criteria, or
|
||||
- a rejection path (`more-work-required`, `needs-tests`, `needs-docs`, etc.)
|
||||
with `MISSING_WORK` and a paste-ready `NEXT_PROMPT`.
|
||||
|
||||
Gitea may auto-close an issue via `Closes #N` in the PR body. That closure is
|
||||
merge mechanics only. Controller acceptance is still required before any final
|
||||
report or queue controller treats the issue as complete.
|
||||
|
||||
## Template
|
||||
|
||||
Use `issue_acceptance_gate.render_controller_acceptance_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/controller-issue-acceptance.md`](../skills/llm-project-workflow/templates/controller-issue-acceptance.md).
|
||||
|
||||
## Final-report rules
|
||||
|
||||
Final reports must not claim `issue complete` solely because a PR merged.
|
||||
Either:
|
||||
|
||||
- include a valid `## Controller Issue Acceptance` block with
|
||||
`STATE: accepted`, or
|
||||
- explicitly state `controller acceptance pending` and identify the controller
|
||||
as the next actor.
|
||||
|
||||
`final_report_validator` enforces this through
|
||||
`issue_acceptance_gate.validate_final_report_issue_acceptance()`.
|
||||
|
||||
## Role boundaries
|
||||
|
||||
- Authors must not mark their own issues accepted.
|
||||
- Reviewers must not mark issue acceptance unless acting under controller
|
||||
capability.
|
||||
- Mergers merge PRs; they do not substitute for controller acceptance.
|
||||
|
||||
## Related
|
||||
|
||||
- #495 — canonical next-action comment templates
|
||||
- #496 — fail-closed canonical comment validation before posting
|
||||
- #303 — controller handoff schema for reconciliation workflows
|
||||
@@ -377,6 +377,59 @@ 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.
|
||||
|
||||
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||
|
||||
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||
runs from broken `master`, canonical review can deadlock on itself.
|
||||
|
||||
Do **not** improvise raw API, direct imports, root edits, or gate bypasses.
|
||||
|
||||
Use the narrow controller-authorized path documented in:
|
||||
|
||||
- [`docs/bootstrap-review-path.md`](bootstrap-review-path.md)
|
||||
|
||||
Hard rules:
|
||||
|
||||
- No merge without a durable `BOOTSTRAP REVIEW AUTHORIZATION (#557)` record on
|
||||
the PR or linked issue.
|
||||
- Validation, duplicate-PR, mutation-ledger, and contamination audits remain
|
||||
mandatory.
|
||||
- Root checkout stays read-only orchestration only; verification runs in
|
||||
`branches/` worktrees.
|
||||
- After land: restart MCP daemons and re-verify with canonical tools only.
|
||||
- This never weakens normal reviewer/merger gates for ordinary PRs.
|
||||
|
||||
## Shell Spawn Hard-Stop Rule
|
||||
|
||||
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
||||
@@ -641,6 +694,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.
|
||||
@@ -871,6 +951,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||
- [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision).
|
||||
|
||||
@@ -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`.
|
||||
+11
-2
@@ -46,7 +46,8 @@ Optional environment variables:
|
||||
| `/runtime` | Stub — MCP runtime health (#430) |
|
||||
| `/audit` | Stub — report audit paste (#431) |
|
||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
||||
| `/leases` | Stub — lease visibility (#433) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
| `/api/leases` | JSON lease/collision export |
|
||||
|
||||
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`.
|
||||
@@ -82,8 +83,16 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||
instead of an empty queue (fail closed).
|
||||
|
||||
## Lease visibility (#433)
|
||||
|
||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||
and duplicate local branches per issue. Links to collision-history backend
|
||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,9 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import issue_acceptance_gate
|
||||
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 +120,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 +586,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):
|
||||
@@ -922,6 +961,69 @@ def _rule_reconcile_linked_issue_live(
|
||||
return []
|
||||
|
||||
|
||||
_PR_CLOSE_NEGATIVE = frozenset({"", "none", "n/a", "na", "0", "no", "not closed", "not performed"})
|
||||
_ANCESTOR_AFFIRMATIVE_RE = re.compile(
|
||||
r"ancestor|passed|true|verified|confirmed", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _reconciler_pr_close_performed(fields: dict[str, str], lock: dict) -> bool:
|
||||
"""True when the report/session indicates a reconciler PR close happened."""
|
||||
if lock.get("pr_closed") is True:
|
||||
return True
|
||||
value = (fields.get("prs closed", "") or "").strip().lower()
|
||||
if value in _PR_CLOSE_NEGATIVE:
|
||||
return False
|
||||
# A closed PR is reported by number (e.g. "#99") or an affirmative result.
|
||||
return bool(re.search(r"#\s*\d+|\b(?:closed|success|done)\b", value))
|
||||
|
||||
|
||||
def _rule_reconcile_close_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#306: a reconciler PR close must carry exact proof fields.
|
||||
|
||||
Read-only/comment-only reconciliations are untouched. Once a PR close is
|
||||
reported (via the ``PRs closed`` field or a session close lock), the
|
||||
handoff must prove the close capability, ancestor landing, PR close
|
||||
result, and the linked-issue result — narrative alone fails closed.
|
||||
"""
|
||||
fields = _handoff_fields(report_text)
|
||||
lock = reconciler_close_lock or {}
|
||||
if not _reconciler_pr_close_performed(fields, lock):
|
||||
return []
|
||||
|
||||
missing: list[str] = []
|
||||
capabilities = fields.get("capabilities proven", "")
|
||||
if "gitea.pr.close" not in capabilities.lower():
|
||||
missing.append("close capability proof (gitea.pr.close)")
|
||||
ancestor = fields.get("ancestor proof", "")
|
||||
if not _ANCESTOR_AFFIRMATIVE_RE.search(ancestor):
|
||||
missing.append("ancestor proof")
|
||||
prs_closed = (fields.get("prs closed", "") or "").strip().lower()
|
||||
if prs_closed in _PR_CLOSE_NEGATIVE and lock.get("pr_closed") is True:
|
||||
missing.append("PR close result")
|
||||
linked = fields.get("linked issue live status", "") or fields.get("issues closed", "")
|
||||
if not linked.strip():
|
||||
missing.append("linked issue result")
|
||||
|
||||
if not missing:
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reconcile.close_proof_fields",
|
||||
"block",
|
||||
"Reconciler close proof",
|
||||
"reconciler PR close reported without required proof field(s): "
|
||||
+ ", ".join(missing),
|
||||
"include close capability proof, ancestor proof, PR close result, "
|
||||
"and linked issue result in the handoff",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_reconcile_pagination_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _INVENTORY_COMPLETE_RE.search(text):
|
||||
@@ -1008,6 +1110,22 @@ def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
||||
if not result.get("applicable") or result.get("valid"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.issue_acceptance_gate",
|
||||
result.get("reasons") or [],
|
||||
field="Controller acceptance",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"add Controller Issue Acceptance proof or state that controller "
|
||||
"acceptance is pending; do not claim issue complete from PR merge alone"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
|
||||
if result.get("proven"):
|
||||
@@ -1024,6 +1142,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,
|
||||
*,
|
||||
@@ -1043,6 +1225,22 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||
|
||||
result = assess_mutation_capability_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.mutation_capability_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Capabilities proven",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "document exact per-mutation capability proof before each mutation",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
@@ -1057,12 +1255,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,22 +1300,28 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_already_landed_eligible,
|
||||
_rule_reviewer_already_landed_state,
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_workflow_load_boundary,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_mutation_capability_proof,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"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,
|
||||
_rule_reconcile_close_proof,
|
||||
_rule_reconcile_pagination_proof,
|
||||
_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,
|
||||
@@ -1111,8 +1333,10 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_conflict_fix_push_proof,
|
||||
_rule_worktree_cleanup_audit_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
_rule_shared_controller_handoff,
|
||||
@@ -1192,6 +1416,7 @@ def assess_final_report_validator(
|
||||
issue_filing_lock: dict | None = None,
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1248,6 +1473,7 @@ def assess_final_report_validator(
|
||||
"local_edits": local_edits,
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
+1214
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
"""Controller issue-acceptance gate helpers (#500).
|
||||
|
||||
Pure validation for controller acceptance comments and final-report claims
|
||||
that an issue is complete. Does not post comments or close issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ACCEPTANCE_HEADING = "controller issue acceptance"
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"STATE",
|
||||
"WHO_IS_NEXT",
|
||||
"NEXT_ACTION",
|
||||
"NEXT_PROMPT",
|
||||
"ISSUE",
|
||||
"MERGED_PR",
|
||||
"MERGE_COMMIT",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED",
|
||||
"VALIDATION_REVIEWED",
|
||||
"CONTROLLER_DECISION",
|
||||
"WHY",
|
||||
)
|
||||
|
||||
ACCEPTED_STATES = frozenset({"accepted"})
|
||||
REJECTION_STATES = frozenset({
|
||||
"more-work-required",
|
||||
"more_work_required",
|
||||
"needs-tests",
|
||||
"needs_tests",
|
||||
"needs-docs",
|
||||
"needs_docs",
|
||||
"needs-feature-enhancement",
|
||||
"needs_feature_enhancement",
|
||||
"needs-follow-up-issue",
|
||||
"needs_follow_up_issue",
|
||||
"blocked",
|
||||
})
|
||||
|
||||
ALLOWED_NEXT_ACTORS = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_ISSUE_REF_RE = re.compile(r"#\d+")
|
||||
_PR_REF_RE = re.compile(r"#\d+")
|
||||
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
|
||||
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
|
||||
|
||||
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
|
||||
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
|
||||
r"\bissue\s+acceptance\s*:\s*accepted\b|"
|
||||
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_ONLY_COMPLETE_RE = re.compile(
|
||||
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
|
||||
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_controller_acceptance_template() -> str:
|
||||
"""Return the canonical controller issue-acceptance comment template."""
|
||||
return """## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
"""
|
||||
|
||||
|
||||
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a controller acceptance block."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
for line in (text or "").splitlines():
|
||||
match = _FIELD_RE.match(line)
|
||||
if match:
|
||||
current_key = match.group(1).strip().upper().replace(" ", "_")
|
||||
fields[current_key] = match.group(2).strip()
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if current_key and stripped:
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_acceptance_block(text: str | None) -> bool:
|
||||
return ACCEPTANCE_HEADING in (text or "").lower()
|
||||
|
||||
|
||||
def _empty_or_placeholder(value: str | None) -> bool:
|
||||
value = (value or "").strip().lower()
|
||||
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."}
|
||||
|
||||
|
||||
def _normalize_state(value: str | None) -> str:
|
||||
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def validate_controller_acceptance_comment(text: str | None) -> dict:
|
||||
"""Validate a controller issue-acceptance comment."""
|
||||
body = text or ""
|
||||
if not contains_acceptance_block(body):
|
||||
return {
|
||||
"valid": False,
|
||||
"fields": {},
|
||||
"reasons": ["missing Controller Issue Acceptance heading"],
|
||||
}
|
||||
|
||||
fields = extract_acceptance_fields(body)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required controller acceptance field: {field}")
|
||||
|
||||
state = _normalize_state(fields.get("STATE"))
|
||||
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
|
||||
reasons.append(
|
||||
"STATE must be accepted or a rejection path "
|
||||
"(more-work-required, needs-tests, needs-docs, "
|
||||
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
|
||||
)
|
||||
|
||||
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if actor and actor not in ALLOWED_NEXT_ACTORS:
|
||||
reasons.append(
|
||||
"WHO_IS_NEXT must be one of: "
|
||||
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
|
||||
)
|
||||
|
||||
if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
|
||||
reasons.append("ISSUE must cite an issue number (#N)")
|
||||
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
|
||||
reasons.append("MERGED_PR must cite a merged PR number (#N)")
|
||||
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
|
||||
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
|
||||
|
||||
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
|
||||
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
|
||||
)
|
||||
|
||||
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
|
||||
if state in ACCEPTED_STATES:
|
||||
if decision not in {"accepted", "accept"}:
|
||||
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
|
||||
if not _CHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"accepted STATE requires at least one checked acceptance criterion"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("WHY")):
|
||||
reasons.append("accepted STATE requires WHY with acceptance rationale")
|
||||
elif state in REJECTION_STATES:
|
||||
if decision not in {"rejected", "reject", "more_work_required"}:
|
||||
reasons.append(
|
||||
"rejection STATE requires CONTROLLER_DECISION: rejected"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
|
||||
reasons.append(
|
||||
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
|
||||
)
|
||||
missing = fields.get("MISSING_WORK") or ""
|
||||
if _empty_or_placeholder(missing):
|
||||
reasons.append(
|
||||
"rejection STATE requires MISSING_WORK describing what is still needed"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def claims_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text claims an issue is complete/accepted."""
|
||||
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_merge_only_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text treats PR merge as issue completion."""
|
||||
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_controller_acceptance_update(text: str | None) -> bool:
|
||||
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def notes_controller_acceptance_pending(text: str | None) -> bool:
|
||||
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
|
||||
"""Validate issue-completion and controller-acceptance claims in final reports."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
complete_claim = claims_issue_complete(text)
|
||||
merge_only = claims_merge_only_issue_complete(text)
|
||||
acceptance_claim = claims_controller_acceptance_update(text)
|
||||
pending_noted = notes_controller_acceptance_pending(text)
|
||||
has_block = contains_acceptance_block(text)
|
||||
|
||||
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
|
||||
reasons.append(
|
||||
"final report treats PR merge as issue completion without controller acceptance proof"
|
||||
)
|
||||
|
||||
if complete_claim and not pending_noted:
|
||||
if not has_block:
|
||||
reasons.append(
|
||||
"final report claims issue complete but includes no Controller Issue Acceptance block"
|
||||
)
|
||||
else:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
else:
|
||||
state = _normalize_state(result["fields"].get("STATE"))
|
||||
if state not in ACCEPTED_STATES:
|
||||
reasons.append(
|
||||
"final report claims issue complete but controller STATE is not accepted"
|
||||
)
|
||||
|
||||
if acceptance_claim and has_block:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
|
||||
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
}
|
||||
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,391 @@
|
||||
"""Durable MCP session validation state shared across daemon process pools (#559).
|
||||
|
||||
The IDE often routes sequential MCP tool calls to different daemon processes.
|
||||
Session-scoped proofs (workflow load, review decision lock) must therefore
|
||||
survive process boundaries while remaining fail-closed against spoofing.
|
||||
|
||||
Security notes (extends #211):
|
||||
- Never store under host-global ``/tmp`` (world-writable, spoofable).
|
||||
- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``).
|
||||
- Files are written atomically with mode ``0o600``.
|
||||
- Records are keyed by remote + org + repo + profile identity, not by PID.
|
||||
- TTL prevents indefinitely stale reuse across unrelated sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||
DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||
TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS"
|
||||
DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
KIND_DECISION_LOCK = "review_decision_lock"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
|
||||
def default_state_dir() -> str:
|
||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def ttl_hours() -> float:
|
||||
raw = (os.environ.get(TTL_HOURS_ENV) or "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TTL_HOURS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_TTL_HOURS
|
||||
return value if value > 0 else DEFAULT_TTL_HOURS
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def current_profile_identity(
|
||||
profile_name: str | None = None,
|
||||
session_profile_lock: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the session profile identity used as the durable key."""
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
explicit = (profile_identity or session_profile_lock or "").strip()
|
||||
lock = (explicit or env_lock or "").strip()
|
||||
name = (profile_name or "").strip()
|
||||
return lock or name or "unknown-profile"
|
||||
|
||||
|
||||
def state_key(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
Session proofs are one-active-per-profile (workflow load / decision lock),
|
||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||
inside the payload and validated on load (#559), which lets a later daemon
|
||||
process recover state without already knowing the remote argument.
|
||||
"""
|
||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
|
||||
def _ensure_state_dir(state_dir: str | None = None) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_json(path: str) -> dict[str, Any] | None:
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
parent = os.path.dirname(path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
os.replace(temp_path, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return fail-closed reasons when durable record identity does not match."""
|
||||
if record is None:
|
||||
return []
|
||||
reasons: list[str] = []
|
||||
expected_profile = current_profile_identity(profile_identity=profile_identity)
|
||||
stored_profile = (
|
||||
(record.get("session_profile_lock") or record.get("profile_identity") or "")
|
||||
.strip()
|
||||
)
|
||||
if stored_profile and expected_profile and stored_profile != expected_profile:
|
||||
if expected_profile != "unknown-profile":
|
||||
reasons.append(
|
||||
"session state profile identity mismatch "
|
||||
f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)"
|
||||
)
|
||||
|
||||
for field, expected in (
|
||||
("remote", remote),
|
||||
("org", org),
|
||||
("repo", repo),
|
||||
):
|
||||
want = (expected or "").strip()
|
||||
have = (str(record.get(field) or "")).strip()
|
||||
if want and have and want != have:
|
||||
reasons.append(
|
||||
f"session state {field} mismatch "
|
||||
f"(stored={have!r}, expected={want!r}; fail closed)"
|
||||
)
|
||||
|
||||
recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at"))
|
||||
if recorded_at is None:
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if age < timedelta(0):
|
||||
reasons.append("session state recorded_at is in the future (fail closed)")
|
||||
return reasons
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
return None
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
# Identity fields live on both envelope and payload for convenience.
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
if reasons:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def save_state(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any] | None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
profile_name=payload.get("session_profile") if payload else None,
|
||||
session_profile_lock=(
|
||||
(payload or {}).get("session_profile_lock") or profile_identity
|
||||
),
|
||||
)
|
||||
# Prefer explicit args over payload fields for key location.
|
||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||
key_org = org if org is not None else (payload or {}).get("org")
|
||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=key_remote,
|
||||
org=key_org,
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
if payload is None:
|
||||
for candidate in (path, lock_path):
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
os.remove(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
now = _now_utc().isoformat().replace("+00:00", "Z")
|
||||
body = dict(payload)
|
||||
body.setdefault("session_pid", os.getpid())
|
||||
body["writer_pid"] = os.getpid()
|
||||
body["profile_identity"] = profile
|
||||
if not (body.get("session_profile_lock") or "").strip():
|
||||
body["session_profile_lock"] = profile
|
||||
body["recorded_at"] = body.get("recorded_at") or now
|
||||
body["updated_at"] = now
|
||||
if key_remote is not None:
|
||||
body["remote"] = key_remote
|
||||
if key_org is not None:
|
||||
body["org"] = key_org
|
||||
if key_repo is not None:
|
||||
body["repo"] = key_repo
|
||||
|
||||
envelope = {
|
||||
"kind": kind,
|
||||
"remote": key_remote,
|
||||
"org": key_org,
|
||||
"repo": key_repo,
|
||||
"profile_identity": profile,
|
||||
"session_profile_lock": body.get("session_profile_lock"),
|
||||
"recorded_at": body["recorded_at"],
|
||||
"updated_at": body["updated_at"],
|
||||
"writer_pid": body["writer_pid"],
|
||||
"payload": body,
|
||||
}
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
|
||||
def clear_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
+181
-8
@@ -17,6 +17,7 @@ import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
|
||||
|
||||
|
||||
def extract_linked_issue(title: str, body: str) -> int | None:
|
||||
@@ -36,6 +37,155 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||
|
||||
|
||||
def extract_issue_marker(*values: str | None) -> int | None:
|
||||
"""Return the first issue marker found in branch/path-like values."""
|
||||
for value in values:
|
||||
match = ISSUE_MARKER_RE.search(value or "")
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for raw_line in (res.stdout or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
flush()
|
||||
current = {"path": line[9:].strip()}
|
||||
elif current is not None and line.startswith("HEAD "):
|
||||
current["head_sha"] = line[5:].strip()
|
||||
elif current is not None and line.startswith("branch "):
|
||||
ref = line[7:].strip()
|
||||
current["branch_ref"] = ref
|
||||
current["branch"] = (
|
||||
ref[len("refs/heads/"):]
|
||||
if ref.startswith("refs/heads/")
|
||||
else ref
|
||||
)
|
||||
elif current is not None and line == "detached":
|
||||
current["detached"] = True
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||
real_path = os.path.realpath(path)
|
||||
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||
|
||||
|
||||
def _head_is_safe_for_cleanup(
|
||||
*,
|
||||
project_root: str,
|
||||
candidate_head: str | None,
|
||||
pr_head_sha: str | None,
|
||||
target_ref: str | None,
|
||||
) -> bool:
|
||||
if not candidate_head:
|
||||
return False
|
||||
if pr_head_sha and candidate_head == pr_head_sha:
|
||||
return True
|
||||
if target_ref:
|
||||
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_cleanup_worktree_state(
|
||||
*,
|
||||
project_root: str,
|
||||
head_branch: str,
|
||||
issue_number: int | None,
|
||||
pr_head_sha: str | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||
state = read_local_worktree_state(expected_path)
|
||||
state["worktree_path"] = expected_path
|
||||
state["expected_worktree_path"] = expected_path
|
||||
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||
state["candidate_paths"] = []
|
||||
state["alias_block_reasons"] = []
|
||||
if state.get("exists"):
|
||||
return state
|
||||
|
||||
branch_issue = extract_issue_marker(head_branch)
|
||||
wanted_issue = issue_number or branch_issue
|
||||
candidates: list[dict[str, Any]] = []
|
||||
unsafe_matches: list[str] = []
|
||||
|
||||
for entry in list_local_worktrees(project_root):
|
||||
path = entry.get("path") or ""
|
||||
if not path or not _is_under_branches(project_root, path):
|
||||
continue
|
||||
branch = entry.get("branch") or ""
|
||||
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||
entry_issue = extract_issue_marker(branch) or path_issue
|
||||
branch_match = branch == head_branch
|
||||
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||
if not (branch_match or issue_match):
|
||||
continue
|
||||
if not _head_is_safe_for_cleanup(
|
||||
project_root=project_root,
|
||||
candidate_head=entry.get("head_sha"),
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
):
|
||||
unsafe_matches.append(path)
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||
if len(candidates) == 1:
|
||||
path = candidates[0]["path"]
|
||||
alias_state = read_local_worktree_state(path)
|
||||
alias_state["worktree_path"] = path
|
||||
alias_state["expected_worktree_path"] = expected_path
|
||||
alias_state["discovered_worktree_path"] = path
|
||||
alias_state["match_type"] = "alias"
|
||||
alias_state["candidate_paths"] = [path]
|
||||
alias_state["alias_block_reasons"] = []
|
||||
alias_state["alias_verified"] = True
|
||||
return alias_state
|
||||
if len(candidates) > 1:
|
||||
state["alias_block_reasons"].append(
|
||||
"ambiguous local worktree aliases: " + ", ".join(
|
||||
sorted(str(entry.get("path")) for entry in candidates)
|
||||
)
|
||||
)
|
||||
state["match_type"] = "ambiguous_alias"
|
||||
elif unsafe_matches:
|
||||
state["alias_block_reasons"].append(
|
||||
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||
+ ", ".join(sorted(unsafe_matches))
|
||||
)
|
||||
state["match_type"] = "unsafe_alias"
|
||||
return state
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
@@ -176,6 +326,7 @@ def assess_local_worktree_cleanup(
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
reasons.extend(worktree_state.get("alias_block_reasons") or [])
|
||||
if not exists:
|
||||
reasons.append("local worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
@@ -186,7 +337,11 @@ def assess_local_worktree_cleanup(
|
||||
)
|
||||
if exists:
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if current_branch and current_branch != head_branch:
|
||||
if (
|
||||
current_branch
|
||||
and current_branch != head_branch
|
||||
and not worktree_state.get("alias_verified")
|
||||
):
|
||||
reasons.append(
|
||||
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
||||
)
|
||||
@@ -198,6 +353,10 @@ def assess_local_worktree_cleanup(
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"worktree_path": worktree_state.get("worktree_path"),
|
||||
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
|
||||
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
|
||||
"match_type": worktree_state.get("match_type"),
|
||||
"candidate_paths": worktree_state.get("candidate_paths") or [],
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
@@ -216,6 +375,7 @@ def build_pr_cleanup_entry(
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pr_number = int(pr["number"])
|
||||
head_branch = pr.get("head") or ""
|
||||
@@ -224,9 +384,16 @@ def build_pr_cleanup_entry(
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
merged = bool(pr.get("merged_at"))
|
||||
worktree_path = resolve_worktree_path(project_root, head_branch)
|
||||
worktree_state = read_local_worktree_state(worktree_path)
|
||||
worktree_state["worktree_path"] = worktree_path
|
||||
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||
worktree_state = resolve_cleanup_worktree_state(
|
||||
project_root=project_root,
|
||||
head_branch=head_branch,
|
||||
issue_number=issue_number,
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||
|
||||
remote = assess_remote_branch_cleanup(
|
||||
@@ -249,7 +416,7 @@ def build_pr_cleanup_entry(
|
||||
)
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"issue_number": extract_linked_issue(title, body),
|
||||
"issue_number": issue_number,
|
||||
"title": title,
|
||||
"head_branch": head_branch,
|
||||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||||
@@ -270,6 +437,7 @@ def build_reconciliation_report(
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
open_heads = collect_open_pr_heads(open_prs)
|
||||
entries: list[dict[str, Any]] = []
|
||||
@@ -290,6 +458,7 @@ def build_reconciliation_report(
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
issue_lock_path=issue_lock_path,
|
||||
protected_branches=protected_branches,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
)
|
||||
return {
|
||||
@@ -301,8 +470,12 @@ def build_reconciliation_report(
|
||||
}
|
||||
|
||||
|
||||
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
||||
worktree_path = resolve_worktree_path(project_root, branch)
|
||||
def remove_local_worktree(
|
||||
project_root: str,
|
||||
branch: str,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||
if not os.path.isdir(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
@@ -326,4 +499,4 @@ def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
|
||||
"performed": True,
|
||||
"message": f"removed worktree {worktree_path}",
|
||||
"worktree_path": worktree_path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
@@ -5608,3 +5617,12 @@ def assess_proof_backed_handoff_report(report_text, **kwargs):
|
||||
from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_mutation_capability_proof(report_text, **kwargs):
|
||||
"""#405: exact per-mutation capability proof in reviewer final reports."""
|
||||
from reviewer_mutation_capability_proof import (
|
||||
assess_mutation_capability_proof as _assess,
|
||||
)
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
@@ -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,321 @@
|
||||
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import mcp_session_state
|
||||
import review_workflow_boundary as boundary
|
||||
|
||||
WORKFLOW_REL_PATH = (
|
||||
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
||||
)
|
||||
SCHEMA_REL_PATH = (
|
||||
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
||||
)
|
||||
TASK_MODE = "review-merge-pr"
|
||||
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
||||
|
||||
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
||||
|
||||
|
||||
def compute_content_hash(text: str) -> str:
|
||||
"""Short deterministic hash for workflow/schema version proof."""
|
||||
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
||||
root = Path(project_root)
|
||||
workflow = root / WORKFLOW_REL_PATH
|
||||
schema = root / SCHEMA_REL_PATH
|
||||
if not workflow.is_file():
|
||||
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
||||
if not schema.is_file():
|
||||
raise FileNotFoundError(f"final report schema missing: {schema}")
|
||||
return workflow, schema
|
||||
|
||||
|
||||
def build_canonical_workflow_metadata(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Load workflow + schema from disk and compute proof metadata."""
|
||||
workflow_path, schema_path = _canonical_paths(project_root)
|
||||
workflow_text = _read_text(workflow_path)
|
||||
schema_text = _read_text(schema_path)
|
||||
workflow_hash = compute_content_hash(workflow_text)
|
||||
schema_hash = compute_content_hash(schema_text)
|
||||
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
||||
return {
|
||||
"workflow_source": WORKFLOW_REL_PATH,
|
||||
"workflow_path": str(workflow_path),
|
||||
"task_mode": TASK_MODE,
|
||||
"workflow_hash": workflow_hash,
|
||||
"workflow_version": workflow_hash,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"final_report_schema_hash": schema_hash,
|
||||
"prompt_conflicts_with_workflow": conflict,
|
||||
"prompt_conflict_reasons": conflict_reasons,
|
||||
"load_tool": LOAD_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
||||
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
||||
if not (prompt_text or "").strip():
|
||||
return False, []
|
||||
text = prompt_text.lower()
|
||||
reasons: list[str] = []
|
||||
conflicting = (
|
||||
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
||||
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
||||
(r"\bauthor/coder\b", "author/coder mode"),
|
||||
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
||||
)
|
||||
for pattern, label in conflicting:
|
||||
if re.search(pattern, text):
|
||||
reasons.append(
|
||||
f"active prompt appears to request {label} while loading "
|
||||
f"{TASK_MODE} workflow"
|
||||
)
|
||||
return bool(reasons), reasons
|
||||
|
||||
|
||||
def _session_binding_fields() -> dict:
|
||||
"""Capture profile identity used to share state across daemon processes."""
|
||||
env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
||||
remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None
|
||||
identity = mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=env_lock,
|
||||
)
|
||||
return {
|
||||
"session_profile": profile_name or identity,
|
||||
"session_profile_lock": env_lock or identity,
|
||||
"profile_identity": identity,
|
||||
"remote": remote,
|
||||
}
|
||||
|
||||
|
||||
def _persist_workflow_load(record: dict | None) -> dict | None:
|
||||
"""Write durable workflow-load proof (or clear it)."""
|
||||
binding = _session_binding_fields()
|
||||
if record is None:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
return None
|
||||
payload = dict(record)
|
||||
payload.update({
|
||||
k: v for k, v in binding.items() if v is not None
|
||||
})
|
||||
return mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload=payload,
|
||||
remote=payload.get("remote"),
|
||||
org=payload.get("org"),
|
||||
repo=payload.get("repo"),
|
||||
profile_identity=payload.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _load_durable_workflow_load() -> dict | None:
|
||||
binding = _session_binding_fields()
|
||||
return mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote=binding.get("remote"),
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
|
||||
|
||||
def _active_workflow_load() -> dict | None:
|
||||
"""Prefer in-process cache; fall back to durable shared state (#559)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
if _REVIEW_WORKFLOW_LOAD is not None:
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
durable = _load_durable_workflow_load()
|
||||
if durable is not None:
|
||||
_REVIEW_WORKFLOW_LOAD = dict(durable)
|
||||
return _REVIEW_WORKFLOW_LOAD
|
||||
|
||||
|
||||
def record_review_workflow_load(
|
||||
project_root: str,
|
||||
*,
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Record workflow load proof for the current MCP session (durable + memory)."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
meta = build_canonical_workflow_metadata(
|
||||
project_root, prompt_text=prompt_text)
|
||||
boundary_state = boundary.assess_boundary_status(project_root)
|
||||
binding = _session_binding_fields()
|
||||
record = {
|
||||
**meta,
|
||||
"session_pid": os.getpid(),
|
||||
"loaded": True,
|
||||
"boundary_status": boundary_state.get("boundary_status"),
|
||||
"boundary_clean": boundary_state.get("boundary_clean"),
|
||||
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
||||
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
||||
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
||||
**{k: v for k, v in binding.items() if v is not None},
|
||||
}
|
||||
persisted = _persist_workflow_load(record)
|
||||
_REVIEW_WORKFLOW_LOAD = dict(persisted or record)
|
||||
return dict(_REVIEW_WORKFLOW_LOAD)
|
||||
|
||||
|
||||
def clear_review_workflow_load() -> None:
|
||||
"""Test helper and review_pr session reset."""
|
||||
global _REVIEW_WORKFLOW_LOAD
|
||||
_REVIEW_WORKFLOW_LOAD = None
|
||||
_persist_workflow_load(None)
|
||||
boundary.clear_pre_review_commands()
|
||||
|
||||
|
||||
def workflow_load_status(project_root: str | None = None) -> dict:
|
||||
"""Non-throwing status for capability/runtime reports."""
|
||||
load = _active_workflow_load()
|
||||
if load is None:
|
||||
return {
|
||||
"workflow_load_proof_present": False,
|
||||
"workflow_load_valid": False,
|
||||
"workflow_source": None,
|
||||
"workflow_hash": None,
|
||||
"final_report_schema_path": SCHEMA_REL_PATH,
|
||||
"reasons": [
|
||||
f"{LOAD_TOOL_NAME} has not been called in this session "
|
||||
"(fail closed for reviewer mutations)"
|
||||
],
|
||||
}
|
||||
reasons = _session_validation_reasons(load, project_root)
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
if boundary_reasons:
|
||||
reasons = list(reasons) + boundary_reasons
|
||||
return {
|
||||
"workflow_load_proof_present": True,
|
||||
"workflow_load_valid": not reasons,
|
||||
"workflow_source": load.get("workflow_source"),
|
||||
"workflow_hash": load.get("workflow_hash"),
|
||||
"task_mode": load.get("task_mode"),
|
||||
"final_report_schema_path": load.get("final_report_schema_path"),
|
||||
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
||||
"prompt_conflicts_with_workflow": load.get(
|
||||
"prompt_conflicts_with_workflow"),
|
||||
"session_pid": load.get("session_pid"),
|
||||
"writer_pid": load.get("writer_pid"),
|
||||
"profile_identity": load.get("profile_identity"),
|
||||
"boundary_status": load.get("boundary_status"),
|
||||
"boundary_clean": load.get("boundary_clean"),
|
||||
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
||||
load, project_root),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _session_validation_reasons(
|
||||
load: dict,
|
||||
project_root: str | None,
|
||||
) -> list[str]:
|
||||
"""Validate durable/in-memory load proof for this session identity (#559)."""
|
||||
reasons: list[str] = []
|
||||
# Cross-process daemon pools are allowed when profile identity matches.
|
||||
# Reject only when the stored profile identity conflicts with this process.
|
||||
binding = _session_binding_fields()
|
||||
stored_identity = (
|
||||
load.get("session_profile_lock")
|
||||
or load.get("profile_identity")
|
||||
or ""
|
||||
).strip()
|
||||
active_identity = (binding.get("profile_identity") or "").strip()
|
||||
if (
|
||||
stored_identity
|
||||
and active_identity
|
||||
and stored_identity != active_identity
|
||||
and active_identity != "unknown-profile"
|
||||
and stored_identity != "unknown-profile"
|
||||
):
|
||||
reasons.append(
|
||||
"workflow load proof profile identity mismatch "
|
||||
f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
# Expired durable records are treated as absent.
|
||||
identity_reasons = mcp_session_state.identity_match_reasons(
|
||||
load,
|
||||
remote=binding.get("remote") or load.get("remote"),
|
||||
org=load.get("org"),
|
||||
repo=load.get("repo"),
|
||||
profile_identity=active_identity or stored_identity,
|
||||
)
|
||||
# Filter out remote-mismatch noise when remote was not bound at load time.
|
||||
for reason in identity_reasons:
|
||||
if "missing recorded_at" in reason or "expired" in reason or "future" in reason:
|
||||
reasons.append(reason)
|
||||
elif "profile identity mismatch" in reason:
|
||||
reasons.append(reason)
|
||||
if reasons:
|
||||
return reasons
|
||||
|
||||
if load.get("prompt_conflicts_with_workflow"):
|
||||
reasons.extend(load.get("prompt_conflict_reasons") or [
|
||||
"active prompt conflicts with loaded review-merge workflow"
|
||||
])
|
||||
if project_root:
|
||||
try:
|
||||
current = build_canonical_workflow_metadata(project_root)
|
||||
except OSError as exc:
|
||||
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
||||
return reasons
|
||||
if current["workflow_hash"] != load.get("workflow_hash"):
|
||||
reasons.append(
|
||||
"stored workflow hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
if current["final_report_schema_hash"] != load.get(
|
||||
"final_report_schema_hash"):
|
||||
reasons.append(
|
||||
"stored final-report schema hash is stale; reload via "
|
||||
f"{LOAD_TOOL_NAME} (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def review_workflow_load_blockers(
|
||||
project_root: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Reasons reviewer mutations must fail closed."""
|
||||
boundary_reasons = boundary.boundary_blockers(project_root)
|
||||
load = _active_workflow_load()
|
||||
if boundary_reasons and load is None:
|
||||
return boundary_reasons
|
||||
status = workflow_load_status(project_root)
|
||||
if not status.get("workflow_load_proof_present"):
|
||||
return list(status.get("reasons") or []) + boundary_reasons
|
||||
if not status.get("workflow_load_valid"):
|
||||
return list(status.get("reasons") or [])
|
||||
return []
|
||||
|
||||
|
||||
def recovery_handoff_without_replay() -> list[str]:
|
||||
"""Safe next-step lines that must not include approve/merge replay."""
|
||||
return [
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory.",
|
||||
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
||||
"or gitea_merge_pr until workflow-load proof is present.",
|
||||
"Do not include approve/merge replay commands in the recovery handoff.",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Exact per-mutation capability proof verifier for reviewer reports (#405).
|
||||
|
||||
A reviewer final report may prove ``review_pr`` capability and then also merge
|
||||
a PR or delete a remote branch. Merge and branch deletion are separate
|
||||
mutations that require their own exact capability proof — a nearby capability
|
||||
must never authorize a different operation. This verifier requires a
|
||||
mutation-capability table pairing every performed mutation with the exact
|
||||
task/permission resolved *before* that mutation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Mutations this verifier tracks, with the exact capability tokens that
|
||||
# authorize each. A row for the mutation must cite one of its own tokens;
|
||||
# tokens from a different mutation (a "nearby capability") never count.
|
||||
_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve",
|
||||
"gitea.pr.request_changes", "request_changes_pr", "approve_pr")
|
||||
_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge")
|
||||
_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete")
|
||||
|
||||
# Detect that a mutation was actually performed (not merely mentioned as a
|
||||
# non-goal or skipped).
|
||||
_MERGE_PERFORMED = re.compile(
|
||||
r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*merged\b|"
|
||||
r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_PERFORMED = re.compile(
|
||||
r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|"
|
||||
r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|"
|
||||
r"\bdeleted (?:the )?(?:remote )?branch\b|"
|
||||
r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_REVIEW_PERFORMED = re.compile(
|
||||
r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|"
|
||||
r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*"
|
||||
r"(?:approved|request[_ ]changes)\b|\breview submitted\b)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Post-hoc proof: capability resolved *after* the mutation is never valid.
|
||||
_POST_HOC = re.compile(
|
||||
r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*"
|
||||
r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# The report must carry an explicit mutation-capability table.
|
||||
_TABLE_MARKER = re.compile(
|
||||
r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool:
|
||||
low = text.lower()
|
||||
return any(tok.lower() in low for tok in tokens)
|
||||
|
||||
|
||||
def assess_mutation_capability_proof(report_text: str) -> dict:
|
||||
"""Validate exact per-mutation capability proof in a reviewer report.
|
||||
|
||||
Returns ``{proven, block, reasons, safe_next_action}``. A report that
|
||||
performs no mutation beyond an ordinary review passes only when its
|
||||
review capability is cited; merge/delete each demand their own exact
|
||||
capability row. Fail closed on nearby-capability substitution, a
|
||||
missing table, missing rows, or post-hoc proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
merged = bool(_MERGE_PERFORMED.search(text))
|
||||
deleted = bool(_DELETE_PERFORMED.search(text))
|
||||
reviewed = bool(_REVIEW_PERFORMED.search(text))
|
||||
|
||||
extra_mutation = merged or deleted
|
||||
|
||||
if _POST_HOC.search(text):
|
||||
reasons.append(
|
||||
"capability proof recorded after the mutation; exact capability "
|
||||
"must be resolved before each mutation"
|
||||
)
|
||||
|
||||
# A review-only report needs its review capability cited; no table required.
|
||||
if reviewed and not _tokens_present(text, _REVIEW_TOKENS):
|
||||
reasons.append(
|
||||
"review mutation performed without exact review capability proof "
|
||||
"(review_pr / gitea.pr.review)"
|
||||
)
|
||||
|
||||
if extra_mutation and not _TABLE_MARKER.search(text):
|
||||
reasons.append(
|
||||
"mutation beyond review performed without a mutation-capability "
|
||||
"table (mutation, exact task/capability, result, order-before)"
|
||||
)
|
||||
|
||||
if merged:
|
||||
if not _tokens_present(text, _MERGE_TOKENS):
|
||||
reasons.append(
|
||||
"merge performed without exact merge capability proof "
|
||||
"(merge_pr / gitea.pr.merge); nearby review_pr does not "
|
||||
"authorize merge"
|
||||
)
|
||||
|
||||
if deleted:
|
||||
if not _tokens_present(text, _DELETE_TOKENS):
|
||||
reasons.append(
|
||||
"branch deletion performed without exact delete capability "
|
||||
"proof (delete_branch / gitea.branch.delete); nearby "
|
||||
"merge_pr does not authorize branch deletion"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "add a mutation-capability table with the exact resolved "
|
||||
"task/permission and pre-mutation order for every mutation; "
|
||||
"skip any mutation whose exact capability is unproven"
|
||||
),
|
||||
}
|
||||
+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,148 @@
|
||||
"""Root checkout guard (#475).
|
||||
|
||||
The project root checkout is the stable control checkout on master/prgs/master.
|
||||
Author/reviewer/merge flows must fail closed when the control checkout is
|
||||
contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of
|
||||
prgs/master). Isolated ``branches/...`` worktrees remain allowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from author_mutation_worktree import is_path_under_branches
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
REMEDIATION = (
|
||||
"Root checkout is not on master. Preserve state, switch root back to master, "
|
||||
"and use scripts/worktree-review or the sanctioned issue worktree flow."
|
||||
)
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
|
||||
|
||||
|
||||
def resolve_remote_master_sha(
|
||||
canonical_repo_root: str,
|
||||
*,
|
||||
remote_refs: tuple[str, ...] | None = None,
|
||||
) -> str | None:
|
||||
"""Return the commit SHA for the tracking master ref when available."""
|
||||
root = (canonical_repo_root or "").strip()
|
||||
if not root:
|
||||
return None
|
||||
for ref in remote_refs or REMOTE_MASTER_REFS:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "--verify", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
sha = (res.stdout or "").strip()
|
||||
if sha:
|
||||
return sha
|
||||
return None
|
||||
|
||||
|
||||
resolve_tracking_master_sha = resolve_remote_master_sha
|
||||
|
||||
|
||||
def assess_root_checkout_guard(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
current_branch: str | None,
|
||||
head_sha: str | None,
|
||||
porcelain_status: str,
|
||||
remote_master_sha: str | None,
|
||||
resolved_role: str | None = None,
|
||||
actual_role: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when the control checkout is not clean master/prgs/master.
|
||||
|
||||
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
|
||||
is the *active profile* role (#540). The reconciler exemption honours either
|
||||
signal so a ``comment_issue`` preflight (which stamps the task role as
|
||||
``author``) cannot strip a genuine reconciler of its exemption. An actual
|
||||
author profile classifies as ``author`` in both signals, so author blocking
|
||||
on a contaminated control checkout is preserved.
|
||||
|
||||
Merger *strictness* (a merger must not be auto-exempted by working from a
|
||||
``branches/`` worktree) stays keyed on the resolved task role: merge
|
||||
operations resolve their own task role, and widening the merger test with
|
||||
``actual_role`` would wrongly subject a merger operating from its clean
|
||||
workspace under a non-merge task to full control-checkout checks.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
branch = (current_branch or "").strip()
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
|
||||
if resolved_role == "reconciler" or actual_role == "reconciler":
|
||||
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
|
||||
|
||||
if resolved_role != "merger" and is_path_under_branches(workspace, root):
|
||||
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
|
||||
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"control checkout has tracked local edits before role work "
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if not branch:
|
||||
reasons.append("control checkout is detached HEAD; expected branch 'master'")
|
||||
elif branch not in BASE_BRANCHES:
|
||||
reasons.append(
|
||||
f"control checkout branch '{branch}' is not a stable base branch "
|
||||
f"({'/'.join(sorted(BASE_BRANCHES))})"
|
||||
)
|
||||
|
||||
if remote_master_sha and head_sha and head_sha != remote_master_sha:
|
||||
reasons.append(
|
||||
"control checkout HEAD does not match prgs/master "
|
||||
f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(proven, reasons, root, workspace, branch or None, head_sha, dirty_files)
|
||||
|
||||
|
||||
def format_root_checkout_guard_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown root checkout violation"])
|
||||
return (
|
||||
f"Root checkout guard (#475): {reasons}. "
|
||||
f"canonical repository root: {root}; workspace: {workspace}. "
|
||||
f"{REMEDIATION}"
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
canonical_repo_root: str,
|
||||
workspace_path: str,
|
||||
current_branch: str | None,
|
||||
head_sha: str | None,
|
||||
dirty_files: list[str],
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"canonical_repo_root": canonical_repo_root,
|
||||
"workspace_path": workspace_path,
|
||||
"current_branch": current_branch,
|
||||
"head_sha": head_sha,
|
||||
"dirty_files": dirty_files,
|
||||
"remediation": REMEDIATION,
|
||||
}
|
||||
|
||||
|
||||
assess_root_checkout = assess_root_checkout_guard
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PYTHON="$ROOT_DIR/venv/bin/python"
|
||||
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "ERROR: expected virtualenv Python at $PYTHON" >&2
|
||||
echo "Create the venv first, then run: venv/bin/python -m pytest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$PYTHON" -m pytest "$@"
|
||||
@@ -182,4 +182,17 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
|
||||
|
||||
Releases follow SemVer from remote `master` only, after full test suite passes.
|
||||
See [`templates/release-tag.md`](templates/release-tag.md) and
|
||||
`scripts/release-tag`.
|
||||
`scripts/release-tag`.
|
||||
|
||||
## Bootstrap Review Path (#557)
|
||||
|
||||
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
|
||||
gates with raw API, direct imports, or root checkout edits.
|
||||
|
||||
If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
|
||||
(#557)` record, follow:
|
||||
|
||||
`docs/bootstrap-review-path.md`
|
||||
|
||||
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Controller issue-acceptance prompt
|
||||
|
||||
Use after a PR merges when auditing whether the linked issue is truly complete.
|
||||
|
||||
```text
|
||||
Audit issue #<N> against its acceptance criteria after merged PR #<PR>.
|
||||
Post a Controller Issue Acceptance comment with checked criteria, validation
|
||||
reviewed, controller decision, next actor, and paste-ready next prompt.
|
||||
Do not mark the issue accepted unless every required criterion is satisfied.
|
||||
```
|
||||
|
||||
## Comment template
|
||||
|
||||
```text
|
||||
## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Rejection paths
|
||||
|
||||
When rejecting completion, `STATE` must name the gap (`needs-tests`,
|
||||
`needs-docs`, `more-work-required`, etc.), `MISSING_WORK` must be explicit,
|
||||
and `NEXT_PROMPT` must be ready for the next author session.
|
||||
@@ -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:
|
||||
@@ -341,6 +375,24 @@ Include:
|
||||
* confirmation that no normal review, approval, request-changes, or merge was
|
||||
performed
|
||||
|
||||
## 18A. Reconciler close proof is enforced (#306)
|
||||
|
||||
When a reconciler run closes a PR, the final-report validator
|
||||
(`final_report_validator` rule `reconcile.close_proof_fields`) **blocks** the
|
||||
handoff unless it carries all four close proofs. The prompt is guidance; the
|
||||
MCP validator is the authority.
|
||||
|
||||
A close is detected from `PRs closed: #<n>` (or a session close lock). Once a
|
||||
close is reported, the handoff must include:
|
||||
|
||||
* `Capabilities proven:` naming `gitea.pr.close` — the exact close capability
|
||||
* `Ancestor proof:` — the landed/ancestor proof for the closed PR
|
||||
* `PRs closed:` — the PR close result (the closed PR number)
|
||||
* `Linked issue live status:` (or `Issues closed:`) — the linked-issue result
|
||||
|
||||
Comment-only and blocked reconciliations (no PR close) are unaffected: the rule
|
||||
returns no finding when nothing was closed.
|
||||
|
||||
## 19. Local artifact and report consistency rule
|
||||
|
||||
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
|
||||
|
||||
@@ -36,6 +36,44 @@ If available, load it first and report:
|
||||
|
||||
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
|
||||
|
||||
## 0A. Workflow-load and session boundary anchor (#403)
|
||||
|
||||
The MCP gate is the authority — not local file viewing.
|
||||
|
||||
Before any reviewer mutation:
|
||||
|
||||
1. Record pre-review commands with `gitea_record_pre_review_command` when they
|
||||
are not automatically classified (inventory/diagnostic commands may be
|
||||
recorded explicitly for proof).
|
||||
2. Call `gitea_load_review_workflow` to establish workflow hash proof **and**
|
||||
session boundary state in the same in-process session proof.
|
||||
3. Do not claim the workflow was loaded by reading
|
||||
`skills/llm-project-workflow/workflows/review-merge-pr.md` as a local file;
|
||||
that narrative does not satisfy the validator.
|
||||
|
||||
Allowed before workflow load (classify as `read_only_inventory` or
|
||||
`diagnostic`):
|
||||
|
||||
* `gitea_whoami`, `gitea_resolve_task_capability`, `gitea_list_prs`,
|
||||
`gitea_view_pr`, `gitea_get_runtime_context`
|
||||
* `git fetch` / `git remote update` for inventory
|
||||
* `git status`, `git worktree list` (read-only)
|
||||
|
||||
Boundary violations (block downstream reviewer mutations even after load):
|
||||
|
||||
* validation commands (`pytest`, `python -m unittest`) in the main checkout
|
||||
* local profile/credential/config inspection (`profiles.json`, `gitea-mcp.json`,
|
||||
`.env`, keychain dumps)
|
||||
* MCP repair (`pkill`, MCP config edits)
|
||||
* git mutations before workflow load
|
||||
|
||||
Final reports must include a structured **Workflow-load helper result** block
|
||||
copied from `gitea_load_review_workflow`, including at minimum:
|
||||
|
||||
* `workflow_hash`
|
||||
* `final_report_schema_hash`
|
||||
* `boundary_status` (`clean` or `violation`)
|
||||
|
||||
## 1. Start with live identity, profile, runtime, and capability checks
|
||||
|
||||
Prove:
|
||||
@@ -796,6 +834,21 @@ inventory before continuing.
|
||||
Final reports must include lease session id, acquisition proof, heartbeat
|
||||
status, and release/blocked status.
|
||||
|
||||
## 26B-1. Merger lease adoption (#536)
|
||||
|
||||
When review and merge are **different sessions**, the merger must adopt the
|
||||
reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`.
|
||||
|
||||
Before merge in a merger-only session:
|
||||
|
||||
1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`.
|
||||
2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under
|
||||
`branches/`, passing `expected_head_sha` pinned to the approved head.
|
||||
3. Quote the adoption comment id and `adopted_from_session_id` in the handoff.
|
||||
4. Proceed to `gitea_merge_pr` only after adoption succeeds.
|
||||
|
||||
Manual in-process lease seeding is rejected by mutation gates (fail closed).
|
||||
|
||||
## 26C. Conflict-fix lease and stale-head protection (#399)
|
||||
|
||||
Before validating, approving, or merging a PR:
|
||||
@@ -871,6 +924,16 @@ Confirm:
|
||||
|
||||
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
||||
|
||||
Review, baseline, and merge-simulation worktrees created during this run are
|
||||
transient and are removed automatically at successful completion once they are
|
||||
clean, carry no open PR, and hold no active lease (#401). Use
|
||||
`gitea_audit_worktree_cleanup` (read-only) to classify `branches/` entries; only
|
||||
`clean_stale_removable` and `detached_review_leftover` may be removed, one-by-one,
|
||||
after `git worktree list` proof plus per-worktree proof of path, branch/HEAD,
|
||||
clean/dirty status, no active PR/lease, and the removal result. Dirty,
|
||||
active-PR, active-issue, and leased worktrees are never deleted automatically; a
|
||||
failed removal must be reported with the leftover path and reason.
|
||||
|
||||
Do not delete or mutate unrelated branches/worktrees.
|
||||
|
||||
Do not touch the main checkout except to update the stable branch after merge if explicitly allowed by the workflow.
|
||||
@@ -915,6 +978,27 @@ If any gate fails, report:
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 28B. MCP-native cleanup only (#517)
|
||||
|
||||
Post-merge cleanup of leases, comments, branches, and worktrees must go through
|
||||
explicit MCP tools — never raw git, curl/API scripts, or ad hoc helper scripts.
|
||||
|
||||
Merger and reviewer sessions must **not** perform cleanup inline. Record:
|
||||
|
||||
* **Merge mutations** — only `gitea_merge_pr` (or review mutations for review-only runs)
|
||||
* **Cleanup mutations** — only authorized reconciler MCP tools, cited by exact tool name
|
||||
|
||||
Authorized cleanup tools include `gitea_reconcile_merged_cleanups`,
|
||||
`gitea_cleanup_post_merge_moot_lease`, `gitea_delete_branch`, and
|
||||
`gitea_cleanup_merged_pr_branch` (when available). Lease cleanup uses
|
||||
append-only release comments — never delete another session's lease comment.
|
||||
|
||||
Hand cleanup to a `prgs-reconciler` session with `gitea.branch.delete` capability
|
||||
proof. Raw `git branch -d`, `git push --delete`, and comment-deletion API calls
|
||||
are blocked in final-report validation.
|
||||
|
||||
Template: `templates/post-merge-cleanup-handoff.md`.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
@@ -1013,6 +1097,26 @@ Use precise wording:
|
||||
|
||||
Do not collapse review, merge, cleanup, or external-state mutations into vague wording.
|
||||
|
||||
## 31B. Mutation-capability table (#405)
|
||||
|
||||
Every performed mutation requires exact capability proof resolved **before** that
|
||||
mutation executes. Nearby capabilities never authorize a different operation —
|
||||
`review_pr` does not authorize `merge_pr`, and `merge_pr` does not authorize
|
||||
`delete_branch` / `gitea.branch.delete`.
|
||||
|
||||
When any mutation beyond a bare review occurs (merge, branch delete, issue
|
||||
close/comment, etc.), the final report must include a **mutation-capability table**
|
||||
with one row per performed mutation:
|
||||
|
||||
* mutation (tool/action name)
|
||||
* exact task/capability resolved (for example `merge_pr` / `gitea.pr.merge`)
|
||||
* result
|
||||
* order/timestamp proof that capability was resolved before the mutation
|
||||
|
||||
If exact capability proof is missing, skip the mutation or stop the workflow —
|
||||
never claim a performed mutation without its row. Post-hoc capability proof after
|
||||
the mutation fails validation.
|
||||
|
||||
## 31A. Local artifact and report consistency rule
|
||||
|
||||
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during reviewer runs unless the canonical workflow or operator explicitly requires it.
|
||||
|
||||
@@ -626,9 +626,14 @@ When pushing to an existing PR branch to resolve merge conflicts:
|
||||
* session worktree path
|
||||
* push cwd
|
||||
* whether the push is fast-forward
|
||||
3. Do not push when a reviewer holds an active lease on the same PR.
|
||||
4. Do not force-push.
|
||||
5. Do not push from the main checkout or wrong cwd.
|
||||
* explicit `remote`, `org`, and `repo` when not using defaults
|
||||
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
|
||||
and produce a recovery handoff with the structured `reasons` and
|
||||
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
|
||||
unsafe or safe (#519).
|
||||
4. Do not push when a reviewer holds an active lease on the same PR.
|
||||
5. Do not force-push.
|
||||
6. Do not push from the main checkout or wrong cwd.
|
||||
|
||||
Conflict-fix final reports must state:
|
||||
|
||||
@@ -699,6 +704,39 @@ Do not update the main checkout unless the canonical workflow explicitly allows
|
||||
|
||||
Any cleanup is a mutation and must be reported.
|
||||
|
||||
### 22A. Session-owned worktree cleanup and TTL (#401)
|
||||
|
||||
Every session-owned worktree created under `branches/` has ownership metadata:
|
||||
path, workflow type, issue number, PR number, branch/head SHA, creator
|
||||
identity/profile, created timestamp, last-used timestamp, and cleanup
|
||||
eligibility.
|
||||
|
||||
Cleanup is classification-driven. `gitea_audit_worktree_cleanup` (read-only)
|
||||
classifies every `branches/` entry as exactly one of:
|
||||
|
||||
* `active_open_pr` — branch has an open PR; never auto-removed.
|
||||
* `active_issue_work` — active claim/lease or fresh issue worktree; never
|
||||
auto-removed.
|
||||
* `dirty_local_worktree` — uncommitted changes; never auto-removed.
|
||||
* `clean_stale_removable` — clean, no PR, no lease; removable.
|
||||
* `detached_review_leftover` — clean detached review/baseline/merge-simulation
|
||||
worktree; removable.
|
||||
* `unsafe_unknown` — protected base checkout or unknown workflow type; never
|
||||
auto-removed.
|
||||
|
||||
Only `clean_stale_removable` and `detached_review_leftover` may be removed, and
|
||||
only one-by-one after `git worktree list` proof plus per-worktree proof of:
|
||||
worktree path, branch/HEAD, clean/dirty status, no active PR/lease, and the
|
||||
removal result. Review, baseline, and merge-simulation worktrees are removed
|
||||
automatically at successful workflow completion; issue/conflict-fix worktrees
|
||||
are removed only after their TTL (`GITEA_WORKTREE_TTL_HOURS`, default 24h)
|
||||
expires and no lock/lease is held.
|
||||
|
||||
Dirty, active-PR, active-issue, and leased worktrees are never deleted
|
||||
automatically. If a removal fails, the final report must list the leftover
|
||||
worktree path and the reason. Include the `git worktree list` output as final
|
||||
cleanup verification.
|
||||
|
||||
## 23. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
|
||||
@@ -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,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live health-check script to verify Gitea MCP namespace connections.
|
||||
|
||||
Spawns the MCP server processes as defined in the IDE's global config,
|
||||
performs the JSON-RPC handshake, and queries the tools list to verify
|
||||
that the connection is fully operational and doesn't return EOF.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def test_connection(name, config):
|
||||
print(f"Testing MCP connection for '{name}'...")
|
||||
command = config.get("command")
|
||||
args = config.get("args", [])
|
||||
env = config.get("env", {})
|
||||
|
||||
# Merge current environment
|
||||
run_env = os.environ.copy()
|
||||
run_env.update(env)
|
||||
|
||||
# Spawn subprocess
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[command] + args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=run_env
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [FAIL] Failed to spawn process: {e}")
|
||||
return False
|
||||
|
||||
# Send initialize request
|
||||
init_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "healthcheck", "version": "1.0"}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
try:
|
||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Read response
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
stderr_content = proc.stderr.read()
|
||||
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
||||
proc.terminate()
|
||||
return False
|
||||
|
||||
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
|
||||
|
||||
# Send initialized notification
|
||||
init_notif = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
}
|
||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Send tools/list request
|
||||
list_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/list",
|
||||
"params": {},
|
||||
"id": 2
|
||||
}
|
||||
proc.stdin.write(json.dumps(list_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
print(" [FAIL] Received EOF on tools/list request.")
|
||||
proc.terminate()
|
||||
return False
|
||||
|
||||
res = json.loads(line)
|
||||
if "error" in res:
|
||||
print(f" [FAIL] Server returned error: {res['error']}")
|
||||
proc.terminate()
|
||||
return False
|
||||
|
||||
tools = res.get("result", {}).get("tools", [])
|
||||
tool_names = [t.get("name") for t in tools]
|
||||
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
||||
|
||||
proc.terminate()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [FAIL] Error during handshake: {e}")
|
||||
proc.terminate()
|
||||
return False
|
||||
|
||||
def main():
|
||||
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
mcp_config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Failed to load mcp_config.json: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
servers = mcp_config.get("mcpServers", {})
|
||||
failed = False
|
||||
for name in ["gitea-author", "gitea-reviewer"]:
|
||||
if name in servers:
|
||||
if not test_connection(name, servers[name]):
|
||||
failed = True
|
||||
else:
|
||||
print(f"Server '{name}' not found in mcp_config.json")
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All Gitea MCP connection tests passed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,14 +18,25 @@ def _reset_mutation_authority(monkeypatch):
|
||||
explicitly.
|
||||
"""
|
||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
||||
# Isolate durable session-state files so tests never share host cache (#559).
|
||||
import tempfile
|
||||
|
||||
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
||||
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", _state_tmp.name)
|
||||
try:
|
||||
import mcp_server
|
||||
except Exception:
|
||||
_state_tmp.cleanup()
|
||||
yield
|
||||
return
|
||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import capability_stop_terminal
|
||||
capability_stop_terminal.clear()
|
||||
@@ -37,3 +48,13 @@ def _reset_mutation_authority(monkeypatch):
|
||||
capability_stop_terminal.clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import review_workflow_load
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
except Exception:
|
||||
pass
|
||||
_state_tmp.cleanup()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Doc-contract checks for the bootstrap review path (#557).
|
||||
|
||||
Self-hosted MCP workflow fixes can deadlock live review daemons. These tests
|
||||
pin the narrow controller-authorized bootstrap path so it cannot silently
|
||||
regress into a general gate bypass.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
DOC = REPO_ROOT / "docs" / "bootstrap-review-path.md"
|
||||
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
|
||||
SKILL = REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def test_bootstrap_doc_exists_and_names_authorization_marker():
|
||||
text = DOC.read_text(encoding="utf-8")
|
||||
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
|
||||
assert "bootstrap deadlock" in text.lower() or "Bootstrap deadlock" in text
|
||||
|
||||
|
||||
def test_bootstrap_doc_requires_validation_and_audits():
|
||||
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||
for phrase in (
|
||||
"git diff --check",
|
||||
"Duplicate-PR audit",
|
||||
"Mutation ledger audit",
|
||||
"Contamination audit",
|
||||
"workflow-contaminated",
|
||||
):
|
||||
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
|
||||
|
||||
|
||||
def test_bootstrap_doc_lists_allowed_and_forbidden_actions():
|
||||
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||
lower = text.lower()
|
||||
for phrase in (
|
||||
"Allowed verification actions",
|
||||
"Forbidden actions",
|
||||
"project root",
|
||||
"direct Python import",
|
||||
"branches/",
|
||||
):
|
||||
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
|
||||
assert "curl" in lower
|
||||
assert "raw" in lower
|
||||
|
||||
|
||||
def test_bootstrap_doc_forbids_general_gate_weakening():
|
||||
text = _normalize(DOC.read_text(encoding="utf-8")).lower()
|
||||
assert "never weakens normal" in text or "does not weaken normal" in text
|
||||
assert "general bypass" in text or "general gate" in text
|
||||
|
||||
|
||||
def test_bootstrap_doc_post_land_restart_and_verify():
|
||||
text = _normalize(DOC.read_text(encoding="utf-8"))
|
||||
for phrase in (
|
||||
"Restart MCP daemons",
|
||||
"canonical tools",
|
||||
"gitea_whoami",
|
||||
):
|
||||
assert phrase in text, f"post-bootstrap guidance missing: {phrase!r}"
|
||||
|
||||
|
||||
def test_runbook_links_bootstrap_path():
|
||||
text = _normalize(RUNBOOK.read_text(encoding="utf-8"))
|
||||
assert "Bootstrap Review Path for self-hosted MCP fixes (#557)" in text
|
||||
assert "bootstrap-review-path.md" in text
|
||||
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
|
||||
|
||||
|
||||
def test_skill_links_bootstrap_path():
|
||||
text = _normalize(SKILL.read_text(encoding="utf-8"))
|
||||
assert "Bootstrap Review Path (#557)" in text
|
||||
assert "bootstrap-review-path.md" in text
|
||||
assert "BLOCKED + DIAGNOSE" in text or "BLOCKED" in text
|
||||
@@ -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):
|
||||
|
||||
@@ -498,6 +498,81 @@ class TestCanonicalReconcileSchema(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestReconcilerCloseProof(unittest.TestCase):
|
||||
"""Issue #306: a reconciler PR close must carry its proof fields."""
|
||||
|
||||
def _closed_report(self, **kwargs):
|
||||
# A report that claims PR #99 was closed via the reconciler path.
|
||||
report = (
|
||||
_reconcile_handoff(**kwargs)
|
||||
.replace(
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment",
|
||||
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
|
||||
)
|
||||
.replace("- Missing capabilities: gitea.pr.close", "- Missing capabilities: none")
|
||||
.replace("- PRs closed: none", "- PRs closed: #99")
|
||||
.replace(
|
||||
"- Blocker: missing gitea.pr.close capability", "- Blocker: none"
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
||||
def test_full_proof_close_passes(self):
|
||||
result = assess_final_report_validator(
|
||||
self._closed_report(), "reconcile_already_landed"
|
||||
)
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertFalse(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_close_without_capability_proof_blocks(self):
|
||||
# Claims PRs closed: #99 but never proves gitea.pr.close capability.
|
||||
report = _reconcile_handoff().replace("- PRs closed: none", "- PRs closed: #99")
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_close_without_ancestor_proof_blocks(self):
|
||||
report = self._closed_report(drop=("Ancestor proof",))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_close_without_linked_issue_result_blocks(self):
|
||||
report = self._closed_report(drop=("Linked issue live status", "Issues closed"))
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_close_lock_requires_proof_even_if_text_says_none(self):
|
||||
# Session lock proves a PR was closed; the report omits close proof.
|
||||
result = assess_final_report_validator(
|
||||
_reconcile_handoff(),
|
||||
"reconcile_already_landed",
|
||||
reconciler_close_lock={"pr_closed": True},
|
||||
)
|
||||
self.assertTrue(result["blocked"])
|
||||
self.assertTrue(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
def test_comment_only_reconcile_needs_no_close_proof(self):
|
||||
result = assess_final_report_validator(
|
||||
_reconcile_handoff(), "reconcile_already_landed"
|
||||
)
|
||||
self.assertEqual(result["grade"], "A")
|
||||
self.assertFalse(
|
||||
any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"])
|
||||
)
|
||||
|
||||
|
||||
class TestEntryPoint(unittest.TestCase):
|
||||
def test_unknown_task_kind_blocks(self):
|
||||
result = assess_final_report_validator("report", "unknown_mode")
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Regression tests for #540: comment_issue preflight must not poison the
|
||||
actual reconciler role for #274 branch-only / #475 root-checkout exemptions.
|
||||
|
||||
`resolve_task_capability("comment_issue")` stamps
|
||||
``_preflight_resolved_role = "author"`` because ``comment_issue`` has
|
||||
``required_role_kind = author``. Before the fix, ``_effective_workspace_role``
|
||||
preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an
|
||||
author inside the #274 branch-only mutation guard and the #475 root checkout
|
||||
guard, blocking ``gitea_create_issue_comment`` from the control checkout.
|
||||
|
||||
The fix keys the role exemptions off the *actual profile role* as well, so the
|
||||
exemption survives the poisoned task role while author profiles stay blocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import root_checkout_guard as rcg # noqa: E402
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
MASTER_SHA = "a" * 40
|
||||
OTHER_SHA = "b" * 40
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.create",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
"audit_label": "prgs-reviewer",
|
||||
}
|
||||
|
||||
|
||||
class TestActualProfileRole(unittest.TestCase):
|
||||
"""`_actual_profile_role` ignores the poisoned preflight task role (#540)."""
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_resolved_role = None
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_profile_role_survives_author_task_stamp(self, _profile):
|
||||
srv._preflight_resolved_role = "author" # comment_issue poison
|
||||
self.assertEqual(srv._actual_profile_role(), "reconciler")
|
||||
# _effective_workspace_role is still poisoned to author (unchanged #510)...
|
||||
self.assertEqual(srv._effective_workspace_role(), "author")
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_profile_role_is_author(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
self.assertEqual(srv._actual_profile_role(), "author")
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE)
|
||||
def test_reviewer_profile_role_is_reviewer(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
self.assertEqual(srv._actual_profile_role(), "reviewer")
|
||||
|
||||
|
||||
class TestBranchesOnlyExemptionRealRole(unittest.TestCase):
|
||||
"""#274 branches-only exemption keys off the actual profile role (#540)."""
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_resolved_role = None
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_exempt_despite_author_task_stamp(self, _profile):
|
||||
srv._preflight_resolved_role = "author" # poison
|
||||
# Must return without raising and without resolving an author worktree.
|
||||
with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx:
|
||||
srv._enforce_branches_only_author_mutation()
|
||||
ctx.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_not_exempt(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
sentinel = RuntimeError("author-mutation-guard-reached")
|
||||
|
||||
def _blow_up(*_a, **_k):
|
||||
raise sentinel
|
||||
|
||||
# Author is not exempt: the guard proceeds to resolve/assess the
|
||||
# workspace (proven by reaching the patched context resolver).
|
||||
with patch(
|
||||
"gitea_mcp_server._resolve_namespace_mutation_context",
|
||||
side_effect=_blow_up,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
srv._enforce_branches_only_author_mutation()
|
||||
self.assertIs(raised.exception, sentinel)
|
||||
|
||||
|
||||
class TestRootCheckoutGuardRealRole(unittest.TestCase):
|
||||
"""#475 root guard honours the actual profile role too (#540)."""
|
||||
|
||||
def _assess(self, **kwargs):
|
||||
defaults = {
|
||||
"workspace_path": CONTROL_CHECKOUT_ROOT,
|
||||
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
|
||||
"current_branch": "feat/some-branch",
|
||||
"head_sha": OTHER_SHA,
|
||||
"porcelain_status": " M gitea_mcp_server.py\n",
|
||||
"remote_master_sha": MASTER_SHA,
|
||||
"resolved_role": "author", # poisoned task role
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return rcg.assess_root_checkout_guard(**defaults)
|
||||
|
||||
def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self):
|
||||
result = self._assess(actual_role="reconciler")
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_actual_author_still_blocked_on_contaminated_root(self):
|
||||
result = self._assess(actual_role="author")
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_merger_strictness_stays_keyed_on_resolved_task_role(self):
|
||||
# When the merge task resolves the merger role, the branches/ auto
|
||||
# exemption is denied and a clean control checkout is required.
|
||||
blocked = self._assess(
|
||||
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
|
||||
current_branch="review-pr-1",
|
||||
porcelain_status="",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="merger",
|
||||
)
|
||||
self.assertTrue(blocked["block"])
|
||||
|
||||
def test_actual_merger_does_not_over_tighten_non_merge_task(self):
|
||||
# A merger profile whose current task did NOT resolve to merger keeps
|
||||
# the branches/ workspace exemption (regression guard for #540): the
|
||||
# actual_role signal must not force merger strictness here.
|
||||
result = self._assess(
|
||||
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
|
||||
current_branch="review-pr-1",
|
||||
porcelain_status="",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="reviewer",
|
||||
actual_role="merger",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_backward_compatible_without_actual_role(self):
|
||||
# No actual_role supplied: behaviour falls back to resolved_role.
|
||||
result = self._assess(resolved_role="reconciler")
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
|
||||
"""Integration: reconciler comment survives the poisoned author task role."""
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_capability_baseline_porcelain = ""
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
srv._preflight_resolved_role = None
|
||||
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": MASTER_SHA,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_comment_from_control_checkout_succeeds(
|
||||
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
|
||||
):
|
||||
srv._preflight_resolved_role = "author" # comment_issue poison
|
||||
mock_api.return_value = {"id": 9001, "html_url": "https://x/y"}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
|
||||
result = srv.gitea_create_issue_comment(
|
||||
515, "canonical reconciler audit", remote="prgs"
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comment_id"], 9001)
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": MASTER_SHA,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_comment_from_control_checkout_blocked(
|
||||
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
|
||||
):
|
||||
srv._preflight_resolved_role = "author"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.gitea_create_issue_comment(
|
||||
515, "author note", remote="prgs"
|
||||
)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for controller issue-acceptance gate (#500)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_acceptance_gate # noqa: E402
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
|
||||
|
||||
def _accepted_comment(**overrides):
|
||||
lines = [
|
||||
"## Controller Issue Acceptance",
|
||||
"",
|
||||
"STATE:",
|
||||
"accepted",
|
||||
"",
|
||||
"WHO_IS_NEXT:",
|
||||
"controller",
|
||||
"",
|
||||
"NEXT_ACTION:",
|
||||
"Close the tracker follow-up after verification.",
|
||||
"",
|
||||
"NEXT_PROMPT:",
|
||||
"Verify deployment checklist for issue #500 and post acceptance.",
|
||||
"",
|
||||
"ISSUE:",
|
||||
"#500",
|
||||
"",
|
||||
"MERGED_PR:",
|
||||
"#503",
|
||||
"",
|
||||
"MERGE_COMMIT:",
|
||||
"0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED:",
|
||||
"- [x] documentation added",
|
||||
"- [x] validator tests added",
|
||||
"",
|
||||
"VALIDATION_REVIEWED:",
|
||||
"pytest tests/test_issue_acceptance_gate.py -q",
|
||||
"",
|
||||
"CONTROLLER_DECISION:",
|
||||
"accepted",
|
||||
"",
|
||||
"WHY:",
|
||||
"All acceptance criteria satisfied with proof.",
|
||||
"",
|
||||
"MISSING_WORK:",
|
||||
"none",
|
||||
"",
|
||||
"FOLLOW_UP_ISSUES:",
|
||||
"none",
|
||||
"",
|
||||
"BLOCKERS:",
|
||||
"none",
|
||||
"",
|
||||
"LAST_UPDATED_BY:",
|
||||
"jcwalker3 / prgs-controller / 2026-07-08",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
text = text.replace(f"{key}:\n", f"{key}:\n{value}\n", 1)
|
||||
return text
|
||||
|
||||
|
||||
def _rejection_comment(state="needs-tests"):
|
||||
text = _accepted_comment()
|
||||
text = text.replace("STATE:\naccepted", f"STATE:\n{state}")
|
||||
text = text.replace(
|
||||
"CONTROLLER_DECISION:\naccepted",
|
||||
"CONTROLLER_DECISION:\nrejected",
|
||||
)
|
||||
text = text.replace(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED:\n- [x] documentation added\n- [x] validator tests added",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED:\n- [ ] regression tests for rejection paths",
|
||||
)
|
||||
text = text.replace(
|
||||
"MISSING_WORK:\nnone",
|
||||
"MISSING_WORK:\nAdd regression tests for controller rejection paths.",
|
||||
)
|
||||
text = text.replace(
|
||||
"NEXT_PROMPT:\nVerify deployment checklist for issue #500 and post acceptance.",
|
||||
"NEXT_PROMPT:\nImplement the missing tests for issue #500 and reopen the PR.",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
class TestControllerAcceptanceComment(unittest.TestCase):
|
||||
def test_accepted_comment_valid(self):
|
||||
result = issue_acceptance_gate.validate_controller_acceptance_comment(
|
||||
_accepted_comment()
|
||||
)
|
||||
self.assertTrue(result["valid"], result["reasons"])
|
||||
|
||||
def test_missing_who_is_next_rejected(self):
|
||||
text = _accepted_comment().replace("WHO_IS_NEXT:\ncontroller\n", "WHO_IS_NEXT:\n\n")
|
||||
result = issue_acceptance_gate.validate_controller_acceptance_comment(text)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertTrue(any("WHO_IS_NEXT" in r for r in result["reasons"]))
|
||||
|
||||
def test_missing_next_prompt_on_rejection(self):
|
||||
text = _rejection_comment()
|
||||
text = text.replace(
|
||||
"NEXT_PROMPT:\nImplement the missing tests for issue #500 and reopen the PR.\n",
|
||||
"NEXT_PROMPT:\n\n",
|
||||
)
|
||||
result = issue_acceptance_gate.validate_controller_acceptance_comment(text)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertTrue(any("NEXT_PROMPT" in r for r in result["reasons"]))
|
||||
|
||||
def test_vague_merge_only_completion_detected(self):
|
||||
text = "PR merged. Issue complete."
|
||||
self.assertTrue(issue_acceptance_gate.claims_merge_only_issue_complete(text))
|
||||
|
||||
def test_rejection_paths_require_missing_work(self):
|
||||
for state in (
|
||||
"more-work-required",
|
||||
"needs-tests",
|
||||
"needs-docs",
|
||||
"needs-feature-enhancement",
|
||||
"needs-follow-up-issue",
|
||||
):
|
||||
text = _rejection_comment(state=state).replace(
|
||||
"MISSING_WORK:\nAdd regression tests for controller rejection paths.\n",
|
||||
"MISSING_WORK:\n\n",
|
||||
)
|
||||
result = issue_acceptance_gate.validate_controller_acceptance_comment(text)
|
||||
self.assertFalse(result["valid"], state)
|
||||
self.assertTrue(any("MISSING_WORK" in r for r in result["reasons"]))
|
||||
|
||||
def test_accepted_requires_checked_criteria(self):
|
||||
text = _accepted_comment().replace(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED:\n- [x] documentation added\n- [x] validator tests added\n",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED:\n- [ ] documentation added\n",
|
||||
)
|
||||
result = issue_acceptance_gate.validate_controller_acceptance_comment(text)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertTrue(any("checked acceptance criterion" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestFinalReportIntegration(unittest.TestCase):
|
||||
def test_merge_only_complete_blocks_work_issue_report(self):
|
||||
report = (
|
||||
"## Controller Handoff\n\n"
|
||||
"- Task: work issue #500\n"
|
||||
"- Merge result: merged\n"
|
||||
"- Current status: PR merged; issue complete\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
self.assertTrue(
|
||||
any(
|
||||
f["rule_id"] == "shared.issue_acceptance_gate"
|
||||
for f in result["findings"]
|
||||
),
|
||||
result["findings"],
|
||||
)
|
||||
|
||||
def test_pending_acceptance_allowed(self):
|
||||
report = (
|
||||
"## Controller Handoff\n\n"
|
||||
"- Task: work issue #500\n"
|
||||
"- Merge result: merged\n"
|
||||
"- Current status: controller acceptance pending\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
self.assertFalse(
|
||||
any(
|
||||
f["rule_id"] == "shared.issue_acceptance_gate"
|
||||
for f in result["findings"]
|
||||
),
|
||||
result["findings"],
|
||||
)
|
||||
|
||||
def test_accepted_block_allows_complete_claim(self):
|
||||
report = _accepted_comment() + "\n\nIssue is complete."
|
||||
gate = issue_acceptance_gate.validate_final_report_issue_acceptance(report)
|
||||
self.assertTrue(gate["valid"], gate["reasons"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,257 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
|
||||
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = "comment_issue"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_reviewer_violation_files = []
|
||||
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self.addCleanup(self._restore_preflight_mode)
|
||||
|
||||
def _restore_preflight_mode(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
|
||||
def _git_state(self, valid_worktree: str):
|
||||
def side_effect(path):
|
||||
real = os.path.realpath(path)
|
||||
if real == os.path.realpath(CONTROL_CHECKOUT_ROOT):
|
||||
return {
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
if real == os.path.realpath(valid_worktree):
|
||||
return {
|
||||
"current_branch": "fix/issue-560-issue-comment-worktree-path",
|
||||
"head_sha": "b" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
return {
|
||||
"current_branch": "other",
|
||||
"head_sha": "c" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
|
||||
return side_effect
|
||||
|
||||
def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None):
|
||||
def side_effect(cmd, *args, **kwargs):
|
||||
result = MagicMock(returncode=0, stdout="")
|
||||
cwd = ""
|
||||
if isinstance(cmd, list) and "-C" in cmd:
|
||||
cwd = os.path.realpath(cmd[cmd.index("-C") + 1])
|
||||
|
||||
if isinstance(cmd, list) and "--show-toplevel" in cmd:
|
||||
if cwd == os.path.realpath(valid_worktree):
|
||||
result.stdout = f"{valid_worktree}\n"
|
||||
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
|
||||
result.stdout = f"{outside_worktree}\n"
|
||||
else:
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n"
|
||||
return result
|
||||
|
||||
if isinstance(cmd, list) and "--git-common-dir" in cmd:
|
||||
if cwd == os.path.realpath(valid_worktree):
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
|
||||
result.stdout = "/tmp/other-repo/.git\n"
|
||||
else:
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
return side_effect
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
def test_comment_rejects_control_checkout_without_worktree_path(
|
||||
self, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
mock_api.return_value = {"id": 42}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
mock_api.return_value = {"id": 43}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=valid_worktree,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertEqual(result["comment_id"], 43)
|
||||
method, url, _auth_arg, payload = mock_api.call_args[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
|
||||
self.assertEqual(payload, {"body": "canonical evidence"})
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_rejects_outside_repo_worktree_path(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
outside_worktree = "/tmp/not-gitea-tools-worktree"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree, outside_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=outside_worktree,
|
||||
)
|
||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_comment_still_requires_capability_preflight(self, mock_api, _auth):
|
||||
srv._preflight_capability_called = False
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
),
|
||||
)
|
||||
self.assertIn("Task capability", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_still_requires_issue_comment_permission(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
denied_env = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, denied_env, clear=True):
|
||||
result = srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=valid_worktree,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertIn("permission_report", result)
|
||||
self.assertEqual(
|
||||
result["permission_report"]["missing_permission"],
|
||||
"gitea.issue.comment",
|
||||
)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
+184
-5
@@ -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()
|
||||
@@ -3950,3 +3965,167 @@ class TestPreflightVerification(unittest.TestCase):
|
||||
self.assertIn("worktree_path argument", msg)
|
||||
self.assertIn("task_file.py", msg)
|
||||
self.assertIn("author namespace", msg)
|
||||
|
||||
|
||||
class TestIssue546Deadlock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import reviewer_pr_lease
|
||||
import review_workflow_load
|
||||
import mcp_server
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._preflight_capability_called = False
|
||||
mcp_server._preflight_resolved_role = None
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
|
||||
self.auth_user_patch = patch("mcp_server._authenticated_username", return_value="reviewer-bot")
|
||||
self.auth_user_patch.start()
|
||||
self.addCleanup(self.auth_user_patch.stop)
|
||||
|
||||
self.verify_purity_patch = patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||
self.verify_purity_patch.start()
|
||||
self.addCleanup(self.verify_purity_patch.stop)
|
||||
|
||||
self.verify_workspace_patch = patch("mcp_server._verify_role_mutation_workspace", return_value="/workspace")
|
||||
self.verify_workspace_patch.start()
|
||||
self.addCleanup(self.verify_workspace_patch.stop)
|
||||
|
||||
self.get_profile_patch = patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.review", "gitea.pr.approve"],
|
||||
"forbidden_operations": [],
|
||||
})
|
||||
self.get_profile_patch.start()
|
||||
self.addCleanup(self.get_profile_patch.stop)
|
||||
|
||||
self.pr_work_lease_patch = patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
||||
)
|
||||
self.pr_work_lease_patch.start()
|
||||
self.addCleanup(self.pr_work_lease_patch.stop)
|
||||
|
||||
self.auth_header_patch = patch("mcp_server.get_auth_header", return_value="token test")
|
||||
self.auth_header_patch.start()
|
||||
self.addCleanup(self.auth_header_patch.stop)
|
||||
|
||||
def test_workflow_no_deadlock(self):
|
||||
import mcp_server
|
||||
import reviewer_pr_lease
|
||||
|
||||
# 1. Resolve capability
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
|
||||
# 2. Load review workflow
|
||||
res = mcp_server.gitea_load_review_workflow()
|
||||
self.assertTrue(res["success"])
|
||||
|
||||
# Mock API requests for lease comments and PR retrieval
|
||||
comments = []
|
||||
def mock_api_side(method, url, auth, data=None):
|
||||
if "/api/v1/user" in url:
|
||||
return {"login": "reviewer-bot"}
|
||||
if "/pulls/" in url:
|
||||
if "/reviews" in url:
|
||||
if method == "POST":
|
||||
return {"id": 2001, "state": "APPROVED"}
|
||||
return [{"id": 2001, "user": {"login": "reviewer-bot"}, "state": "APPROVED", "commit_id": "abc123"}]
|
||||
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
|
||||
if "/comments" in url:
|
||||
if method == "POST":
|
||||
comments.append({"id": 1001, "body": data["body"], "user": {"login": "reviewer-bot"}})
|
||||
return {"id": 1001}
|
||||
return comments
|
||||
return {}
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=mock_api_side):
|
||||
# 3. Acquire reviewer lease
|
||||
res = mcp_server.gitea_acquire_reviewer_pr_lease(
|
||||
pr_number=550,
|
||||
worktree="/workspace",
|
||||
candidate_head="abc123",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["comment_id"], 1001)
|
||||
|
||||
# verify lease is recorded in session
|
||||
session_lease = reviewer_pr_lease.get_session_lease()
|
||||
self.assertIsNotNone(session_lease)
|
||||
self.assertEqual(session_lease["pr_number"], 550)
|
||||
|
||||
# 4. Resolve capability again (simulating subsequent tool call preflight check/token refresh)
|
||||
# This should NOT clear the session lease or decision lock!
|
||||
mcp_server._preflight_capability_called = False # simulate clearance from prior mutation
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertIsNotNone(reviewer_pr_lease.get_session_lease()) # Preserved!
|
||||
|
||||
# 5. Mark final review decision APPROVED
|
||||
res = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=550,
|
||||
action="approve",
|
||||
expected_head_sha="abc123",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["marked_ready"])
|
||||
|
||||
# 6. Submit review (should succeed without deadlock)
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=550,
|
||||
action="approve",
|
||||
body="APPROVED",
|
||||
expected_head_sha="abc123",
|
||||
final_review_decision_ready=True,
|
||||
remote="prgs",
|
||||
worktree_path="/workspace",
|
||||
)
|
||||
self.assertTrue(res["performed"])
|
||||
|
||||
def test_release_reviewer_pr_lease(self):
|
||||
import mcp_server
|
||||
import reviewer_pr_lease
|
||||
|
||||
# Resolve capability and load workflow
|
||||
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
comments = [_reviewer_lease_comment(550, session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123")]
|
||||
posted_comments = []
|
||||
|
||||
def mock_api_side(method, url, auth, data=None):
|
||||
if "/api/v1/user" in url:
|
||||
return {"login": "reviewer-bot"}
|
||||
if "/pulls/" in url:
|
||||
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
|
||||
if "/comments" in url:
|
||||
if method == "POST":
|
||||
new_c = {"id": 1002, "body": data["body"], "user": {"login": "reviewer-bot"}}
|
||||
comments.append(new_c)
|
||||
posted_comments.append(new_c)
|
||||
return {"id": 1002}
|
||||
return comments
|
||||
return {}
|
||||
|
||||
# Set session lease in memory
|
||||
import merger_lease_adoption as mla
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 550,
|
||||
"session_id": _DEFAULT_LEASE_SESSION,
|
||||
"candidate_head": "abc123",
|
||||
"target_branch": "master",
|
||||
"comment_id": 9001,
|
||||
}, lease_provenance=mla.build_lease_provenance(source=mla.SOURCE_ACQUIRE, comment_id=9001))
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=mock_api_side):
|
||||
# Release lease
|
||||
res = mcp_server.gitea_release_reviewer_pr_lease(pr_number=550, remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["released"])
|
||||
self.assertEqual(res["comment_id"], 1002)
|
||||
|
||||
# verify lease is cleared in session
|
||||
self.assertIsNone(reviewer_pr_lease.get_session_lease())
|
||||
# verify comment phase is released
|
||||
self.assertIn("phase: released", posted_comments[0]["body"])
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for durable MCP session state shared across daemon processes (#559)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys_path_root = str(Path(__file__).resolve().parent.parent)
|
||||
import sys
|
||||
|
||||
if sys_path_root not in sys.path:
|
||||
sys.path.insert(0, sys_path_root)
|
||||
|
||||
import mcp_session_state
|
||||
import review_workflow_load
|
||||
import mcp_server
|
||||
|
||||
|
||||
class TestMcpSessionStateStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self.state_dir = self._tmpdir.name
|
||||
self._env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
mcp_session_state.STATE_DIR_ENV: self.state_dir,
|
||||
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def test_round_trip_same_identity(self):
|
||||
saved = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload={"loaded": True, "workflow_hash": "abc123def456"},
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertIsNotNone(saved)
|
||||
self.assertEqual(saved["workflow_hash"], "abc123def456")
|
||||
self.assertIn("recorded_at", saved)
|
||||
|
||||
loaded = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertIsNotNone(loaded)
|
||||
self.assertEqual(loaded["workflow_hash"], "abc123def456")
|
||||
self.assertEqual(loaded["profile_identity"], "prgs-reviewer")
|
||||
|
||||
def test_profile_mismatch_returns_none(self):
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
payload={"final_review_decision_ready": True, "remote": "prgs"},
|
||||
remote="prgs",
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-author"},
|
||||
clear=False,
|
||||
):
|
||||
loaded = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertIsNone(loaded)
|
||||
|
||||
def test_clear_removes_file(self):
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload={"loaded": True},
|
||||
remote="prgs",
|
||||
)
|
||||
path = mcp_session_state.state_file_path(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote="prgs",
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self.state_dir,
|
||||
)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote="prgs",
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_files_are_private_mode(self):
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
payload={"loaded": True},
|
||||
remote="prgs",
|
||||
)
|
||||
path = mcp_session_state.state_file_path(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
remote="prgs",
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self.state_dir,
|
||||
)
|
||||
mode = os.stat(path).st_mode & 0o777
|
||||
self.assertEqual(mode, 0o600)
|
||||
|
||||
|
||||
class TestWorkflowLoadCrossProcess(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
mcp_session_state.STATE_DIR_ENV: self._tmpdir.name,
|
||||
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||
"GITEA_MCP_REMOTE": "prgs",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._env.start()
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def tearDown(self):
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
self._env.stop()
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def test_different_pid_same_profile_accepted(self):
|
||||
root = str(Path(__file__).resolve().parent.parent)
|
||||
recorded = review_workflow_load.record_review_workflow_load(root)
|
||||
self.assertTrue(recorded["loaded"])
|
||||
|
||||
# Simulate another daemon process: clear memory, keep durable file,
|
||||
# and change apparent PID identity only (profile remains the same).
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
status = review_workflow_load.workflow_load_status(root)
|
||||
self.assertTrue(status["workflow_load_proof_present"])
|
||||
self.assertTrue(
|
||||
status["workflow_load_valid"],
|
||||
msg=status.get("reasons"),
|
||||
)
|
||||
|
||||
def test_profile_mismatch_blocks(self):
|
||||
root = str(Path(__file__).resolve().parent.parent)
|
||||
review_workflow_load.record_review_workflow_load(root)
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-author"},
|
||||
clear=False,
|
||||
):
|
||||
# Durable load uses active identity; mismatched profile key misses.
|
||||
status = review_workflow_load.workflow_load_status(root)
|
||||
self.assertFalse(status["workflow_load_proof_present"])
|
||||
|
||||
|
||||
class TestDecisionLockCrossProcess(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
mcp_session_state.STATE_DIR_ENV: self._tmpdir.name,
|
||||
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._env.start()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
self._env.stop()
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def test_decision_lock_survives_memory_clear(self):
|
||||
with patch.object(mcp_server, "get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
}):
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr", force=True)
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
self.assertIsNotNone(lock)
|
||||
self.assertEqual(lock["remote"], "prgs")
|
||||
self.assertFalse(lock["final_review_decision_ready"])
|
||||
|
||||
# New process: memory empty, durable state remains.
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
restored = mcp_server._load_review_decision_lock()
|
||||
self.assertIsNotNone(restored)
|
||||
self.assertEqual(restored["remote"], "prgs")
|
||||
reasons = mcp_server._review_decision_session_reasons(restored)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime
|
||||
|
||||
import gitea_mcp_server
|
||||
|
||||
class TestMcpStaleRuntime(unittest.TestCase):
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch.dict("os.environ", {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"})
|
||||
def test_stale_and_missing_runtimes(self, mock_getpid, mock_exists, mock_getmtime, mock_run):
|
||||
# Setup mocks
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
|
||||
# Code modification time: Jul 8 2026, 14:00:00
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime.return_value = code_time.timestamp()
|
||||
|
||||
# Mock ps -ax output
|
||||
# PID 12345 is self (started at 13:00:00 - stale)
|
||||
# PID 54321 is prgs-author (started at 15:00:00 - fresh)
|
||||
# prgs-reviewer is missing
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
"54321 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
# Mock env output for ps eww
|
||||
mock_run_env12345 = MagicMock()
|
||||
mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler"
|
||||
|
||||
mock_run_env54321 = MagicMock()
|
||||
mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
pid = args[2]
|
||||
if pid == "12345":
|
||||
return mock_run_env12345
|
||||
elif pid == "54321":
|
||||
return mock_run_env54321
|
||||
elif args[0] == "ps":
|
||||
return mock_run_ps
|
||||
raise ValueError(f"Unexpected subprocess run args: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
# Test 1: required reviewer role matching prgs-reviewer (which is missing)
|
||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("review_pr", ["prgs-reviewer"])
|
||||
|
||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||
self.assertTrue(any("stale-runtime: None of the matching profiles for task" in r for r in reasons))
|
||||
|
||||
# Test 2: required author role matching prgs-author (which is fresh)
|
||||
reasons_author = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||
|
||||
# Still contains self stale error
|
||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons_author))
|
||||
# But does NOT contain missing author profile error
|
||||
self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
@@ -154,6 +154,195 @@ class TestMergedCleanupReport(unittest.TestCase):
|
||||
finally:
|
||||
os.remove(lock_path)
|
||||
|
||||
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||
@patch("subprocess.run")
|
||||
def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state):
|
||||
mock_run.return_value = Mock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"worktree /repo/Gitea-Tools\n"
|
||||
"HEAD aaaa\n"
|
||||
"branch refs/heads/master\n\n"
|
||||
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
|
||||
"HEAD cafebabe\n"
|
||||
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||
),
|
||||
)
|
||||
mock_state.side_effect = [
|
||||
{
|
||||
"exists": False,
|
||||
"clean": None,
|
||||
"current_branch": None,
|
||||
"head_sha": None,
|
||||
"dirty_files": [],
|
||||
},
|
||||
{
|
||||
"exists": True,
|
||||
"clean": True,
|
||||
"current_branch": "feat/issue-510-workspace-binding-isolation",
|
||||
"head_sha": "cafebabe",
|
||||
"dirty_files": [],
|
||||
},
|
||||
]
|
||||
|
||||
report = mcr.build_reconciliation_report(
|
||||
project_root="/repo/Gitea-Tools",
|
||||
closed_prs=[
|
||||
{
|
||||
"number": 512,
|
||||
"title": "merged cleanup",
|
||||
"body": "Closes #510",
|
||||
"head": {
|
||||
"ref": "feat/issue-510-workspace-binding-isolation",
|
||||
"sha": "cafebabe",
|
||||
},
|
||||
"merged_at": "2026-07-06T12:00:00Z",
|
||||
"merge_commit_sha": "abc123",
|
||||
}
|
||||
],
|
||||
open_prs=[],
|
||||
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
|
||||
head_on_master={512: True},
|
||||
delete_capability_allowed=True,
|
||||
target_ref="prgs/master",
|
||||
)
|
||||
|
||||
local = report["entries"][0]["local_worktree"]
|
||||
self.assertTrue(local["safe_to_remove_worktree"])
|
||||
self.assertEqual(local["match_type"], "alias")
|
||||
self.assertEqual(
|
||||
local["expected_worktree_path"],
|
||||
"/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation",
|
||||
)
|
||||
self.assertEqual(
|
||||
local["discovered_worktree_path"],
|
||||
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||
)
|
||||
|
||||
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||
@patch("subprocess.run")
|
||||
def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state):
|
||||
mock_run.return_value = Mock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"worktree /repo/Gitea-Tools/branches/issue-510-one\n"
|
||||
"HEAD cafebabe\n"
|
||||
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||
"worktree /repo/Gitea-Tools/branches/issue-510-two\n"
|
||||
"HEAD cafebabe\n"
|
||||
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||
),
|
||||
)
|
||||
mock_state.return_value = {
|
||||
"exists": False,
|
||||
"clean": None,
|
||||
"current_branch": None,
|
||||
"head_sha": None,
|
||||
"dirty_files": [],
|
||||
}
|
||||
|
||||
report = mcr.build_reconciliation_report(
|
||||
project_root="/repo/Gitea-Tools",
|
||||
closed_prs=[
|
||||
{
|
||||
"number": 512,
|
||||
"title": "merged cleanup",
|
||||
"body": "Closes #510",
|
||||
"head": {
|
||||
"ref": "feat/issue-510-workspace-binding-isolation",
|
||||
"sha": "cafebabe",
|
||||
},
|
||||
"merged_at": "2026-07-06T12:00:00Z",
|
||||
"merge_commit_sha": "abc123",
|
||||
}
|
||||
],
|
||||
open_prs=[],
|
||||
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
|
||||
head_on_master={512: True},
|
||||
delete_capability_allowed=True,
|
||||
target_ref="prgs/master",
|
||||
)
|
||||
|
||||
local = report["entries"][0]["local_worktree"]
|
||||
self.assertFalse(local["safe_to_remove_worktree"])
|
||||
self.assertEqual(local["match_type"], "ambiguous_alias")
|
||||
self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0])
|
||||
|
||||
@patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||
@patch("merged_cleanup_reconcile.read_local_worktree_state")
|
||||
@patch("subprocess.run")
|
||||
def test_alias_head_on_target_branch_is_safe_cleanup_commit(
|
||||
self, mock_run, mock_state, _ancestor
|
||||
):
|
||||
mock_run.return_value = Mock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
|
||||
"HEAD deadbeef\n"
|
||||
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
|
||||
),
|
||||
)
|
||||
mock_state.side_effect = [
|
||||
{
|
||||
"exists": False,
|
||||
"clean": None,
|
||||
"current_branch": None,
|
||||
"head_sha": None,
|
||||
"dirty_files": [],
|
||||
},
|
||||
{
|
||||
"exists": True,
|
||||
"clean": True,
|
||||
"current_branch": "feat/issue-510-workspace-binding-isolation",
|
||||
"head_sha": "deadbeef",
|
||||
"dirty_files": [],
|
||||
},
|
||||
]
|
||||
|
||||
state = mcr.resolve_cleanup_worktree_state(
|
||||
project_root="/repo/Gitea-Tools",
|
||||
head_branch="feat/issue-510-workspace-binding-isolation",
|
||||
issue_number=510,
|
||||
pr_head_sha="cafebabe",
|
||||
target_ref="prgs/master",
|
||||
)
|
||||
|
||||
self.assertTrue(state["exists"])
|
||||
self.assertEqual(state["match_type"], "alias")
|
||||
self.assertEqual(
|
||||
state["discovered_worktree_path"],
|
||||
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||
)
|
||||
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("subprocess.run")
|
||||
def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir):
|
||||
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||
result = mcr.remove_local_worktree(
|
||||
"/repo/Gitea-Tools",
|
||||
"feat/issue-510-workspace-binding-isolation",
|
||||
worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(
|
||||
result["worktree_path"],
|
||||
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||
)
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
"/repo/Gitea-Tools",
|
||||
"worktree",
|
||||
"remove",
|
||||
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,179 @@
|
||||
"""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_profile_identity_mismatch_blocks(self):
|
||||
"""#559: different daemon PIDs are OK; profile identity mismatch is not."""
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import mcp_session_state
|
||||
|
||||
root = str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
mcp_session_state.STATE_DIR_ENV: tmp,
|
||||
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
review_workflow_load.record_review_workflow_load(root)
|
||||
# Corrupt the in-memory profile identity while keeping PID.
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD[
|
||||
"session_profile_lock"
|
||||
] = "other-profile"
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD[
|
||||
"profile_identity"
|
||||
] = "other-profile"
|
||||
blockers = review_workflow_load.review_workflow_load_blockers(root)
|
||||
self.assertTrue(
|
||||
any("profile identity mismatch" in b for b in blockers),
|
||||
msg=blockers,
|
||||
)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for exact per-mutation capability proof in reviewer reports (#405)."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||
from review_proofs import assess_mutation_capability_proof as proofs_assess
|
||||
from final_report_validator import assess_final_report_validator
|
||||
|
||||
|
||||
REVIEW_ONLY = """
|
||||
Review decision: approved
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | resolved before review
|
||||
"""
|
||||
|
||||
MERGE_PROVEN = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | resolved before merge
|
||||
"""
|
||||
|
||||
MERGE_AND_DELETE_PROVEN = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Remote branch deleted: feat/issue-x
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
- gitea_delete_branch | delete_branch (gitea.branch.delete) | deleted | before delete
|
||||
"""
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
def test_review_only_with_capability_passes(self):
|
||||
r = assess_mutation_capability_proof(REVIEW_ONLY)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_merge_with_exact_capability_passes(self):
|
||||
r = assess_mutation_capability_proof(MERGE_PROVEN)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_merge_and_delete_fully_proven_passes(self):
|
||||
r = assess_mutation_capability_proof(MERGE_AND_DELETE_PROVEN)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_review_pr_does_not_authorize_merge(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("merge" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_merge_pr_does_not_authorize_branch_deletion(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Remote branch deleted: feat/issue-x
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("delet" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_delete_skipped_when_capability_missing_passes(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Branch deletion: skipped — delete_branch capability not available
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_missing_table_when_merging_blocks(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
merge_pr gitea.pr.merge resolved
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("table" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_post_hoc_proof_blocks(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | capability resolved after merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("after" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_review_without_capability_blocks(self):
|
||||
report = "Review decision: approved\nreview submitted\n"
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
|
||||
def test_no_mutation_no_requirement(self):
|
||||
r = assess_mutation_capability_proof("Selected PR: #1\nSkipped, no action.")
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
|
||||
class TestWiring(unittest.TestCase):
|
||||
def test_review_proofs_wrapper_matches_module(self):
|
||||
self.assertEqual(
|
||||
proofs_assess(MERGE_PROVEN)["proven"],
|
||||
assess_mutation_capability_proof(MERGE_PROVEN)["proven"],
|
||||
)
|
||||
|
||||
def test_final_report_validator_flags_nearby_capability_merge(self):
|
||||
report = """
|
||||
## Controller Handoff
|
||||
- Task: review_pr
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
"""
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rule_ids = [f["rule_id"] for f in result.get("findings", [])]
|
||||
self.assertIn("reviewer.mutation_capability_proof", rule_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Contract checks for the root-level `run-tests.sh` convenience runner (#473).
|
||||
|
||||
`run-tests.sh` is the canonical full-validation entry point. It must invoke the
|
||||
project virtualenv interpreter, forward extra args to pytest, fail closed when
|
||||
the venv Python is missing, and stay repo-local (no network, no lock files).
|
||||
These checks pin that behavior so it cannot silently regress, and confirm the
|
||||
developer testing guide names the runner.
|
||||
"""
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "run-tests.sh"
|
||||
GUIDE = REPO_ROOT / "docs" / "developer-testing-guidelines.md"
|
||||
|
||||
|
||||
def _script_text() -> str:
|
||||
return SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_run_tests_script_exists():
|
||||
assert SCRIPT.is_file(), "run-tests.sh must exist at the repository root"
|
||||
|
||||
|
||||
def test_run_tests_script_is_executable():
|
||||
mode = SCRIPT.stat().st_mode
|
||||
assert mode & stat.S_IXUSR, "run-tests.sh must be executable (chmod +x)"
|
||||
|
||||
|
||||
def test_run_tests_script_has_strict_bash_flags():
|
||||
text = _script_text()
|
||||
assert text.startswith("#!/usr/bin/env bash"), "must use the bash shebang"
|
||||
assert "set -euo pipefail" in text, "must set -euo pipefail"
|
||||
|
||||
|
||||
def test_run_tests_script_uses_venv_pytest_and_forwards_args():
|
||||
text = _script_text()
|
||||
# Resolves the venv interpreter relative to the script's own directory.
|
||||
assert "venv/bin/python" in text, "must use the project virtualenv Python"
|
||||
assert "-m pytest" in text, "must run pytest via the module"
|
||||
assert '"$@"' in text, "must forward extra CLI args to pytest"
|
||||
|
||||
|
||||
def test_run_tests_script_fails_closed_without_venv():
|
||||
text = _script_text()
|
||||
# Missing venv must be an explicit, non-zero-exit error, not a silent
|
||||
# fallback to the wrong interpreter.
|
||||
assert "if [[ ! -x" in text, "must guard on an executable venv Python"
|
||||
assert "exit 1" in text, "must exit non-zero when the venv is missing"
|
||||
assert "ERROR" in text, "must print a clear error message"
|
||||
|
||||
|
||||
def test_run_tests_script_stays_repo_local():
|
||||
text = _script_text()
|
||||
# No network calls, no lock-file writes from the runner itself.
|
||||
for forbidden in ("curl", "wget", "gitea_issue_lock.json"):
|
||||
assert forbidden not in text, f"runner must not reference {forbidden!r}"
|
||||
|
||||
|
||||
def test_guide_names_canonical_runner():
|
||||
text = " ".join(GUIDE.read_text(encoding="utf-8").split())
|
||||
assert "./run-tests.sh" in text, "testing guide must name ./run-tests.sh"
|
||||
assert "./run-tests.sh tests/test_mcp_server.py -q" in text, (
|
||||
"testing guide must show the focused-validation example"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for web UI lease visibility (#433)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from webui.app import create_app
|
||||
from webui.lease_loader import (
|
||||
_duplicate_branch_warnings,
|
||||
_duplicate_pr_warnings,
|
||||
load_lease_snapshot,
|
||||
parse_reviewer_lease_comment,
|
||||
snapshot_to_dict,
|
||||
)
|
||||
|
||||
|
||||
class TestLeaseLoader(unittest.TestCase):
|
||||
def test_parse_reviewer_lease_comment(self):
|
||||
body = (
|
||||
"<!-- mcp-review-lease:v1 -->\n"
|
||||
"pr: #42\n"
|
||||
"reviewer_identity: sysadmin\n"
|
||||
"profile: prgs-reviewer\n"
|
||||
"phase: validating\n"
|
||||
"expires_at: 2026-07-07T20:00:00Z\n"
|
||||
)
|
||||
parsed = parse_reviewer_lease_comment(body)
|
||||
self.assertIsNotNone(parsed)
|
||||
assert parsed is not None
|
||||
self.assertEqual(parsed["pr_number"], 42)
|
||||
self.assertEqual(parsed["phase"], "validating")
|
||||
|
||||
def test_duplicate_pr_warnings(self):
|
||||
raw_prs = [
|
||||
{"number": 1, "title": "Closes #99", "body": ""},
|
||||
{"number": 2, "title": "fixes #99", "body": ""},
|
||||
]
|
||||
warnings = _duplicate_pr_warnings(raw_prs)
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertEqual(warnings[0].issue_number, 99)
|
||||
self.assertEqual(warnings[0].pr_numbers, (1, 2))
|
||||
|
||||
def test_duplicate_branch_warnings(self):
|
||||
warnings = _duplicate_branch_warnings([
|
||||
"feat/issue-12-a",
|
||||
"feat/issue-12-b",
|
||||
"master",
|
||||
])
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertEqual(warnings[0].issue_number, 12)
|
||||
|
||||
def test_load_snapshot_with_injected_fetch(self):
|
||||
def fetch_prs(_h, _o, _r, _a):
|
||||
return ([], None)
|
||||
|
||||
def fetch_issues(_h, _o, _r, _a):
|
||||
return ([], None)
|
||||
|
||||
snapshot = load_lease_snapshot(
|
||||
fetch_prs=fetch_prs,
|
||||
fetch_issues=fetch_issues,
|
||||
fetch_comments=lambda *_a, **_k: [],
|
||||
issue_lock_path=str(Path("/nonexistent/lock.json")),
|
||||
)
|
||||
data = snapshot_to_dict(snapshot)
|
||||
self.assertIn("claim_inventory", data)
|
||||
self.assertIn("collision_history", data)
|
||||
|
||||
|
||||
class TestLeaseRoutes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_leases_page_renders(self):
|
||||
response = self.client.get("/leases")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Leases", response.text)
|
||||
self.assertIn("Collision warnings", response.text)
|
||||
self.assertNotIn("child issue", response.text.lower())
|
||||
|
||||
def test_api_leases_json(self):
|
||||
response = self.client.get("/api/leases")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("claim_inventory", data)
|
||||
self.assertIn("duplicate_prs", data)
|
||||
self.assertIn("reviewer_leases", data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,10 +46,14 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Gitea-Tools", response.text)
|
||||
|
||||
def test_leases_is_implemented(self):
|
||||
response = self.client.get("/leases")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Leases", response.text)
|
||||
self.assertIn("Collision warnings", response.text)
|
||||
|
||||
def test_extra_stub_routes(self):
|
||||
for path in ("/worktrees", "/leases"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client.get(path).status_code, 200)
|
||||
self.assertEqual(self.client.get("/worktrees").status_code, 200)
|
||||
|
||||
def test_post_is_rejected(self):
|
||||
response = self.client.post("/health")
|
||||
@@ -62,10 +66,10 @@ class TestWebuiSkeleton(unittest.TestCase):
|
||||
self.assertIn("Live queue", response.text)
|
||||
|
||||
def test_nav_links_on_all_pages(self):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
|
||||
with self.subTest(path=path):
|
||||
text = self.client.get(path).text
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
|
||||
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
|
||||
self.assertIn(f'href="{href}"', text)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
+9
-4
@@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
|
||||
from webui.project_views import render_project_detail, render_projects_list
|
||||
from webui.prompt_library import find_prompt, library_to_dict
|
||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||
from webui.lease_views import render_leases_page
|
||||
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
|
||||
from webui.queue_views import render_queue_page
|
||||
|
||||
@@ -141,10 +143,12 @@ async def worktrees(_request: Request) -> HTMLResponse:
|
||||
|
||||
|
||||
async def leases(_request: Request) -> HTMLResponse:
|
||||
return _stub_page(
|
||||
"Leases",
|
||||
"Lease visibility will show active issue and reviewer PR leases.",
|
||||
)
|
||||
snapshot = load_lease_snapshot()
|
||||
return HTMLResponse(render_leases_page(snapshot))
|
||||
|
||||
|
||||
async def api_leases(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
@@ -175,6 +179,7 @@ def create_app() -> Starlette:
|
||||
Route("/audit", audit, methods=["GET"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
Route("/leases", leases, methods=["GET"]),
|
||||
Route("/api/leases", api_leases, methods=["GET"]),
|
||||
],
|
||||
exception_handlers={405: method_not_allowed},
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Lease and collision visibility for the web UI (#433)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
||||
from issue_claim_heartbeat import build_claim_inventory
|
||||
from merged_cleanup_reconcile import read_issue_lock
|
||||
from issue_lock_provenance import ISSUE_LOCK_FILE
|
||||
|
||||
from webui.project_registry import ProjectRecord, load_registry
|
||||
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs
|
||||
|
||||
_REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
_REVIEWER_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||
|
||||
_COLLISION_HISTORY = (
|
||||
{"number": 267, "title": "Author work leases"},
|
||||
{"number": 268, "title": "Issue claim heartbeat leases"},
|
||||
{"number": 400, "title": "Early duplicate-work detection"},
|
||||
{"number": 407, "title": "Per-PR reviewer leases"},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollisionWarning:
|
||||
kind: str
|
||||
message: str
|
||||
issue_number: int | None = None
|
||||
pr_numbers: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeaseSnapshot:
|
||||
project_id: str
|
||||
repo_label: str
|
||||
issue_lock: dict[str, Any] | None
|
||||
claim_inventory: dict[str, Any]
|
||||
reviewer_leases: tuple[dict[str, Any], ...]
|
||||
duplicate_prs: tuple[CollisionWarning, ...]
|
||||
duplicate_branches: tuple[CollisionWarning, ...]
|
||||
collision_history: tuple[dict[str, Any], ...]
|
||||
fetch_error: str | None = None
|
||||
|
||||
|
||||
def _repo_root() -> str:
|
||||
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||
if override:
|
||||
return os.path.realpath(override)
|
||||
return os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _host_from_url(remote_host: str) -> str:
|
||||
parsed = urlparse(remote_host.strip())
|
||||
return parsed.netloc or remote_host.strip().rstrip("/")
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
text = body or ""
|
||||
if _REVIEWER_LEASE_MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _REVIEWER_FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_comments(
|
||||
host: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
auth: str,
|
||||
*,
|
||||
issue_number: int,
|
||||
) -> list[dict]:
|
||||
url = f"{repo_api_url(host, org, repo)}/issues/{issue_number}/comments"
|
||||
comments: list[dict] = []
|
||||
page = 1
|
||||
while page <= 10:
|
||||
raw_page, meta = api_fetch_page(url, auth, page=page, limit=50)
|
||||
comments.extend(raw_page)
|
||||
if meta.get("is_final_page"):
|
||||
break
|
||||
page += 1
|
||||
return comments
|
||||
|
||||
|
||||
def _list_local_branch_names(project_root: str) -> list[str]:
|
||||
result = subprocess.run(
|
||||
["git", "-C", project_root, "branch", "--list", "--format=%(refname:short)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _duplicate_pr_warnings(raw_prs: list[dict]) -> list[CollisionWarning]:
|
||||
issue_to_prs: dict[int, list[int]] = {}
|
||||
for pr in raw_prs:
|
||||
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
|
||||
if linked is None:
|
||||
continue
|
||||
issue_to_prs.setdefault(linked, []).append(int(pr["number"]))
|
||||
warnings: list[CollisionWarning] = []
|
||||
for issue_number, pr_numbers in sorted(issue_to_prs.items()):
|
||||
if len(pr_numbers) > 1:
|
||||
warnings.append(
|
||||
CollisionWarning(
|
||||
kind="duplicate-pr",
|
||||
issue_number=issue_number,
|
||||
pr_numbers=tuple(sorted(pr_numbers)),
|
||||
message=(
|
||||
f"Issue #{issue_number} has {len(pr_numbers)} open PRs: "
|
||||
f"{', '.join(f'#{n}' for n in sorted(pr_numbers))} (#400)"
|
||||
),
|
||||
)
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _duplicate_branch_warnings(branch_names: list[str]) -> list[CollisionWarning]:
|
||||
issue_to_branches: dict[int, list[str]] = {}
|
||||
for name in branch_names:
|
||||
match = _ISSUE_BRANCH_RE.search(name)
|
||||
if not match:
|
||||
continue
|
||||
issue_num = int(match.group(1))
|
||||
issue_to_branches.setdefault(issue_num, []).append(name)
|
||||
warnings: list[CollisionWarning] = []
|
||||
for issue_number, branches in sorted(issue_to_branches.items()):
|
||||
if len(branches) > 1:
|
||||
warnings.append(
|
||||
CollisionWarning(
|
||||
kind="duplicate-branch",
|
||||
issue_number=issue_number,
|
||||
message=(
|
||||
f"Issue #{issue_number} has {len(branches)} local branches: "
|
||||
f"{', '.join(branches)}"
|
||||
),
|
||||
)
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _extract_reviewer_leases(
|
||||
raw_prs: list[dict],
|
||||
*,
|
||||
fetch_comments: Callable[[int], list[dict]],
|
||||
) -> list[dict[str, Any]]:
|
||||
leases: list[dict[str, Any]] = []
|
||||
for pr in raw_prs:
|
||||
pr_number = int(pr["number"])
|
||||
for comment in fetch_comments(pr_number):
|
||||
parsed = parse_reviewer_lease_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
leases.append(
|
||||
{
|
||||
**parsed,
|
||||
"pr_number": parsed.get("pr_number") or pr_number,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login"),
|
||||
"created_at": comment.get("created_at"),
|
||||
}
|
||||
)
|
||||
active_phases = {"claimed", "validating", "approved", "request-changes", "merging"}
|
||||
return [lease for lease in leases if (lease.get("phase") or "") in active_phases]
|
||||
|
||||
|
||||
def load_lease_snapshot(
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
project_root: str | None = None,
|
||||
issue_lock_path: str | None = None,
|
||||
fetch_prs: Callable | None = None,
|
||||
fetch_issues: Callable | None = None,
|
||||
fetch_comments: Callable[[str, str, str, str, int], list[dict]] | None = None,
|
||||
) -> LeaseSnapshot:
|
||||
registry = load_registry()
|
||||
project: ProjectRecord | None = None
|
||||
if project_id:
|
||||
project = next((p for p in registry.projects if p.id == project_id), None)
|
||||
else:
|
||||
project = registry.projects[0] if registry.projects else None
|
||||
|
||||
root = project_root or _repo_root()
|
||||
lock = read_issue_lock(issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE)
|
||||
|
||||
if project is None:
|
||||
return LeaseSnapshot(
|
||||
project_id=project_id or "",
|
||||
repo_label="",
|
||||
issue_lock=lock,
|
||||
claim_inventory={"entries": [], "counts": {}},
|
||||
reviewer_leases=(),
|
||||
duplicate_prs=(),
|
||||
duplicate_branches=(),
|
||||
collision_history=_COLLISION_HISTORY,
|
||||
fetch_error="project not found in registry",
|
||||
)
|
||||
|
||||
host = _host_from_url(project.remote_host)
|
||||
pr_fetch = fetch_prs or _fetch_prs
|
||||
issue_fetch = fetch_issues or _fetch_issues
|
||||
comment_fetch = fetch_comments or _fetch_comments
|
||||
using_live = fetch_prs is None or fetch_issues is None
|
||||
auth = get_auth_header(host) if using_live else "test-auth"
|
||||
|
||||
if using_live and not auth:
|
||||
return LeaseSnapshot(
|
||||
project_id=project.id,
|
||||
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||
issue_lock=lock,
|
||||
claim_inventory={"entries": [], "counts": {}},
|
||||
reviewer_leases=(),
|
||||
duplicate_prs=(),
|
||||
duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)),
|
||||
collision_history=_COLLISION_HISTORY,
|
||||
fetch_error=(
|
||||
f"Gitea credentials unavailable for {host}; "
|
||||
"remote lease artifacts cannot be loaded"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
raw_prs, _ = pr_fetch(host, project.gitea_owner, project.repo_name, auth)
|
||||
raw_issues, _ = issue_fetch(host, project.gitea_owner, project.repo_name, auth)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return LeaseSnapshot(
|
||||
project_id=project.id,
|
||||
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||
issue_lock=lock,
|
||||
claim_inventory={"entries": [], "counts": {}},
|
||||
reviewer_leases=(),
|
||||
duplicate_prs=(),
|
||||
duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)),
|
||||
collision_history=_COLLISION_HISTORY,
|
||||
fetch_error=f"Gitea fetch failed: {exc}",
|
||||
)
|
||||
|
||||
comments_by_issue: dict[int, list[dict]] = {}
|
||||
for issue in raw_issues:
|
||||
if not any(lb.get("name") == "status:in-progress" for lb in issue.get("labels", [])):
|
||||
continue
|
||||
number = int(issue["number"])
|
||||
comments_by_issue[number] = comment_fetch(
|
||||
host, project.gitea_owner, project.repo_name, auth, issue_number=number
|
||||
)
|
||||
|
||||
branch_names = _list_local_branch_names(root)
|
||||
inventory = build_claim_inventory(
|
||||
issues=raw_issues,
|
||||
comments_by_issue=comments_by_issue,
|
||||
open_prs=raw_prs,
|
||||
branch_names=branch_names,
|
||||
)
|
||||
|
||||
def _pr_comments(pr_number: int) -> list[dict]:
|
||||
return comment_fetch(
|
||||
host, project.gitea_owner, project.repo_name, auth, issue_number=pr_number
|
||||
)
|
||||
|
||||
reviewer_leases = tuple(_extract_reviewer_leases(raw_prs, fetch_comments=_pr_comments))
|
||||
|
||||
return LeaseSnapshot(
|
||||
project_id=project.id,
|
||||
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||
issue_lock=lock,
|
||||
claim_inventory=inventory,
|
||||
reviewer_leases=reviewer_leases,
|
||||
duplicate_prs=tuple(_duplicate_pr_warnings(raw_prs)),
|
||||
duplicate_branches=tuple(_duplicate_branch_warnings(branch_names)),
|
||||
collision_history=_COLLISION_HISTORY,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_to_dict(snapshot: LeaseSnapshot) -> dict[str, Any]:
|
||||
return {
|
||||
"project_id": snapshot.project_id,
|
||||
"repo_label": snapshot.repo_label,
|
||||
"issue_lock": snapshot.issue_lock,
|
||||
"claim_inventory": snapshot.claim_inventory,
|
||||
"reviewer_leases": list(snapshot.reviewer_leases),
|
||||
"duplicate_prs": [
|
||||
{
|
||||
"kind": w.kind,
|
||||
"message": w.message,
|
||||
"issue_number": w.issue_number,
|
||||
"pr_numbers": list(w.pr_numbers),
|
||||
}
|
||||
for w in snapshot.duplicate_prs
|
||||
],
|
||||
"duplicate_branches": [
|
||||
{
|
||||
"kind": w.kind,
|
||||
"message": w.message,
|
||||
"issue_number": w.issue_number,
|
||||
}
|
||||
for w in snapshot.duplicate_branches
|
||||
],
|
||||
"collision_history": list(snapshot.collision_history),
|
||||
"fetch_error": snapshot.fetch_error,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"""HTML views for lease and collision visibility (#433)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.lease_loader import LeaseSnapshot
|
||||
|
||||
|
||||
def _escape(text: str) -> str:
|
||||
return html.escape(text, quote=True)
|
||||
|
||||
|
||||
def _warnings_block(snapshot: LeaseSnapshot) -> str:
|
||||
warnings = list(snapshot.duplicate_prs) + list(snapshot.duplicate_branches)
|
||||
if not warnings:
|
||||
return "<p class='muted'>No duplicate PR/branch collisions detected in current inventory.</p>"
|
||||
items = "".join(f"<li>{_escape(w.message)}</li>" for w in warnings)
|
||||
return f"<ul class='collision-warnings'>{items}</ul>"
|
||||
|
||||
|
||||
def _lock_block(snapshot: LeaseSnapshot) -> str:
|
||||
lock = snapshot.issue_lock
|
||||
if not lock:
|
||||
return "<p class='muted'>No active local issue lock file.</p>"
|
||||
return (
|
||||
"<pre class='prompt-text'>"
|
||||
f"{_escape(json.dumps(lock, indent=2, sort_keys=True))}"
|
||||
"</pre>"
|
||||
)
|
||||
|
||||
|
||||
def _claims_table(snapshot: LeaseSnapshot) -> str:
|
||||
entries = snapshot.claim_inventory.get("entries") or []
|
||||
if not entries:
|
||||
return "<p class='muted'>No in-progress issue claims in fetched inventory.</p>"
|
||||
rows = []
|
||||
for entry in entries:
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>#{entry.get('issue_number')}</td>"
|
||||
f"<td><span class='badge badge-{_escape(str(entry.get('status')))}'>"
|
||||
f"{_escape(str(entry.get('status')))}</span></td>"
|
||||
f"<td>{_escape(str(entry.get('linked_open_pr') or '—'))}</td>"
|
||||
f"<td>{entry.get('heartbeat_count', 0)}</td>"
|
||||
f"<td>{_escape(', '.join(entry.get('matching_branches') or []) or '—')}</td>"
|
||||
f"<td class='muted'>{_escape('; '.join(entry.get('reasons') or []))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class='registry leases'>"
|
||||
"<thead><tr><th>Issue</th><th>Claim status</th><th>Open PR</th>"
|
||||
"<th>Heartbeats</th><th>Branches</th><th>Notes</th></tr></thead>"
|
||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
def _reviewer_leases_table(snapshot: LeaseSnapshot) -> str:
|
||||
if not snapshot.reviewer_leases:
|
||||
return (
|
||||
"<p class='muted'>No active reviewer PR lease comments found "
|
||||
"(marker <code><!-- mcp-review-lease:v1 --></code>; see #407).</p>"
|
||||
)
|
||||
rows = []
|
||||
for lease in snapshot.reviewer_leases:
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>#{lease.get('pr_number')}</td>"
|
||||
f"<td>{_escape(str(lease.get('reviewer_identity') or '—'))}</td>"
|
||||
f"<td><code>{_escape(str(lease.get('profile') or '—'))}</code></td>"
|
||||
f"<td>{_escape(str(lease.get('phase') or '—'))}</td>"
|
||||
f"<td><code>{_escape(str(lease.get('expires_at') or '—'))}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
return (
|
||||
"<table class='registry leases'>"
|
||||
"<thead><tr><th>PR</th><th>Reviewer</th><th>Profile</th>"
|
||||
"<th>Phase</th><th>Expires</th></tr></thead>"
|
||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||
)
|
||||
|
||||
|
||||
def _history_links(snapshot: LeaseSnapshot) -> str:
|
||||
items = "".join(
|
||||
f"<li><a href=\"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/"
|
||||
f"{item['number']}\">#{item['number']}</a> — {_escape(item['title'])}</li>"
|
||||
for item in snapshot.collision_history
|
||||
)
|
||||
return f"<ul>{items}</ul>"
|
||||
|
||||
|
||||
LEASE_PAGE_STYLES = """
|
||||
<style>
|
||||
.collision-warnings li { color: #f0c4c4; margin: 0.35rem 0; }
|
||||
table.leases td:nth-child(6) { font-size: 0.85rem; }
|
||||
.badge-active, .badge-awaiting_review { color: #8fd19e; border-color: #3d6b4a; }
|
||||
.badge-stale, .badge-phantom, .badge-reclaimable { color: #f0a8a8; border-color: #7a3b3b; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def render_leases_page(snapshot: LeaseSnapshot) -> str:
|
||||
error_block = ""
|
||||
if snapshot.fetch_error:
|
||||
error_block = (
|
||||
'<div class="stub"><p><strong>Partial load:</strong> '
|
||||
f"{_escape(snapshot.fetch_error)}</p></div>"
|
||||
)
|
||||
body = (
|
||||
"<h2>Leases & collisions</h2>"
|
||||
"<p>Read-only visibility for issue claims, reviewer PR leases, and "
|
||||
"duplicate-work risks. Does not acquire or release leases.</p>"
|
||||
f"{error_block}"
|
||||
f"<p class='meta'>Project: <code>{_escape(snapshot.repo_label)}</code></p>"
|
||||
"<h3>Collision warnings</h3>"
|
||||
f"{_warnings_block(snapshot)}"
|
||||
"<h3>Local issue lock</h3>"
|
||||
f"{_lock_block(snapshot)}"
|
||||
"<h3>In-progress issue claims (#268)</h3>"
|
||||
f"{_claims_table(snapshot)}"
|
||||
"<h3>Reviewer PR leases (#407)</h3>"
|
||||
f"{_reviewer_leases_table(snapshot)}"
|
||||
"<h3>Collision / lease history issues</h3>"
|
||||
f"{_history_links(snapshot)}"
|
||||
"<p><a href=\"/api/leases\">JSON API</a></p>"
|
||||
f"{LEASE_PAGE_STYLES}"
|
||||
)
|
||||
return render_page(title="Leases", body_html=body)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user