Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a2243aad | ||
|
|
a0fffae576 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
ebd06b73c9 | ||
|
|
af9f1c5944 | ||
|
|
f83d2c2027 | ||
|
|
93781af7e9 | ||
|
|
314615ec2c | ||
|
|
52013791d4 | ||
|
|
2ac7519792 | ||
|
|
a32c68e24b | ||
|
|
7186718cfd | ||
|
|
964505654f | ||
|
|
008cd3fd74 | ||
|
|
11ffe0f9dd | ||
|
|
36781ebef7 | ||
|
|
2941ec529c | ||
|
|
eddb68818e |
@@ -0,0 +1,63 @@
|
|||||||
|
"""Controller-closure baseline-proof gate (#529, criterion 6).
|
||||||
|
|
||||||
|
A controller (or any session) may close a tracking issue with a closure
|
||||||
|
report that summarizes validation. When that report calls a non-zero
|
||||||
|
test-suite exit an "expected pre-existing failure" (or baseline / known
|
||||||
|
failure) it must carry pre-merge proof; otherwise the closure buries an
|
||||||
|
unproven regression as an accepted baseline and weakens controller
|
||||||
|
confidence in the durable state.
|
||||||
|
|
||||||
|
This module fails closed on exactly that case by reusing the pre-merge
|
||||||
|
baseline proof verifier (#533). A closure report is allowed when it is
|
||||||
|
empty (no validation claim), a clean pass, or a baseline failure proven on
|
||||||
|
the PR pre-merge base commit (or a documented known-failure record that
|
||||||
|
predates the PR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from premerge_baseline_proof import CLEAN_PASS, assess_premerge_baseline_proof
|
||||||
|
|
||||||
|
|
||||||
|
def assess_controller_closure_baseline_proof(
|
||||||
|
closure_report: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether an issue-closure report may proceed.
|
||||||
|
|
||||||
|
Returns a dict with ``block``, ``proven``, ``label``, ``reasons``,
|
||||||
|
``skipped`` and ``safe_next_action``. Only blocks when the closure
|
||||||
|
report claims a non-zero validation exit is an expected
|
||||||
|
pre-existing/baseline failure without valid pre-merge proof.
|
||||||
|
"""
|
||||||
|
text = (closure_report or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {
|
||||||
|
"block": False,
|
||||||
|
"proven": True,
|
||||||
|
"label": CLEAN_PASS,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = assess_premerge_baseline_proof(text)
|
||||||
|
block = bool(result.get("block"))
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"proven": not block,
|
||||||
|
"label": result.get("label"),
|
||||||
|
"reasons": list(result.get("reasons") or []),
|
||||||
|
"skipped": bool(result.get("skipped", False)),
|
||||||
|
"safe_next_action": (
|
||||||
|
result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"provide pre-merge baseline proof (base commit, tested commit, "
|
||||||
|
"command, exit status, failure signature) before closing on an "
|
||||||
|
"expected pre-existing failure"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if block
|
||||||
|
else "",
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||||
|
|
||||||
|
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||||
|
- **Related:**
|
||||||
|
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||||
|
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||||
|
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||||
|
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||||
|
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||||
|
|
||||||
|
- Mid-session identity/preflight resets
|
||||||
|
- Stale-runtime vs master parity failures
|
||||||
|
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||||
|
- Accidental production mutations from experimental code
|
||||||
|
|
||||||
|
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
### 2.1 Stable control runtime
|
||||||
|
|
||||||
|
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||||
|
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||||
|
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||||
|
|
||||||
|
### 2.2 Dev / test runtime
|
||||||
|
|
||||||
|
MCP **server code** development and testing:
|
||||||
|
|
||||||
|
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||||
|
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||||
|
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||||
|
|
||||||
|
### 2.3 Forbidden actions (normal sessions)
|
||||||
|
|
||||||
|
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||||
|
|
||||||
|
| Forbidden | Why |
|
||||||
|
|-----------|-----|
|
||||||
|
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||||
|
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||||
|
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||||
|
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||||
|
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||||
|
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||||
|
|
||||||
|
**LLM-allowed vs operator-owned (authoritative split):**
|
||||||
|
|
||||||
|
| Actor | May do | Must not do |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||||
|
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||||
|
|
||||||
|
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||||
|
|
||||||
|
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||||
|
|
||||||
|
### 2.4 Promotion (operator / release-manager only)
|
||||||
|
|
||||||
|
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||||
|
|
||||||
|
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||||
|
| **promoted runtime SHA** | Commit after promotion |
|
||||||
|
| **source branch/PR** | Where the change was reviewed |
|
||||||
|
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||||
|
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||||
|
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||||
|
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||||
|
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||||
|
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||||
|
|
||||||
|
Suggested durable marker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||||
|
|
||||||
|
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||||
|
Previous-SHA: <full sha>
|
||||||
|
Promoted-SHA: <full sha>
|
||||||
|
Source-PR: <number>
|
||||||
|
Source-Branch: <name>
|
||||||
|
Reload-Method: <text>
|
||||||
|
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||||
|
Identity-Proof: profile=<name> user=<name>
|
||||||
|
Workspace-Proof: root=<path>
|
||||||
|
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||||
|
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||||
|
Operator: <username>
|
||||||
|
Timestamp: <ISO-8601>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Unhealthy stable runtime → stop work
|
||||||
|
|
||||||
|
If the stable MCP runtime is **unhealthy**, including any of:
|
||||||
|
|
||||||
|
- client-namespace probes fail
|
||||||
|
- wrong identity / wrong profile
|
||||||
|
- wrong workspace root
|
||||||
|
- missing mutation capability for the intended role
|
||||||
|
- persistent EOF after **client reconnect**
|
||||||
|
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||||
|
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||||
|
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||||
|
4. Resume only after:
|
||||||
|
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||||
|
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||||
|
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||||
|
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||||
|
|
||||||
|
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||||
|
|
||||||
|
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||||
|
|
||||||
|
**Sanctioned response (authoritative):**
|
||||||
|
|
||||||
|
| Step | Actor | Action |
|
||||||
|
|------|-------|--------|
|
||||||
|
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||||
|
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||||
|
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||||
|
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||||
|
|
||||||
|
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||||
|
|
||||||
|
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||||
|
|
||||||
|
## 3. Relationship to other controls
|
||||||
|
|
||||||
|
| Doc / mechanism | Interaction |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||||
|
| EOF recovery | Reconnect only; no process kill |
|
||||||
|
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||||
|
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||||
|
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||||
|
|
||||||
|
## 4. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Predictable control plane for concurrent LLMs
|
||||||
|
- Clear operator-only promotion gate with rollback
|
||||||
|
- Aligns session behavior with health/EOF docs already landed
|
||||||
|
|
||||||
|
### Costs
|
||||||
|
|
||||||
|
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||||
|
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Does not ban operator-supervised restarts during incidents
|
||||||
|
- Does not replace CI or code review for MCP changes
|
||||||
|
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||||
|
|
||||||
|
## 5. Implementation follow-ups (optional tooling)
|
||||||
|
|
||||||
|
These may land in later issues; the **policy binds sessions now**:
|
||||||
|
|
||||||
|
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||||
|
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||||
|
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||||
|
|
||||||
|
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||||
|
|
||||||
|
## 6. Acceptance for this ADR
|
||||||
|
|
||||||
|
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||||
|
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||||
|
3. Issue #615 references this path.
|
||||||
|
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||||
|
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||||
|
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||||
|
|
||||||
|
## 7. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||||
|
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
|||||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||||
review or author workflows.
|
review or author workflows.
|
||||||
|
|
||||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
|||||||
|
|
||||||
1. Read the error — it names the **resolved workspace path**, **role
|
1. Read the error — it names the **resolved workspace path**, **role
|
||||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||||
workspace (or set the role-specific env var before launch).
|
the active `branches/` worktree differs from the MCP process root; **client
|
||||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
reconnect** after transport EOF only (no process kill).
|
||||||
branches/ worktree differs from the MCP process root.
|
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||||
|
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||||
|
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||||
|
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||||
own namespace — that destroys another agent's WIP.
|
own namespace — that destroys another agent's WIP.
|
||||||
|
|
||||||
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||||
- Quote the resolved workspace path and binding source from the error.
|
- Quote the resolved workspace path and binding source from the error.
|
||||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
request an **operator** reload of the correct namespace MCP / env binding.
|
||||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||||
|
that the LLM must not self-restart the MCP process.
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
|
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`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.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
|
||||||
|
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
|
||||||
|
|
||||||
|
```
|
||||||
|
client is closing: EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subsequent call to that same namespace returns the same error, including
|
||||||
|
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
|
||||||
|
servers registered with the same client (for example `context7`) keep working,
|
||||||
|
so this is **not** a global MCP-client outage.
|
||||||
|
|
||||||
|
## Why this is not a code defect
|
||||||
|
|
||||||
|
This failure is a **transport-level** condition in the IDE / MCP client manager,
|
||||||
|
not a missing or broken tool:
|
||||||
|
|
||||||
|
- The tool can be present and registered in the Python `FastMCP` tool manager.
|
||||||
|
- Direct Python inspection of the server confirms the tool exists.
|
||||||
|
- Running the server manually and sending JSON-RPC over stdio works fine
|
||||||
|
(offline spawn) — that path does **not** prove the IDE namespace is healthy.
|
||||||
|
|
||||||
|
The client manager entered a closed state after the backing subprocess for that
|
||||||
|
namespace terminated (or was killed) behind its back. Once closed, the client
|
||||||
|
does **not** re-spawn the child on the next tool call — it just replays
|
||||||
|
`client is closing: EOF`. The OS process may even still be alive if a parent
|
||||||
|
language-server process is holding the stdio pipes open.
|
||||||
|
|
||||||
|
This is the canonical "registered in FastMCP ≠ callable through the namespace"
|
||||||
|
false-ready state. It is distinct from the **stale-runtime** family in #531 /
|
||||||
|
#544, where the process is reachable but running behind `master`; that case is
|
||||||
|
detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in
|
||||||
|
`gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one,
|
||||||
|
so the `ps` check alone will not surface it.
|
||||||
|
|
||||||
|
## Recovery path (canonical — client reconnect only)
|
||||||
|
|
||||||
|
Do the steps in order. Stop as soon as a live **client-namespace** call succeeds.
|
||||||
|
|
||||||
|
1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a
|
||||||
|
different MCP server (e.g. `context7`).
|
||||||
|
- Only the Gitea namespace fails → single-namespace transport close. Continue.
|
||||||
|
- Every server fails → restart the whole MCP client, not just one namespace.
|
||||||
|
|
||||||
|
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
|
||||||
|
client MCP-reconnect action for that server entry (in Claude Code:
|
||||||
|
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
|
||||||
|
client to spawn a fresh subprocess and re-open the pipe. This clears the
|
||||||
|
closed-client state that a bare `kill`/respawn from a terminal does **not**.
|
||||||
|
|
||||||
|
3. **Do not "fix" it by importing the server or poking the process.** Reaching
|
||||||
|
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
|
||||||
|
killing PIDs to force a respawn, or touching MCP config mtimes does **not**
|
||||||
|
restore the *client's* view of the namespace and violates the daemon-import
|
||||||
|
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
|
||||||
|
is a **client reconnect / relaunch**.
|
||||||
|
|
||||||
|
4. **Verify through the same path the workflow will use.** After reconnect, call
|
||||||
|
the specific tool the blocked workflow needs — not just any tool — through
|
||||||
|
the target namespace. For a merge that means calling the merger-authorized
|
||||||
|
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
|
||||||
|
namespace does **not** prove another namespace or another tool is callable.
|
||||||
|
Record success with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(..., probe_source="client_namespace")
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **If reconnect does not clear it,** relaunch the client entirely, then repeat
|
||||||
|
step 4. If EOF persists after a full relaunch, the backing subprocess is
|
||||||
|
failing to start — inspect its stderr / launch config (command path, venv,
|
||||||
|
`*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still
|
||||||
|
do not use PID kill or config-touch as the primary recovery.
|
||||||
|
|
||||||
|
## Diagnostics to capture when reporting EOF
|
||||||
|
|
||||||
|
Include all of these so the failure is actionable and reproducible:
|
||||||
|
|
||||||
|
- **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
|
||||||
|
`gitea-merger` / `gitea-tools`).
|
||||||
|
- **Tool** that was called and the **exact** error string.
|
||||||
|
- **PID** of the backing process (if any) and whether it was still alive
|
||||||
|
(informational only — not a recovery action).
|
||||||
|
- **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
|
||||||
|
worktree binding such as `GITEA_AUTHOR_WORKTREE`).
|
||||||
|
- **Config path** the client launched the server from.
|
||||||
|
- Result of the **cross-server control** call (did `context7` succeed?).
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
|
||||||
|
subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
|
||||||
|
classifies with `probe_source=offline_spawn`. That is useful for offline
|
||||||
|
launch/registration debugging. It is **not** proof the IDE-managed namespace is
|
||||||
|
healthy. See `docs/mcp-namespace-health.md`.
|
||||||
|
|
||||||
|
## Do-not list during EOF recovery
|
||||||
|
|
||||||
|
- Do **not** retry a blocked merge/adoption until the required tool is confirmed
|
||||||
|
callable through the merger-authorized **client** namespace (see #543).
|
||||||
|
- Do **not** clean, reset, or rebind a **foreign** worktree to work around the
|
||||||
|
error.
|
||||||
|
- Do **not** bypass the namespace with direct imports, raw API/curl, or
|
||||||
|
in-memory state restoration.
|
||||||
|
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||||
|
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||||
|
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||||
|
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MCP namespace health diagnostics (#543)
|
||||||
|
|
||||||
|
Gitea MCP tools can be registered in the Python FastMCP server while the IDE's
|
||||||
|
live MCP namespace is still unusable. The failure usually appears as
|
||||||
|
`client is closing: EOF`, `transport closed`, or an empty response when calling
|
||||||
|
a tool such as `gitea_whoami`.
|
||||||
|
|
||||||
|
Do not treat static tool registration as proof that review or merge workflows
|
||||||
|
can proceed. A reviewer or merger flow must have **client-namespace** evidence
|
||||||
|
that the required tool is callable through the configured IDE MCP namespace.
|
||||||
|
|
||||||
|
## Probe sources (do not confuse them)
|
||||||
|
|
||||||
|
| Source | How obtained | Proves IDE namespace? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** |
|
||||||
|
| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) |
|
||||||
|
|
||||||
|
`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets
|
||||||
|
`ide_namespace_proven=true` for `client_namespace` success. Offline spawn
|
||||||
|
success never clears review/merge mutation gates.
|
||||||
|
|
||||||
|
## Client-namespace health check (canonical)
|
||||||
|
|
||||||
|
1. Through the IDE client, call a cheap tool on the target namespace
|
||||||
|
(`gitea_whoami` or `gitea_list_profiles`).
|
||||||
|
2. Feed the live result into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_assess_mcp_namespace_health(
|
||||||
|
namespace="gitea-merger", # or reviewer / author / tools
|
||||||
|
registered_tools=[...], # optional static list
|
||||||
|
probe_result={"success": true, "result": {...}},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. A healthy client-namespace assessment is recorded in the MCP session and
|
||||||
|
consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates.
|
||||||
|
4. If the probe fails with EOF, recover via **client reconnect only** — see
|
||||||
|
`docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP
|
||||||
|
config mtimes as a recovery procedure.
|
||||||
|
|
||||||
|
## Offline spawn probe (non-authoritative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This script spawns a **separate** server process from config, performs
|
||||||
|
JSON-RPC `initialize` → `tools/list` → `tools/call`, and classifies the
|
||||||
|
result with `probe_source=offline_spawn`. Use it for offline debugging of
|
||||||
|
launch command / registration. It does **not** prove the IDE-managed
|
||||||
|
namespace is healthy.
|
||||||
|
|
||||||
|
By default the script checks:
|
||||||
|
|
||||||
|
| Namespace | Required tool |
|
||||||
|
| --- | --- |
|
||||||
|
| `gitea-author` | `gitea_whoami` |
|
||||||
|
| `gitea-reviewer` | `gitea_whoami` |
|
||||||
|
| `gitea-merger` | `gitea_whoami` |
|
||||||
|
| `gitea-tools` | `gitea_list_profiles` |
|
||||||
|
|
||||||
|
## Recovery (canonical)
|
||||||
|
|
||||||
|
When a namespace returns EOF, follow
|
||||||
|
`docs/mcp-namespace-eof-recovery.md` in order:
|
||||||
|
|
||||||
|
1. Confirm blast radius (Gitea namespace vs all MCP servers).
|
||||||
|
2. **Reconnect the namespace through the client** (IDE reconnect / relaunch).
|
||||||
|
3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime
|
||||||
|
touches — those do not restore the client's closed transport.
|
||||||
|
4. Re-verify the **specific** required tool through the target namespace.
|
||||||
|
5. Resume review/merge only after a successful `client_namespace` assessment.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
1. **State machine (read-only):** feed `blocks_merge_workflow` from a
|
||||||
|
`client_namespace` assessment into
|
||||||
|
`gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
|
||||||
|
2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
|
||||||
|
`_live_namespace_health_gate` and fail closed when the session has a
|
||||||
|
recorded unhealthy or non-client probe for the required namespace
|
||||||
|
(`gitea-reviewer` for review, `gitea-merger` for merge).
|
||||||
|
|
||||||
|
When blocked, repair the IDE namespace and re-record a healthy
|
||||||
|
`client_namespace` assessment before retrying the mutation.
|
||||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
|||||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||||
|
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Supported Gitea instances
|
## Supported Gitea instances
|
||||||
|
|
||||||
@@ -30,3 +31,4 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
|||||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||||
|
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||||
|
|
||||||
|
## Stable MCP control runtime (#615)
|
||||||
|
|
||||||
|
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||||
|
|
||||||
|
| Situation | Who acts | What to do |
|
||||||
|
|-----------|----------|------------|
|
||||||
|
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||||
|
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||||
|
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||||
|
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||||
|
|
||||||
## Gitea Wiki sync
|
## Gitea Wiki sync
|
||||||
|
|
||||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ FINAL_REPORT_TASK_KINDS = frozenset({
|
|||||||
"issue_filing",
|
"issue_filing",
|
||||||
"issue_selection",
|
"issue_selection",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
"controller_close",
|
||||||
})
|
})
|
||||||
|
|
||||||
_TASK_KIND_ALIASES = {
|
_TASK_KIND_ALIASES = {
|
||||||
@@ -51,6 +52,9 @@ _TASK_KIND_ALIASES = {
|
|||||||
"reconcile_already_landed": "reconcile_already_landed",
|
"reconcile_already_landed": "reconcile_already_landed",
|
||||||
"work-issue": "work_issue",
|
"work-issue": "work_issue",
|
||||||
"create_issue": "issue_filing",
|
"create_issue": "issue_filing",
|
||||||
|
"close_issue": "controller_close",
|
||||||
|
"close-issue": "controller_close",
|
||||||
|
"controller-close": "controller_close",
|
||||||
}
|
}
|
||||||
|
|
||||||
_HANDOFF_ROLE_BY_TASK = {
|
_HANDOFF_ROLE_BY_TASK = {
|
||||||
@@ -62,6 +66,7 @@ _HANDOFF_ROLE_BY_TASK = {
|
|||||||
"issue_filing": "issue_filing",
|
"issue_filing": "issue_filing",
|
||||||
"issue_selection": None,
|
"issue_selection": None,
|
||||||
"inventory": "inventory",
|
"inventory": "inventory",
|
||||||
|
"controller_close": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
|
||||||
@@ -850,6 +855,27 @@ def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_post_merge_validation(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block active approval on an already-merged/closed PR, and post-merge moot
|
||||||
|
validation claimed without merged-state + merge-commit proof (#529)."""
|
||||||
|
from post_merge_validation import assess_post_merge_validation
|
||||||
|
|
||||||
|
result = assess_post_merge_validation(report_text)
|
||||||
|
if not result.get("block"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"reviewer.post_merge_validation",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Validation status",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result.get("safe_next_action")
|
||||||
|
or (
|
||||||
|
"record post-merge moot validation instead of an active approval; "
|
||||||
|
"cite PR state merged/closed and the merge commit SHA"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_status_vocabulary(
|
def _rule_reviewer_validation_status_vocabulary(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
*,
|
*,
|
||||||
@@ -1514,6 +1540,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
_rule_reviewer_premerge_baseline_proof,
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
_rule_reviewer_post_merge_validation,
|
||||||
_rule_reviewer_validation_status_vocabulary,
|
_rule_reviewer_validation_status_vocabulary,
|
||||||
_rule_reviewer_main_checkout_baseline,
|
_rule_reviewer_main_checkout_baseline,
|
||||||
_rule_reviewer_main_checkout_path,
|
_rule_reviewer_main_checkout_path,
|
||||||
@@ -1606,6 +1633,13 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||||
*_SHARED_ISSUE_LOCK_RULES,
|
*_SHARED_ISSUE_LOCK_RULES,
|
||||||
],
|
],
|
||||||
|
# Controller issue closure (#529): a closure report must not bury an
|
||||||
|
# unproven non-zero suite exit as an "expected pre-existing failure".
|
||||||
|
# Kept intentionally narrow so a closure pre-check does not demand the
|
||||||
|
# full reviewer/author handoff schema.
|
||||||
|
"controller_close": [
|
||||||
|
_rule_reviewer_premerge_baseline_proof,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+570
-19
@@ -191,6 +191,7 @@ MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
|||||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||||
|
|
||||||
import namespace_workspace_binding as nwb # noqa: E402
|
import namespace_workspace_binding as nwb # noqa: E402
|
||||||
|
import mcp_namespace_health # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _preflight_in_test_mode() -> bool:
|
def _preflight_in_test_mode() -> bool:
|
||||||
@@ -222,6 +223,21 @@ def _effective_workspace_role() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_role_kind(profile: dict) -> str:
|
||||||
|
"""Resolve a profile's declared role before inferring from permissions."""
|
||||||
|
role = (profile.get("role") or profile.get("role_kind") or "").strip()
|
||||||
|
if role:
|
||||||
|
return role
|
||||||
|
profile_name = (profile.get("profile_name") or "").strip().lower()
|
||||||
|
for candidate in ("reconciler", "merger", "reviewer", "author"):
|
||||||
|
if candidate in profile_name:
|
||||||
|
return candidate
|
||||||
|
return _role_kind(
|
||||||
|
profile.get("allowed_operations") or [],
|
||||||
|
profile.get("forbidden_operations") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _actual_profile_role() -> str:
|
def _actual_profile_role() -> str:
|
||||||
"""Resolve the workspace role from the ACTIVE PROFILE alone (#540).
|
"""Resolve the workspace role from the ACTIVE PROFILE alone (#540).
|
||||||
|
|
||||||
@@ -234,10 +250,7 @@ def _actual_profile_role() -> str:
|
|||||||
``author`` here, so author blocking is preserved.
|
``author`` here, so author blocking is preserved.
|
||||||
"""
|
"""
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
role = _role_kind(
|
role = _profile_role_kind(profile)
|
||||||
profile.get("allowed_operations") or [],
|
|
||||||
profile.get("forbidden_operations") or [],
|
|
||||||
)
|
|
||||||
return nwb.normalize_role_kind(
|
return nwb.normalize_role_kind(
|
||||||
role,
|
role,
|
||||||
profile_name=profile.get("profile_name"),
|
profile_name=profile.get("profile_name"),
|
||||||
@@ -2812,6 +2825,35 @@ _TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
|||||||
# remote + profile identity with TTL.
|
# remote + profile identity with TTL.
|
||||||
_REVIEW_DECISION_LOCK: dict | None = None
|
_REVIEW_DECISION_LOCK: dict | None = None
|
||||||
|
|
||||||
|
# Session-scoped live MCP namespace health assessments (#543).
|
||||||
|
# Keyed by namespace name. Only client_namespace entries authorize mutations.
|
||||||
|
_LIVE_NAMESPACE_HEALTH: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_live_namespace_health(assessment: dict | None) -> None:
|
||||||
|
"""Store a namespace health assessment for mutation gates."""
|
||||||
|
if not isinstance(assessment, dict):
|
||||||
|
return
|
||||||
|
ns = str(assessment.get("namespace") or "").strip()
|
||||||
|
if not ns:
|
||||||
|
return
|
||||||
|
_LIVE_NAMESPACE_HEALTH[ns] = {
|
||||||
|
"namespace": ns,
|
||||||
|
"healthy": bool(assessment.get("healthy")),
|
||||||
|
"ide_namespace_proven": bool(assessment.get("ide_namespace_proven")),
|
||||||
|
"probe_source": assessment.get("probe_source"),
|
||||||
|
"blocks_merge_workflow": bool(assessment.get("blocks_merge_workflow")),
|
||||||
|
"error_type": assessment.get("error_type"),
|
||||||
|
"required_tool": assessment.get("required_tool"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _live_namespace_health_gate(task: str) -> list[str]:
|
||||||
|
"""Fail closed on recorded broken/non-client IDE namespace health (#543)."""
|
||||||
|
return mcp_namespace_health.mutation_gate_from_session(
|
||||||
|
task, _LIVE_NAMESPACE_HEALTH
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _decision_lock_binding(lock: dict | None = None) -> dict:
|
def _decision_lock_binding(lock: dict | None = None) -> dict:
|
||||||
"""Resolve key fields for durable decision-lock storage."""
|
"""Resolve key fields for durable decision-lock storage."""
|
||||||
@@ -3502,6 +3544,11 @@ def _evaluate_pr_review_submission(
|
|||||||
reasons.extend(workflow_blockers)
|
reasons.extend(workflow_blockers)
|
||||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||||
return result
|
return result
|
||||||
|
if live:
|
||||||
|
ns_gate = _live_namespace_health_gate("review_pr")
|
||||||
|
if ns_gate:
|
||||||
|
reasons.extend(ns_gate)
|
||||||
|
return result
|
||||||
|
|
||||||
if action not in _REVIEW_ACTIONS:
|
if action not in _REVIEW_ACTIONS:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
@@ -4150,6 +4197,335 @@ def gitea_submit_pr_review(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_save_review_draft(
|
||||||
|
pr_number: int,
|
||||||
|
action: str,
|
||||||
|
body: str = "",
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
validation_commands: str | None = None,
|
||||||
|
validation_results: str | None = None,
|
||||||
|
blocker_reason: str | None = None,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Save a prepared review verdict as non-terminal draft evidence without submitting a formal review.
|
||||||
|
|
||||||
|
This permits resuming the review later (e.g. after a stale runtime restart or connection drop).
|
||||||
|
"""
|
||||||
|
action = (action or "").strip().lower()
|
||||||
|
if action not in _REVIEW_ACTIONS:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [
|
||||||
|
f"unknown review action '{action}'; expected one of "
|
||||||
|
f"{sorted(_REVIEW_ACTIONS)}"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Resolve remote context
|
||||||
|
try:
|
||||||
|
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "reasons": [str(exc)]}
|
||||||
|
|
||||||
|
auth = _auth(h)
|
||||||
|
|
||||||
|
# 2. Fetch current PR details to get target base branch and base branch SHA
|
||||||
|
try:
|
||||||
|
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||||
|
pr = api_request("GET", pr_url, auth) or {}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
current_head = (pr.get("head") or {}).get("sha")
|
||||||
|
target_branch = (pr.get("base") or {}).get("ref")
|
||||||
|
target_branch_sha = (pr.get("base") or {}).get("sha")
|
||||||
|
|
||||||
|
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [
|
||||||
|
f"expected_head_sha '{expected_head_sha}' does not match current PR head SHA '{current_head}'"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Resolve worktree path
|
||||||
|
try:
|
||||||
|
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "reasons": [f"failed to resolve worktree path: {str(exc)}"]}
|
||||||
|
|
||||||
|
# 4. Save payload to durable session state
|
||||||
|
profile = get_profile()
|
||||||
|
payload = {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"action": action,
|
||||||
|
"body": body,
|
||||||
|
"head_sha": expected_head_sha or current_head,
|
||||||
|
"target_branch": target_branch,
|
||||||
|
"target_branch_sha": target_branch_sha,
|
||||||
|
"worktree_path": resolved_worktree,
|
||||||
|
"validation_commands": validation_commands,
|
||||||
|
"validation_results": validation_results,
|
||||||
|
"blocker_reason": blocker_reason,
|
||||||
|
"saved_by_identity": _authenticated_username(h) or "",
|
||||||
|
"saved_by_profile": profile.get("profile_name"),
|
||||||
|
"remote": remote,
|
||||||
|
"org": resolved_org,
|
||||||
|
"repo": resolved_repo,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mcp_session_state.save_state(
|
||||||
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||||
|
payload=payload,
|
||||||
|
remote=remote,
|
||||||
|
org=resolved_org,
|
||||||
|
repo=resolved_repo,
|
||||||
|
profile_identity=profile.get("profile_name"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "reasons": [f"failed to save draft state: {str(exc)}"]}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"action": action,
|
||||||
|
"head_sha": payload["head_sha"],
|
||||||
|
"target_branch": target_branch,
|
||||||
|
"target_branch_sha": target_branch_sha,
|
||||||
|
"worktree_path": resolved_worktree,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_resume_review_draft(
|
||||||
|
pr_number: int,
|
||||||
|
submit: bool = False,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Resume a previously saved review draft for the given PR number.
|
||||||
|
|
||||||
|
Performs live-state checks (PR head SHA, target branch SHA, terminal lock, active leases,
|
||||||
|
runtime/master parity, reviewer identity, worktree binding, validation freshness).
|
||||||
|
Refuses to submit or mark ready if any state has changed unsafely.
|
||||||
|
"""
|
||||||
|
profile = get_profile()
|
||||||
|
active_profile = profile.get("profile_name")
|
||||||
|
|
||||||
|
# 1. Load draft state
|
||||||
|
try:
|
||||||
|
draft = mcp_session_state.load_state(
|
||||||
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=active_profile,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "reasons": [f"failed to load draft state: {str(exc)}"]}
|
||||||
|
|
||||||
|
if not draft:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [f"no review draft found for PR #{pr_number} under profile '{active_profile}'"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.get("pr_number") != pr_number:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [
|
||||||
|
f"loaded draft is for PR #{draft.get('pr_number')}, but requested PR #{pr_number}"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Resolve remote context
|
||||||
|
try:
|
||||||
|
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"success": False, "reasons": [str(exc)]}
|
||||||
|
|
||||||
|
auth = _auth(h)
|
||||||
|
|
||||||
|
# 3. Fetch live PR state
|
||||||
|
try:
|
||||||
|
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||||
|
pr = api_request("GET", pr_url, auth) or {}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Perform live-state checks
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
# PR must be open
|
||||||
|
if pr.get("state") != "open":
|
||||||
|
reasons.append(f"PR #{pr_number} is not open (state: '{pr.get('state')}')")
|
||||||
|
|
||||||
|
# Live head SHA must match saved head_sha
|
||||||
|
live_head = (pr.get("head") or {}).get("sha")
|
||||||
|
saved_head = draft.get("head_sha")
|
||||||
|
if live_head != saved_head:
|
||||||
|
reasons.append(
|
||||||
|
f"PR head SHA changed (live={live_head!r}, saved={saved_head!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Live target branch SHA must match saved target_branch_sha
|
||||||
|
live_target_sha = (pr.get("base") or {}).get("sha")
|
||||||
|
saved_target_sha = draft.get("target_branch_sha")
|
||||||
|
if live_target_sha != saved_target_sha:
|
||||||
|
reasons.append(
|
||||||
|
f"target branch SHA changed (live={live_target_sha!r}, saved={saved_target_sha!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check #332 terminal lock
|
||||||
|
hard_stop = terminal_review_hard_stop_reasons(pr_number, "resume")
|
||||||
|
if hard_stop:
|
||||||
|
reasons.extend(hard_stop)
|
||||||
|
|
||||||
|
# Check active reviewer lease (must be claimed by the current session)
|
||||||
|
try:
|
||||||
|
comments = _fetch_pr_comments(pr_number, remote=remote, host=host, org=resolved_org, repo=resolved_repo)
|
||||||
|
except Exception as exc:
|
||||||
|
reasons.append(f"failed to fetch PR comments: {str(exc)}")
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
active_lease = reviewer_pr_lease.find_active_reviewer_lease(comments, pr_number=pr_number)
|
||||||
|
if not active_lease:
|
||||||
|
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||||
|
else:
|
||||||
|
# Check that lease owner is our session_id
|
||||||
|
session = reviewer_pr_lease.get_session_lease()
|
||||||
|
session_id = (session or {}).get("session_id")
|
||||||
|
owner_session_id = active_lease.get("session_id")
|
||||||
|
if owner_session_id != session_id:
|
||||||
|
reasons.append(
|
||||||
|
f"active reviewer lease belongs to session_id={owner_session_id!r}, "
|
||||||
|
f"but current session has session_id={session_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check runtime/master parity
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||||
|
config = gitea_config.load_config()
|
||||||
|
matching_profiles = []
|
||||||
|
if config and "profiles" in config:
|
||||||
|
for p_name, p_data in config["profiles"].items():
|
||||||
|
p_allowed = p_data.get("allowed_operations") or []
|
||||||
|
p_forbidden = p_data.get("forbidden_operations") or []
|
||||||
|
p_allowed_n = []
|
||||||
|
for op in p_allowed:
|
||||||
|
try:
|
||||||
|
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
p_forbidden_n = []
|
||||||
|
for op in p_forbidden:
|
||||||
|
try:
|
||||||
|
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Check for "review_pr" or similar capability
|
||||||
|
ok, _ = gitea_config.check_operation("gitea.pr.review", p_allowed_n, p_forbidden_n)
|
||||||
|
if ok:
|
||||||
|
matching_profiles.append(p_name)
|
||||||
|
runtime_reasons = _check_mcp_runtimes_diagnostics("review_pr", matching_profiles)
|
||||||
|
if runtime_reasons:
|
||||||
|
reasons.extend(runtime_reasons)
|
||||||
|
|
||||||
|
# Check reviewer identity
|
||||||
|
identity = _authenticated_username(h)
|
||||||
|
if identity != draft.get("saved_by_identity"):
|
||||||
|
reasons.append(
|
||||||
|
f"reviewer identity mismatch (active={identity!r}, saved={draft.get('saved_by_identity')!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check worktree binding
|
||||||
|
try:
|
||||||
|
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||||
|
except Exception as exc:
|
||||||
|
reasons.append(f"failed to resolve current worktree: {str(exc)}")
|
||||||
|
resolved_worktree = None
|
||||||
|
if resolved_worktree != draft.get("worktree_path"):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree binding mismatch (current={resolved_worktree!r}, saved={draft.get('worktree_path')!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {"success": False, "reasons": reasons}
|
||||||
|
|
||||||
|
# 5. All checks pass! Mark decision lock as ready.
|
||||||
|
action = draft.get("action")
|
||||||
|
body = draft.get("body")
|
||||||
|
saved_head = draft.get("head_sha")
|
||||||
|
|
||||||
|
lock = _load_review_decision_lock() or {}
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = pr_number
|
||||||
|
lock["ready_action"] = action
|
||||||
|
lock["ready_expected_head_sha"] = saved_head
|
||||||
|
lock["ready_remote"] = remote
|
||||||
|
lock["ready_org"] = resolved_org
|
||||||
|
lock["ready_repo"] = resolved_repo
|
||||||
|
_save_review_decision_lock(lock)
|
||||||
|
|
||||||
|
submitted = False
|
||||||
|
submission_res = None
|
||||||
|
if submit:
|
||||||
|
# Submit the review!
|
||||||
|
submission_res = gitea_submit_pr_review(
|
||||||
|
pr_number=pr_number,
|
||||||
|
action=action,
|
||||||
|
body=body,
|
||||||
|
expected_head_sha=saved_head,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
org=resolved_org,
|
||||||
|
repo=resolved_repo,
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
worktree_path=resolved_worktree,
|
||||||
|
)
|
||||||
|
if submission_res.get("performed") or submission_res.get("success"):
|
||||||
|
submitted = True
|
||||||
|
# Delete the draft payload on success
|
||||||
|
mcp_session_state.save_state(
|
||||||
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||||
|
payload=None,
|
||||||
|
remote=remote,
|
||||||
|
org=resolved_org,
|
||||||
|
repo=resolved_repo,
|
||||||
|
profile_identity=active_profile,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If submit fails, return the submit failure reasons
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": submission_res.get("reasons") or ["submission failed"],
|
||||||
|
"submission_result": submission_res,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"action": action,
|
||||||
|
"marked_ready": True,
|
||||||
|
"submitted": submitted,
|
||||||
|
"submission_result": submission_res,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_edit_pr(
|
def gitea_edit_pr(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -4603,6 +4979,12 @@ def gitea_merge_pr(
|
|||||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# Gate 0b — recorded live MCP namespace health must not be broken (#543).
|
||||||
|
ns_gate = _live_namespace_health_gate("merge_pr")
|
||||||
|
if ns_gate:
|
||||||
|
reasons.extend(ns_gate)
|
||||||
|
return result
|
||||||
|
|
||||||
# Gate 1 — valid merge method (no API call on a bad method).
|
# Gate 1 — valid merge method (no API call on a bad method).
|
||||||
if do not in _MERGE_METHODS:
|
if do not in _MERGE_METHODS:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
@@ -6309,6 +6691,7 @@ def gitea_close_issue(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
closure_report: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Close an issue by setting its state to 'closed'.
|
"""Close an issue by setting its state to 'closed'.
|
||||||
|
|
||||||
@@ -6318,6 +6701,9 @@ def gitea_close_issue(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
closure_report: Optional validation/closure summary. When it claims a
|
||||||
|
non-zero test-suite exit is an "expected pre-existing"/baseline
|
||||||
|
failure without pre-merge proof, the close fails closed (#529).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'success' boolean and 'message'.
|
dict with 'success' boolean and 'message'.
|
||||||
@@ -6327,6 +6713,25 @@ def gitea_close_issue(
|
|||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote, task="close_issue")
|
verify_preflight_purity(remote, task="close_issue")
|
||||||
|
|
||||||
|
# #529: block closure that buries an unproven non-zero suite exit as an
|
||||||
|
# "expected pre-existing failure". Skipped when no closure report is given.
|
||||||
|
from controller_closure_baseline_proof import (
|
||||||
|
assess_controller_closure_baseline_proof,
|
||||||
|
)
|
||||||
|
closure_gate = assess_controller_closure_baseline_proof(closure_report)
|
||||||
|
if closure_gate["block"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"blocked": True,
|
||||||
|
"message": (
|
||||||
|
f"Issue #{issue_number} close blocked (#529): closure report "
|
||||||
|
"claims an expected pre-existing/baseline failure without "
|
||||||
|
"pre-merge proof."
|
||||||
|
),
|
||||||
|
"reasons": closure_gate["reasons"],
|
||||||
|
"safe_next_action": closure_gate["safe_next_action"],
|
||||||
|
}
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
||||||
@@ -7154,6 +7559,62 @@ def gitea_assess_reviewer_pr_lease(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_diagnose_reviewer_pr_lease_handoff(
|
||||||
|
pr_number: int,
|
||||||
|
proposed_worktree: str | None = None,
|
||||||
|
instructed_session_id: str | None = None,
|
||||||
|
instructed_comment_id: int | None = None,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Read-only: diagnose open-PR reviewer lease handoff and emit next action (#599).
|
||||||
|
|
||||||
|
Classifies no-lease / own / foreign / instructed-lease-missing-with-replacement
|
||||||
|
and worktree binding mismatch. Returns a canonical ``next_action`` without
|
||||||
|
mutating Gitea state. Never steals foreign leases.
|
||||||
|
"""
|
||||||
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
|
if read_block:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"reasons": read_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.read"),
|
||||||
|
}
|
||||||
|
comments = _fetch_pr_comments(
|
||||||
|
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||||
|
h, _o, _r = _resolve(remote, host, org, repo)
|
||||||
|
try:
|
||||||
|
username = _authenticated_username(h)
|
||||||
|
except Exception:
|
||||||
|
username = None
|
||||||
|
session_lease = reviewer_pr_lease.get_session_lease() or {}
|
||||||
|
env_wt = (
|
||||||
|
(os.environ.get("GITEA_REVIEWER_WORKTREE") or "").strip()
|
||||||
|
or (os.environ.get("GITEA_ACTIVE_WORKTREE") or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
# Prefer in-session lease id when present; otherwise diagnose as a fresh
|
||||||
|
# caller without inventing ownership of an on-thread lease.
|
||||||
|
session_id = (session_lease.get("session_id") or "").strip() or None
|
||||||
|
diagnosis = reviewer_pr_lease.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
comments,
|
||||||
|
pr_number=pr_number,
|
||||||
|
current_session_id=session_id,
|
||||||
|
current_reviewer_identity=username,
|
||||||
|
proposed_worktree=proposed_worktree,
|
||||||
|
env_bound_worktree=env_wt,
|
||||||
|
instructed_session_id=instructed_session_id,
|
||||||
|
instructed_comment_id=instructed_comment_id,
|
||||||
|
)
|
||||||
|
diagnosis["success"] = True
|
||||||
|
diagnosis["remote"] = remote
|
||||||
|
diagnosis["authenticated_user"] = username
|
||||||
|
return diagnosis
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_cleanup_post_merge_moot_lease(
|
def gitea_cleanup_post_merge_moot_lease(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -8533,40 +8994,45 @@ def gitea_assess_review_merge_state_machine(
|
|||||||
pre_merge_gates: dict[str, bool] | None = None,
|
pre_merge_gates: dict[str, bool] | None = None,
|
||||||
infra_stop: bool = False,
|
infra_stop: bool = False,
|
||||||
capability_blocked: bool = False,
|
capability_blocked: bool = False,
|
||||||
|
live_namespace_broken: bool = False,
|
||||||
recovery_handoff_text: str | None = None,
|
recovery_handoff_text: str | None = None,
|
||||||
final_report_text: str | None = None,
|
final_report_text: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Read-only: assess enforced PR review/merge workflow state (#290)."""
|
"""Read-only: assess enforced PR review/merge workflow state (#290).
|
||||||
|
|
||||||
|
``live_namespace_broken`` fails review/merge closed when the live MCP
|
||||||
|
namespace call path is unusable even though the tool is registered in
|
||||||
|
FastMCP (#543 AC5). Supply the ``blocks_merge_workflow`` verdict from
|
||||||
|
``gitea_assess_mcp_namespace_health``.
|
||||||
|
"""
|
||||||
completion = state_completion or {}
|
completion = state_completion or {}
|
||||||
blockers = review_merge_state_machine.assess_workflow_blockers(
|
blocker_kwargs = {
|
||||||
infra_stop=infra_stop,
|
"infra_stop": infra_stop,
|
||||||
capability_blocked=capability_blocked,
|
"capability_blocked": capability_blocked,
|
||||||
)
|
"live_namespace_broken": live_namespace_broken,
|
||||||
|
}
|
||||||
|
blockers = review_merge_state_machine.assess_workflow_blockers(**blocker_kwargs)
|
||||||
result = {
|
result = {
|
||||||
"workflow": review_merge_state_machine.workflow_status(
|
"workflow": review_merge_state_machine.workflow_status(
|
||||||
completion,
|
completion,
|
||||||
infra_stop=infra_stop,
|
**blocker_kwargs,
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
),
|
),
|
||||||
"blockers": blockers,
|
"blockers": blockers,
|
||||||
"approve": review_merge_state_machine.can_approve(
|
"approve": review_merge_state_machine.can_approve(
|
||||||
completion,
|
completion,
|
||||||
infra_stop=infra_stop,
|
**blocker_kwargs,
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
),
|
),
|
||||||
"merge": review_merge_state_machine.can_merge(
|
"merge": review_merge_state_machine.can_merge(
|
||||||
completion,
|
completion,
|
||||||
pre_merge_gates=pre_merge_gates,
|
pre_merge_gates=pre_merge_gates,
|
||||||
infra_stop=infra_stop,
|
**blocker_kwargs,
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if target_state:
|
if target_state:
|
||||||
result["advancement"] = review_merge_state_machine.assess_state_advancement(
|
result["advancement"] = review_merge_state_machine.assess_state_advancement(
|
||||||
completion,
|
completion,
|
||||||
target_state=target_state,
|
target_state=target_state,
|
||||||
infra_stop=infra_stop,
|
**blocker_kwargs,
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
)
|
)
|
||||||
if recovery_handoff_text is not None:
|
if recovery_handoff_text is not None:
|
||||||
result["recovery_handoff"] = (
|
result["recovery_handoff"] = (
|
||||||
@@ -9096,6 +9562,46 @@ def gitea_list_profiles() -> dict:
|
|||||||
return {"profiles": profiles_out}
|
return {"profiles": profiles_out}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_assess_mcp_namespace_health(
|
||||||
|
namespace: str,
|
||||||
|
required_tool: str | None = None,
|
||||||
|
registered_tools: list[str] | None = None,
|
||||||
|
probe_result: dict | None = None,
|
||||||
|
process: dict | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
configured: bool = True,
|
||||||
|
probe_source: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Classify MCP namespace health for required Gitea tools (#543).
|
||||||
|
|
||||||
|
Static FastMCP registration is not enough to prove a namespace works: IDE
|
||||||
|
clients can keep a registered tool list while live calls fail with
|
||||||
|
``client is closing: EOF``. Pass live IDE invocation evidence with
|
||||||
|
``probe_source='client_namespace'``. Offline subprocess probes
|
||||||
|
(``test_mcp_conn.py``) must use ``probe_source='offline_spawn'`` and never
|
||||||
|
count as IDE proof.
|
||||||
|
|
||||||
|
Assessments are recorded in the session so live
|
||||||
|
``gitea_submit_pr_review`` / ``gitea_merge_pr`` can fail closed when a
|
||||||
|
client-namespace probe reported unhealthy.
|
||||||
|
"""
|
||||||
|
result = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
namespace,
|
||||||
|
required_tool=required_tool,
|
||||||
|
registered_tools=registered_tools,
|
||||||
|
probe_result=probe_result,
|
||||||
|
process=process,
|
||||||
|
config_path=config_path,
|
||||||
|
profile=profile,
|
||||||
|
configured=configured,
|
||||||
|
probe_source=probe_source,
|
||||||
|
)
|
||||||
|
_record_live_namespace_health(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_activate_profile(
|
def gitea_activate_profile(
|
||||||
profile_name: str,
|
profile_name: str,
|
||||||
@@ -10199,6 +10705,24 @@ def gitea_resolve_task_capability(
|
|||||||
|
|
||||||
required_permission = task_capability_map.required_permission(task)
|
required_permission = task_capability_map.required_permission(task)
|
||||||
required_role = task_capability_map.required_role(task)
|
required_role = task_capability_map.required_role(task)
|
||||||
|
role_exclusive_tasks = {
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"blind_pr_queue_review",
|
||||||
|
"pr_queue_cleanup",
|
||||||
|
"pr-queue-cleanup",
|
||||||
|
"merge_pr",
|
||||||
|
"create_branch",
|
||||||
|
"push_branch",
|
||||||
|
"create_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"address_pr_change_requests",
|
||||||
|
"delete_branch",
|
||||||
|
"work_issue",
|
||||||
|
"work-issue",
|
||||||
|
}
|
||||||
|
|
||||||
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
||||||
if required_role == "reviewer":
|
if required_role == "reviewer":
|
||||||
@@ -10293,11 +10817,26 @@ def gitea_resolve_task_capability(
|
|||||||
# Load active permissions
|
# Load active permissions
|
||||||
active_allowed = profile.get("allowed_operations") or []
|
active_allowed = profile.get("allowed_operations") or []
|
||||||
active_forbidden = profile.get("forbidden_operations") or []
|
active_forbidden = profile.get("forbidden_operations") or []
|
||||||
|
active_role_kind = _profile_role_kind(profile)
|
||||||
|
|
||||||
# Check if allowed in current session
|
# Check if allowed in current session
|
||||||
allowed_in_current_session, _ = gitea_config.check_operation(
|
permission_allowed_in_current_session, _ = gitea_config.check_operation(
|
||||||
required_permission, active_allowed, active_forbidden
|
required_permission, active_allowed, active_forbidden
|
||||||
)
|
)
|
||||||
|
role_matches_current_session = (
|
||||||
|
task not in role_exclusive_tasks
|
||||||
|
or active_role_kind == required_role
|
||||||
|
)
|
||||||
|
role_mismatch_reason = None
|
||||||
|
if not role_matches_current_session:
|
||||||
|
role_mismatch_reason = (
|
||||||
|
f"Active profile role '{active_role_kind}' cannot perform "
|
||||||
|
f"{required_role} task '{task}' even if nearby permissions are "
|
||||||
|
"present (fail closed)."
|
||||||
|
)
|
||||||
|
allowed_in_current_session = (
|
||||||
|
permission_allowed_in_current_session and role_matches_current_session
|
||||||
|
)
|
||||||
|
|
||||||
switching = gitea_config.is_runtime_switching_enabled()
|
switching = gitea_config.is_runtime_switching_enabled()
|
||||||
available_in_session = allowed_in_current_session
|
available_in_session = allowed_in_current_session
|
||||||
@@ -10324,7 +10863,13 @@ def gitea_resolve_task_capability(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
|
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
|
||||||
if ok:
|
p_role = (p_data.get("role") or "").strip()
|
||||||
|
p_role_ok = (
|
||||||
|
task not in role_exclusive_tasks
|
||||||
|
or not p_role
|
||||||
|
or p_role == required_role
|
||||||
|
)
|
||||||
|
if ok and p_role_ok:
|
||||||
matching_profiles.append(p_name)
|
matching_profiles.append(p_name)
|
||||||
|
|
||||||
configured = len(matching_profiles) > 0
|
configured = len(matching_profiles) > 0
|
||||||
@@ -10352,6 +10897,8 @@ def gitea_resolve_task_capability(
|
|||||||
reason_msg = (
|
reason_msg = (
|
||||||
f"No profile configured with permission '{required_permission}'."
|
f"No profile configured with permission '{required_permission}'."
|
||||||
)
|
)
|
||||||
|
elif role_mismatch_reason:
|
||||||
|
reason_msg = role_mismatch_reason
|
||||||
different_namespace_required = False
|
different_namespace_required = False
|
||||||
next_safe_action = "None; ready for operations."
|
next_safe_action = "None; ready for operations."
|
||||||
|
|
||||||
@@ -10383,6 +10930,8 @@ def gitea_resolve_task_capability(
|
|||||||
# into author-side mutations.
|
# into author-side mutations.
|
||||||
stop_required = not allowed_in_current_session
|
stop_required = not allowed_in_current_session
|
||||||
task_role_guidance = []
|
task_role_guidance = []
|
||||||
|
if role_mismatch_reason:
|
||||||
|
task_role_guidance.append(f"STOP: {role_mismatch_reason}")
|
||||||
if required_role == "reviewer":
|
if required_role == "reviewer":
|
||||||
if allowed_in_current_session:
|
if allowed_in_current_session:
|
||||||
task_role_guidance.append(
|
task_role_guidance.append(
|
||||||
@@ -10428,7 +10977,9 @@ def gitea_resolve_task_capability(
|
|||||||
"required_role_kind": required_role,
|
"required_role_kind": required_role,
|
||||||
"active_profile": profile["profile_name"],
|
"active_profile": profile["profile_name"],
|
||||||
"active_identity": username,
|
"active_identity": username,
|
||||||
|
"active_role_kind": active_role_kind,
|
||||||
"active_profile_allowed_operations": active_allowed,
|
"active_profile_allowed_operations": active_allowed,
|
||||||
|
"active_profile_permission_allowed": permission_allowed_in_current_session,
|
||||||
"allowed_in_current_session": allowed_in_current_session,
|
"allowed_in_current_session": allowed_in_current_session,
|
||||||
"available_in_session": available_in_session,
|
"available_in_session": available_in_session,
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
|
|||||||
@@ -42,12 +42,38 @@ STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
|||||||
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Durable validation-outcome labels (#529): the four canonical distinctions a
|
||||||
|
# reviewer report can carry. These are orthogonal to the single active status:*
|
||||||
|
# label, so they use their own prefix and are not subject to the one-status
|
||||||
|
# invariant.
|
||||||
|
VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
|
LabelSpec("validation:clean-pass", "0e8a16", "Validation was a clean pass"),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:baseline-accepted",
|
||||||
|
"fbca04",
|
||||||
|
"Validation passed with a baseline-proven unrelated failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:blocked",
|
||||||
|
"b60205",
|
||||||
|
"Validation blocked by an unresolved failure",
|
||||||
|
),
|
||||||
|
LabelSpec(
|
||||||
|
"validation:post-merge-moot",
|
||||||
|
"5319e7",
|
||||||
|
"Validation is post-merge moot (PR already merged/closed before review)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||||
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS
|
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_LABEL_SPECS
|
||||||
)
|
)
|
||||||
|
|
||||||
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
||||||
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
||||||
|
VALIDATION_LABELS: frozenset[str] = frozenset(
|
||||||
|
spec.name for spec in VALIDATION_LABEL_SPECS
|
||||||
|
)
|
||||||
CANONICAL_LABELS: frozenset[str] = frozenset(
|
CANONICAL_LABELS: frozenset[str] = frozenset(
|
||||||
spec.name for spec in CANONICAL_LABEL_SPECS
|
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Assess live MCP namespace health without trusting static registration.
|
||||||
|
|
||||||
|
The IDE/client namespace can fail with EOF even when this Python process still
|
||||||
|
registers the Gitea tools with FastMCP. These helpers keep that distinction
|
||||||
|
explicit so reviewer/merger flows can fail closed on the live path.
|
||||||
|
|
||||||
|
Probe sources
|
||||||
|
-------------
|
||||||
|
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
|
||||||
|
only source that can prove the workflow namespace is healthy).
|
||||||
|
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
|
||||||
|
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
|
||||||
|
IDE-managed namespace is callable.
|
||||||
|
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_NAMESPACE_TOOLS = {
|
||||||
|
"gitea-author": "gitea_whoami",
|
||||||
|
"gitea-reviewer": "gitea_whoami",
|
||||||
|
"gitea-merger": "gitea_whoami",
|
||||||
|
"gitea-tools": "gitea_list_profiles",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
|
||||||
|
|
||||||
|
# Namespaces that must be healthy for a given mutation task.
|
||||||
|
TASK_REQUIRED_NAMESPACES = {
|
||||||
|
"review_pr": "gitea-reviewer",
|
||||||
|
"submit_review": "gitea-reviewer",
|
||||||
|
"merge_pr": "gitea-merger",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROBE_SOURCE_CLIENT = "client_namespace"
|
||||||
|
PROBE_SOURCE_OFFLINE = "offline_spawn"
|
||||||
|
PROBE_SOURCE_UNKNOWN = "unknown"
|
||||||
|
VALID_PROBE_SOURCES = frozenset(
|
||||||
|
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
|
||||||
|
)
|
||||||
|
|
||||||
|
EOF_PATTERNS = (
|
||||||
|
"client is closing: eof",
|
||||||
|
"transport closed",
|
||||||
|
"connection closed",
|
||||||
|
"broken pipe",
|
||||||
|
"end of file",
|
||||||
|
"eof",
|
||||||
|
)
|
||||||
|
|
||||||
|
SAFE_ENV_KEYS = (
|
||||||
|
"GITEA_MCP_PROFILE",
|
||||||
|
"GITEA_PROFILE_NAME",
|
||||||
|
"GITEA_SERVICE",
|
||||||
|
"GITEA_EXECUTION_ROLE",
|
||||||
|
"GITEA_MCP_CONFIG",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(v) for v in value]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_eof(text: str | None) -> bool:
|
||||||
|
lowered = (text or "").lower()
|
||||||
|
return any(pattern in lowered for pattern in EOF_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_probe_source(probe_source: str | None) -> str:
|
||||||
|
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
|
||||||
|
if raw in VALID_PROBE_SOURCES:
|
||||||
|
return raw
|
||||||
|
return PROBE_SOURCE_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
if not process:
|
||||||
|
return {}
|
||||||
|
env = process.get("env") or process.get("environment") or {}
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: str(env[key])
|
||||||
|
for key in SAFE_ENV_KEYS
|
||||||
|
if key in env and env[key] not in (None, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_namespace_probe(
|
||||||
|
namespace: str,
|
||||||
|
*,
|
||||||
|
required_tool: str | None = None,
|
||||||
|
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||||
|
probe_result: dict[str, Any] | None = None,
|
||||||
|
process: dict[str, Any] | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
profile: str | None = None,
|
||||||
|
configured: bool = True,
|
||||||
|
probe_source: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify whether a required tool is callable through a live namespace.
|
||||||
|
|
||||||
|
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
||||||
|
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
||||||
|
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
||||||
|
"""
|
||||||
|
ns = (namespace or "").strip()
|
||||||
|
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
||||||
|
source = _normalize_probe_source(probe_source)
|
||||||
|
registered_list = _as_list(registered_tools)
|
||||||
|
registered = None if registered_list is None else tool in registered_list
|
||||||
|
|
||||||
|
probe = probe_result or {}
|
||||||
|
probe_success = bool(probe.get("success"))
|
||||||
|
error_message = str(
|
||||||
|
probe.get("error")
|
||||||
|
or probe.get("message")
|
||||||
|
or probe.get("stderr")
|
||||||
|
or probe.get("exception")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
error_type = str(probe.get("error_type") or "").strip()
|
||||||
|
if not error_type and error_message:
|
||||||
|
if _contains_eof(error_message):
|
||||||
|
error_type = "namespace_eof"
|
||||||
|
elif "timeout" in error_message.lower():
|
||||||
|
error_type = "namespace_timeout"
|
||||||
|
else:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
error_type = "namespace_not_configured"
|
||||||
|
elif registered is False:
|
||||||
|
error_type = "tool_missing"
|
||||||
|
elif not probe_result:
|
||||||
|
error_type = "live_probe_missing"
|
||||||
|
elif not probe_success and not error_type:
|
||||||
|
error_type = "namespace_call_failed"
|
||||||
|
|
||||||
|
callable_live = bool(configured and probe_result and probe_success)
|
||||||
|
# Probe-path health (spawn or client). IDE-proven only for client path.
|
||||||
|
healthy = bool(configured and registered is not False and callable_live)
|
||||||
|
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
|
||||||
|
process_pid = process.get("pid") if isinstance(process, dict) else None
|
||||||
|
profile_name = profile or (
|
||||||
|
process.get("profile") if isinstance(process, dict) else None
|
||||||
|
)
|
||||||
|
env_summary = _safe_env_summary(process)
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not configured:
|
||||||
|
reasons.append(f"MCP namespace '{ns}' is not configured.")
|
||||||
|
if registered is False:
|
||||||
|
reasons.append(
|
||||||
|
f"Required tool '{tool}' is not registered in namespace '{ns}'."
|
||||||
|
)
|
||||||
|
if error_type == "live_probe_missing":
|
||||||
|
reasons.append(
|
||||||
|
f"No live client invocation proof was supplied for '{ns}.{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_eof":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_timeout":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
elif error_type == "namespace_call_failed":
|
||||||
|
reasons.append(
|
||||||
|
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
|
||||||
|
)
|
||||||
|
if source == PROBE_SOURCE_OFFLINE:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
|
||||||
|
"not prove the IDE-managed MCP namespace is healthy."
|
||||||
|
)
|
||||||
|
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
|
||||||
|
reasons.append(
|
||||||
|
"Probe source unspecified; treat as not IDE-namespace proof unless "
|
||||||
|
"re-supplied with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
|
||||||
|
remediation = []
|
||||||
|
if not healthy or not ide_namespace_proven:
|
||||||
|
remediation.append(
|
||||||
|
"Reconnect the IDE MCP client namespace (client reconnect / "
|
||||||
|
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
|
||||||
|
"record the result with probe_source='client_namespace'."
|
||||||
|
)
|
||||||
|
remediation.append(
|
||||||
|
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
|
||||||
|
"shell kill/PID respawn as proof the IDE namespace is repaired."
|
||||||
|
)
|
||||||
|
if process_pid and source != PROBE_SOURCE_OFFLINE:
|
||||||
|
remediation.append(
|
||||||
|
f"Diagnostics may include PID {process_pid}; process details "
|
||||||
|
"are informational only — recovery is client-layer reconnect."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remediation.append(
|
||||||
|
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
|
||||||
|
f"(probe_source={source})."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client-namespace broken health blocks review/merge. Offline probes never
|
||||||
|
# authorize mutations and only block when they report unhealthy (still
|
||||||
|
# fail-closed for known bad spawn evidence).
|
||||||
|
blocks = False
|
||||||
|
if source == PROBE_SOURCE_CLIENT:
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
elif source == PROBE_SOURCE_OFFLINE:
|
||||||
|
# Offline never proves IDE health; never unblock. Unhealthy offline
|
||||||
|
# still surfaces as a soft diagnostic, not a mutation-ledger block.
|
||||||
|
blocks = False
|
||||||
|
else:
|
||||||
|
# Unknown source: only block when evidence is unhealthy (fail closed
|
||||||
|
# on bad data without treating success as IDE proof).
|
||||||
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": healthy,
|
||||||
|
"healthy": healthy,
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"configured": configured,
|
||||||
|
"registered_tools_checked": registered_list is not None,
|
||||||
|
"required_tool_registered": registered,
|
||||||
|
"required_tool_callable": callable_live,
|
||||||
|
"probe_source": source,
|
||||||
|
"ide_namespace_proven": ide_namespace_proven,
|
||||||
|
"error_type": None if healthy else error_type,
|
||||||
|
"error_message": error_message or None,
|
||||||
|
"reasons": reasons,
|
||||||
|
"remediation": remediation,
|
||||||
|
"diagnostics": {
|
||||||
|
"namespace": ns,
|
||||||
|
"required_tool": tool,
|
||||||
|
"process_pid": process_pid,
|
||||||
|
"profile": profile_name,
|
||||||
|
"env": env_summary,
|
||||||
|
"config_path": config_path,
|
||||||
|
"probe_source": source,
|
||||||
|
},
|
||||||
|
"blocks_merge_workflow": blocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
|
||||||
|
"""Return whether a broken namespace must block a workflow task."""
|
||||||
|
if healthy:
|
||||||
|
return False
|
||||||
|
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
|
||||||
|
|
||||||
|
|
||||||
|
def required_namespace_for_task(task: str) -> str | None:
|
||||||
|
"""Map a mutation task to the MCP namespace that must be healthy."""
|
||||||
|
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_gate_from_session(
|
||||||
|
task: str,
|
||||||
|
session_health: dict[str, dict[str, Any]] | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Fail-closed gate using recorded client-namespace health assessments.
|
||||||
|
|
||||||
|
* Missing session entry → no gate (caller has not assessed yet).
|
||||||
|
* Client-namespace unhealthy / not IDE-proven → block mutation.
|
||||||
|
* Offline-only session entries never authorize mutations.
|
||||||
|
"""
|
||||||
|
ns = required_namespace_for_task(task)
|
||||||
|
if not ns:
|
||||||
|
return []
|
||||||
|
store = session_health or {}
|
||||||
|
entry = store.get(ns)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
source = _normalize_probe_source(entry.get("probe_source"))
|
||||||
|
if source != PROBE_SOURCE_CLIENT:
|
||||||
|
return [
|
||||||
|
f"recorded namespace health for '{ns}' is probe_source={source}, "
|
||||||
|
"not client_namespace; re-probe through the IDE-managed path "
|
||||||
|
f"before {(task or 'mutation')}"
|
||||||
|
]
|
||||||
|
if entry.get("ide_namespace_proven") and entry.get("healthy"):
|
||||||
|
return []
|
||||||
|
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
|
||||||
|
detail = entry.get("error_type") or "unhealthy"
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is recorded {detail} "
|
||||||
|
f"(probe_source={source}); repair the IDE namespace before "
|
||||||
|
f"{(task or 'mutation')} (fail closed, #543)"
|
||||||
|
]
|
||||||
|
if not entry.get("ide_namespace_proven"):
|
||||||
|
return [
|
||||||
|
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
|
||||||
|
"client_namespace probe before mutation (fail closed, #543)"
|
||||||
|
]
|
||||||
|
return []
|
||||||
@@ -30,6 +30,9 @@ DEFAULT_TTL_HOURS = 4.0
|
|||||||
|
|
||||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||||
KIND_DECISION_LOCK = "review_decision_lock"
|
KIND_DECISION_LOCK = "review_decision_lock"
|
||||||
|
KIND_REVIEW_DRAFT = "review_draft"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Canonical post-merge (moot) validation outcome and wording gate (#529).
|
||||||
|
|
||||||
|
When a PR is already merged or closed *before* a reviewer submits their
|
||||||
|
verdict, an ordinary "approve" is misleading: the review never gated the
|
||||||
|
merge, so a normal active approval overstates controller confidence. The
|
||||||
|
sanctioned outcome for that case is a **post-merge moot validation** — the
|
||||||
|
reviewer records what validation found, but classifies it as moot rather than
|
||||||
|
an active approval.
|
||||||
|
|
||||||
|
This module defines the four canonical validation distinctions #529 requires
|
||||||
|
and enforces the post-merge case:
|
||||||
|
|
||||||
|
- ``CLEAN_PASS_OUTCOME`` — clean validation pass on an open, reviewable PR.
|
||||||
|
- ``BASELINE_ACCEPTED_OUTCOME`` — validation pass with a baseline-proven
|
||||||
|
unrelated failure (proof handled by :mod:`premerge_baseline_proof`).
|
||||||
|
- ``BLOCKED_OUTCOME`` — validation blocked by an unresolved failure.
|
||||||
|
- ``POST_MERGE_MOOT_OUTCOME`` — the PR was already merged/closed before review
|
||||||
|
submission; validation is recorded as post-merge moot, not an active
|
||||||
|
approval.
|
||||||
|
|
||||||
|
The gate blocks two mistakes:
|
||||||
|
|
||||||
|
1. Claiming an *active approval* on a PR that was already merged/closed before
|
||||||
|
review submission (must be recorded as post-merge moot validation instead).
|
||||||
|
2. Claiming post-merge moot validation without proof the PR is actually
|
||||||
|
merged/closed (a merged-state field plus a merge commit SHA).
|
||||||
|
|
||||||
|
Each outcome maps to a durable ``validation:*`` process-state label so PRs and
|
||||||
|
issues carry the distinction (#529 acceptance criterion).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CLEAN_PASS_OUTCOME = "clean validation pass"
|
||||||
|
BASELINE_ACCEPTED_OUTCOME = "validation pass with baseline-proven unrelated failure"
|
||||||
|
BLOCKED_OUTCOME = "validation blocked by unresolved failure"
|
||||||
|
POST_MERGE_MOOT_OUTCOME = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Canonical validation status label a reviewer writes in the report body for the
|
||||||
|
# post-merge case; kept in sync with validation_status_vocabulary.
|
||||||
|
POST_MERGE_MOOT_STATUS = "post-merge moot validation"
|
||||||
|
|
||||||
|
# Durable process-state labels (registered in issue_workflow_labels).
|
||||||
|
LABEL_CLEAN_PASS = "validation:clean-pass"
|
||||||
|
LABEL_BASELINE_ACCEPTED = "validation:baseline-accepted"
|
||||||
|
LABEL_BLOCKED = "validation:blocked"
|
||||||
|
LABEL_POST_MERGE_MOOT = "validation:post-merge-moot"
|
||||||
|
|
||||||
|
_OUTCOME_TO_LABEL: dict[str, str] = {
|
||||||
|
CLEAN_PASS_OUTCOME: LABEL_CLEAN_PASS,
|
||||||
|
BASELINE_ACCEPTED_OUTCOME: LABEL_BASELINE_ACCEPTED,
|
||||||
|
BLOCKED_OUTCOME: LABEL_BLOCKED,
|
||||||
|
POST_MERGE_MOOT_OUTCOME: LABEL_POST_MERGE_MOOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The PR was already merged/closed before the review was submitted.
|
||||||
|
_MERGED_BEFORE_REVIEW_RE = re.compile(
|
||||||
|
r"(?:already[- ]merged"
|
||||||
|
r"|merged\s+before\s+review"
|
||||||
|
r"|closed\s+before\s+review"
|
||||||
|
r"|pr\s+(?:is|was)\s+(?:already\s+)?(?:merged|closed)"
|
||||||
|
r"|pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged\s*/\s*closed)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# An active approval verdict is being claimed.
|
||||||
|
_ACTIVE_APPROVAL_RE = re.compile(
|
||||||
|
r"(?:review\s+decision\s*[:=]\s*approve"
|
||||||
|
r"|submitted\s+['\"]?approve['\"]?"
|
||||||
|
r"|active\s+approval"
|
||||||
|
r"|approving\s+the\s+pr"
|
||||||
|
r"|posting\s+an?\s+approval)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# The sanctioned post-merge moot validation wording.
|
||||||
|
_POST_MERGE_MOOT_RE = re.compile(
|
||||||
|
r"post[- ]merge\s+(?:moot\s+)?validation"
|
||||||
|
r"|post[- ]merge\s+moot"
|
||||||
|
r"|moot\s+validation",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Proof the PR is genuinely merged/closed.
|
||||||
|
_MERGED_STATE_PROOF_RE = re.compile(
|
||||||
|
r"(?:pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||||
|
r"|merged_at\s*[:=]\s*\S"
|
||||||
|
r"|closed_at\s*[:=]\s*\S"
|
||||||
|
r"|state\s*[:=]\s*(?:merged|closed))",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||||
|
r"merge\s+commit(?:\s+sha)?\s*[:=]\s*[0-9a-f]{7,40}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POST_MERGE_VALIDATION_TEMPLATE = (
|
||||||
|
"Validation status: post-merge moot validation\n"
|
||||||
|
"PR state: merged\n"
|
||||||
|
"Merge commit sha: <40-hex merge commit>\n"
|
||||||
|
"Validation finding: <what the validation run showed, for the record>\n"
|
||||||
|
"Note: PR merged/closed before review submission; recorded as post-merge "
|
||||||
|
"moot validation, not an active approval."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_state_label(outcome: str) -> str | None:
|
||||||
|
"""Return the durable ``validation:*`` label for a canonical outcome."""
|
||||||
|
return _OUTCOME_TO_LABEL.get(outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_merge_validation(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
pr_merged_or_closed: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess post-merge moot validation wording and proof (#529).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_text: The reviewer final report text.
|
||||||
|
pr_merged_or_closed: Optional structured signal that the PR is already
|
||||||
|
merged/closed. When ``True`` it forces the merged-before-review
|
||||||
|
path even if the report text omits the phrasing; proof fields are
|
||||||
|
still required in the report body for durability.
|
||||||
|
|
||||||
|
Returns a dict with ``proven``, ``block``, ``outcome``,
|
||||||
|
``process_state_label``, ``reasons``, ``skipped`` and ``safe_next_action``.
|
||||||
|
Only blocks when the report either claims an active approval on a
|
||||||
|
merged/closed PR, or claims post-merge moot validation without proof.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
|
||||||
|
merged_signal = bool(_MERGED_BEFORE_REVIEW_RE.search(text)) or bool(
|
||||||
|
pr_merged_or_closed
|
||||||
|
)
|
||||||
|
active_approval = bool(_ACTIVE_APPROVAL_RE.search(text))
|
||||||
|
moot_wording = bool(_POST_MERGE_MOOT_RE.search(text))
|
||||||
|
|
||||||
|
# Nothing about merged-state or moot validation — not applicable here.
|
||||||
|
if not merged_signal and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": None,
|
||||||
|
"process_state_label": None,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": True,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# An active approval on a PR already merged/closed before review, without
|
||||||
|
# the sanctioned moot wording, overstates confidence.
|
||||||
|
if merged_signal and active_approval and not moot_wording:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [
|
||||||
|
"PR was already merged/closed before review submission; an "
|
||||||
|
"active approval overstates confidence — record 'post-merge "
|
||||||
|
"moot validation' instead of a normal approval"
|
||||||
|
],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"classify the outcome as post-merge moot validation (not an "
|
||||||
|
"active approval); cite PR state merged/closed and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-merge moot validation claimed — require merged-state + commit proof.
|
||||||
|
if moot_wording:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not _MERGED_STATE_PROOF_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without merged/closed state "
|
||||||
|
"proof (state: merged/closed, or merged_at/closed_at)"
|
||||||
|
)
|
||||||
|
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"post-merge moot validation claimed without a merge commit SHA"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": reasons,
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove the PR is merged/closed: cite PR state and the merge "
|
||||||
|
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merged signal present but no approval claim and no moot wording yet —
|
||||||
|
# guide toward the moot outcome without blocking.
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||||
|
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||||
|
"reasons": [],
|
||||||
|
"skipped": False,
|
||||||
|
"safe_next_action": (
|
||||||
|
"PR appears merged/closed; if submitting a verdict, record "
|
||||||
|
"post-merge moot validation rather than an active approval"
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
|
|||||||
capability_blocked: bool = False,
|
capability_blocked: bool = False,
|
||||||
mcp_reconnect_failed: bool = False,
|
mcp_reconnect_failed: bool = False,
|
||||||
stale_capability_state: bool = False,
|
stale_capability_state: bool = False,
|
||||||
|
live_namespace_broken: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
|
"""Return hard blockers that forbid all PR queue work (#290 AC3).
|
||||||
|
|
||||||
|
``live_namespace_broken`` fails the merge/review path closed when the live
|
||||||
|
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
|
||||||
|
though the tool is registered in FastMCP (#543 AC5). Feed it the
|
||||||
|
``blocks_merge_workflow`` verdict from
|
||||||
|
``mcp_namespace_health.classify_namespace_probe``.
|
||||||
|
"""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if infra_stop:
|
if infra_stop:
|
||||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||||
@@ -104,6 +112,12 @@ def assess_workflow_blockers(
|
|||||||
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
||||||
if stale_capability_state:
|
if stale_capability_state:
|
||||||
reasons.append("stale MCP capability state detected after reconnect failure")
|
reasons.append("stale MCP capability state detected after reconnect failure")
|
||||||
|
if live_namespace_broken:
|
||||||
|
reasons.append(
|
||||||
|
"live MCP namespace call path is broken (registered in FastMCP but "
|
||||||
|
"not callable through the namespace); repair the namespace before "
|
||||||
|
"review/merge"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"block": bool(reasons),
|
"block": bool(reasons),
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
@@ -118,16 +132,19 @@ def assess_state_advancement(
|
|||||||
state_completion: dict[str, bool] | None,
|
state_completion: dict[str, bool] | None,
|
||||||
*,
|
*,
|
||||||
target_state: str,
|
target_state: str,
|
||||||
infra_stop: bool = False,
|
**blocker_kwargs,
|
||||||
capability_blocked: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed when *target_state* is requested before upstream gates pass."""
|
"""Fail closed when *target_state* is requested before upstream gates pass.
|
||||||
|
|
||||||
|
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
|
||||||
|
``mcp_reconnect_failed``, ``stale_capability_state``,
|
||||||
|
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
|
||||||
|
``can_approve``/``can_merge`` honor the full blocker set, not just
|
||||||
|
infra_stop/capability_blocked (#543 AC5).
|
||||||
|
"""
|
||||||
completion = dict(state_completion or {})
|
completion = dict(state_completion or {})
|
||||||
target = _clean(target_state).upper()
|
target = _clean(target_state).upper()
|
||||||
blockers = assess_workflow_blockers(
|
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||||
infra_stop=infra_stop,
|
|
||||||
capability_blocked=capability_blocked,
|
|
||||||
)
|
|
||||||
reasons = list(blockers["reasons"])
|
reasons = list(blockers["reasons"])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -523,3 +523,240 @@ def assess_lease_inventory(
|
|||||||
"stale_review_leases": stale,
|
"stale_review_leases": stale,
|
||||||
"reclaimable_review_leases": reclaimable,
|
"reclaimable_review_leases": reclaimable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Canonical next-action vocabulary for reviewer lease handoff (#599).
|
||||||
|
NEXT_ACTION_ACQUIRE = "acquire"
|
||||||
|
NEXT_ACTION_WAIT = "wait"
|
||||||
|
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION = "resume_exact_owner_session"
|
||||||
|
NEXT_ACTION_RELEASE_EXPIRED_LEASE = "release_expired_lease"
|
||||||
|
NEXT_ACTION_OPERATOR_AUTHORIZED_CLEANUP = "operator_authorized_cleanup"
|
||||||
|
NEXT_ACTION_REPAIR_WORKTREE_BINDING = "repair_worktree_binding"
|
||||||
|
|
||||||
|
_HANDOFF_CLASSIFICATIONS = frozenset({
|
||||||
|
"no_lease",
|
||||||
|
"own_active",
|
||||||
|
"own_expired",
|
||||||
|
"foreign_active",
|
||||||
|
"foreign_reclaimable",
|
||||||
|
"foreign_expired",
|
||||||
|
"instructed_lease_missing_with_replacement",
|
||||||
|
"worktree_binding_mismatch",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_path(value: str | None) -> str:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return os.path.normpath(text.rstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_reviewer_pr_lease_handoff(
|
||||||
|
comments: list[dict],
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
current_session_id: str | None,
|
||||||
|
current_reviewer_identity: str | None,
|
||||||
|
proposed_worktree: str | None = None,
|
||||||
|
env_bound_worktree: str | None = None,
|
||||||
|
instructed_session_id: str | None = None,
|
||||||
|
instructed_comment_id: int | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify open-PR reviewer lease handoff and emit a canonical next action (#599).
|
||||||
|
|
||||||
|
Read-only diagnosis. Never steals, releases, or adopts a foreign lease.
|
||||||
|
Fail-closed acquisition rules for active foreign leases remain intact.
|
||||||
|
|
||||||
|
Returns a structured diagnosis with:
|
||||||
|
- classification
|
||||||
|
- next_action (one of wait / resume_exact_owner_session /
|
||||||
|
release_expired_lease / operator_authorized_cleanup /
|
||||||
|
repair_worktree_binding / acquire)
|
||||||
|
- active_lease identity fields when present
|
||||||
|
- worktree_binding match result
|
||||||
|
- instructed-lease mismatch flags
|
||||||
|
"""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
session_id = (current_session_id or "").strip()
|
||||||
|
identity = (current_reviewer_identity or "").strip()
|
||||||
|
instructed_sid = (instructed_session_id or "").strip()
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||||
|
session_lease = get_session_lease()
|
||||||
|
|
||||||
|
# Worktree binding: env-bound vs proposed vs active lease worktree.
|
||||||
|
env_wt = _norm_path(env_bound_worktree)
|
||||||
|
prop_wt = _norm_path(proposed_worktree)
|
||||||
|
lease_wt = _norm_path((active or {}).get("worktree") if active else None)
|
||||||
|
binding_mismatch = False
|
||||||
|
binding_details: dict[str, Any] = {
|
||||||
|
"env_bound_worktree": env_bound_worktree or None,
|
||||||
|
"proposed_worktree": proposed_worktree or None,
|
||||||
|
"lease_worktree": (active or {}).get("worktree") if active else None,
|
||||||
|
"match": True,
|
||||||
|
}
|
||||||
|
paths = [p for p in (env_wt, prop_wt, lease_wt) if p]
|
||||||
|
if len(paths) >= 2 and len(set(paths)) > 1:
|
||||||
|
binding_mismatch = True
|
||||||
|
binding_details["match"] = False
|
||||||
|
reasons.append(
|
||||||
|
"worktree binding mismatch: env/proposed/lease worktree paths disagree"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Instructed lease gone while a different lease is active (PR #592-style).
|
||||||
|
instructed_missing_with_replacement = False
|
||||||
|
if instructed_sid or instructed_comment_id is not None:
|
||||||
|
if not active:
|
||||||
|
reasons.append(
|
||||||
|
"instructed lease is gone and no active replacement lease remains"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
owner = (active.get("session_id") or "").strip()
|
||||||
|
cid = active.get("comment_id")
|
||||||
|
sid_mismatch = bool(instructed_sid and owner and owner != instructed_sid)
|
||||||
|
cid_mismatch = (
|
||||||
|
instructed_comment_id is not None
|
||||||
|
and cid is not None
|
||||||
|
and int(cid) != int(instructed_comment_id)
|
||||||
|
)
|
||||||
|
if sid_mismatch or cid_mismatch:
|
||||||
|
instructed_missing_with_replacement = True
|
||||||
|
reasons.append(
|
||||||
|
"instructed lease is gone; a different active lease replaced it "
|
||||||
|
f"(active session_id={owner}, comment_id={cid})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Classification + next_action.
|
||||||
|
classification = "no_lease"
|
||||||
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
|
||||||
|
if active:
|
||||||
|
owner = (active.get("session_id") or "").strip()
|
||||||
|
freshness = active.get("freshness") or classify_lease_freshness(
|
||||||
|
active, now=now
|
||||||
|
)
|
||||||
|
owner_identity = (active.get("reviewer_identity") or "").strip()
|
||||||
|
is_own = bool(session_id and owner and owner == session_id)
|
||||||
|
# Same identity alone is NOT ownership for resume; session_id must match.
|
||||||
|
same_identity = bool(
|
||||||
|
identity and owner_identity and identity == owner_identity
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_own and freshness in {"active", "stale_warning"}:
|
||||||
|
classification = "own_active"
|
||||||
|
next_action = NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
elif is_own and freshness in {"reclaimable", "expired"}:
|
||||||
|
classification = "own_expired"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"own lease freshness is '{freshness}'; release via "
|
||||||
|
"gitea_release_reviewer_pr_lease then re-acquire"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness in {"active", "stale_warning"}:
|
||||||
|
classification = "foreign_active"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
f"foreign active reviewer lease (session_id={owner}, "
|
||||||
|
f"phase={active.get('phase')}, freshness={freshness}); "
|
||||||
|
"do not submit; do not steal"
|
||||||
|
)
|
||||||
|
if same_identity:
|
||||||
|
reasons.append(
|
||||||
|
"lease identity matches current reviewer but session_id differs; "
|
||||||
|
"resume only from the exact owner session_id or wait"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness == "reclaimable":
|
||||||
|
classification = "foreign_reclaimable"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"foreign reclaimable lease (session_id={owner}); clear only via "
|
||||||
|
"sanctioned gitea_release_reviewer_pr_lease when reclaimable"
|
||||||
|
)
|
||||||
|
elif not is_own and freshness == "expired":
|
||||||
|
classification = "foreign_expired"
|
||||||
|
next_action = NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
reasons.append(
|
||||||
|
f"foreign expired lease (session_id={owner}); use sanctioned release"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
classification = "foreign_active"
|
||||||
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
reasons.append(
|
||||||
|
f"unclassified active lease state (session_id={owner}, "
|
||||||
|
f"freshness={freshness}); wait fail-closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instructed_missing_with_replacement and classification.startswith(
|
||||||
|
"foreign"
|
||||||
|
):
|
||||||
|
classification = "instructed_lease_missing_with_replacement"
|
||||||
|
# Foreign active still means wait; reclaimable still release.
|
||||||
|
if next_action == NEXT_ACTION_WAIT:
|
||||||
|
reasons.append(
|
||||||
|
"replacement foreign lease is active — wait; "
|
||||||
|
"operator_authorized_cleanup only with explicit operator authority"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
classification = "no_lease"
|
||||||
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
reasons.append("no active reviewer lease; acquire via gitea_acquire_reviewer_pr_lease")
|
||||||
|
|
||||||
|
# Binding mismatch is a first-class blocker before submit, but does not
|
||||||
|
# erase foreign-lease wait/release guidance. Override next_action only when
|
||||||
|
# the session would otherwise be free to acquire or resume (submit path).
|
||||||
|
if binding_mismatch:
|
||||||
|
if next_action in {
|
||||||
|
NEXT_ACTION_ACQUIRE,
|
||||||
|
NEXT_ACTION_RESUME_EXACT_OWNER_SESSION,
|
||||||
|
}:
|
||||||
|
classification = "worktree_binding_mismatch"
|
||||||
|
next_action = NEXT_ACTION_REPAIR_WORKTREE_BINDING
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"also repair worktree binding before submit "
|
||||||
|
f"(next_action remains {next_action})"
|
||||||
|
)
|
||||||
|
|
||||||
|
lease_summary = None
|
||||||
|
if active:
|
||||||
|
lease_summary = {
|
||||||
|
"comment_id": active.get("comment_id"),
|
||||||
|
"session_id": active.get("session_id"),
|
||||||
|
"phase": active.get("phase"),
|
||||||
|
"candidate_head": active.get("candidate_head"),
|
||||||
|
"expires_at": active.get("expires_at"),
|
||||||
|
"last_activity": active.get("last_activity"),
|
||||||
|
"freshness": active.get("freshness")
|
||||||
|
or classify_lease_freshness(active, now=now),
|
||||||
|
"reviewer_identity": active.get("reviewer_identity"),
|
||||||
|
"profile": active.get("profile"),
|
||||||
|
"worktree": active.get("worktree"),
|
||||||
|
"blocker": active.get("blocker"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"classification": classification,
|
||||||
|
"next_action": next_action,
|
||||||
|
"active_lease": lease_summary,
|
||||||
|
"session_lease": session_lease,
|
||||||
|
"worktree_binding": binding_details,
|
||||||
|
"instructed_session_id": instructed_session_id,
|
||||||
|
"instructed_comment_id": instructed_comment_id,
|
||||||
|
"instructed_lease_missing_with_replacement": instructed_missing_with_replacement,
|
||||||
|
"mutation_allowed": (
|
||||||
|
next_action == NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
and not binding_mismatch
|
||||||
|
and bool(session_lease)
|
||||||
|
),
|
||||||
|
"reasons": reasons,
|
||||||
|
"forbidden": [
|
||||||
|
"manual lock deletion",
|
||||||
|
"raw API bypass",
|
||||||
|
"mtime manipulation",
|
||||||
|
"direct _SESSION_LEASE seeding",
|
||||||
|
"silent foreign lease steal",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
+28
-2
@@ -55,7 +55,6 @@ def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
|
|||||||
|
|
||||||
REVIEWER_TASKS = frozenset({
|
REVIEWER_TASKS = frozenset({
|
||||||
"review_pr",
|
"review_pr",
|
||||||
"merge_pr",
|
|
||||||
"blind_pr_queue_review",
|
"blind_pr_queue_review",
|
||||||
"pr_queue_cleanup",
|
"pr_queue_cleanup",
|
||||||
"pr-queue-cleanup",
|
"pr-queue-cleanup",
|
||||||
@@ -63,6 +62,10 @@ REVIEWER_TASKS = frozenset({
|
|||||||
"approve_pr",
|
"approve_pr",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
MERGER_TASKS = frozenset({
|
||||||
|
"merge_pr",
|
||||||
|
})
|
||||||
|
|
||||||
AUTHOR_TASKS = frozenset({
|
AUTHOR_TASKS = frozenset({
|
||||||
"create_issue",
|
"create_issue",
|
||||||
"comment_issue",
|
"comment_issue",
|
||||||
@@ -98,7 +101,7 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
"delete_branch": "author",
|
"delete_branch": "author",
|
||||||
"review_pr": "reviewer",
|
"review_pr": "reviewer",
|
||||||
"merge_pr": "reviewer",
|
"merge_pr": "merger",
|
||||||
"blind_pr_queue_review": "reviewer",
|
"blind_pr_queue_review": "reviewer",
|
||||||
"pr_queue_cleanup": "reviewer",
|
"pr_queue_cleanup": "reviewer",
|
||||||
"pr-queue-cleanup": "reviewer",
|
"pr-queue-cleanup": "reviewer",
|
||||||
@@ -128,6 +131,10 @@ WRONG_ROLE_RECONCILER_MSG = (
|
|||||||
"MCP namespace/profile with exact close capability."
|
"MCP namespace/profile with exact close capability."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WRONG_ROLE_MERGER_MSG = (
|
||||||
|
"Wrong role/session for merger task. Launch merger MCP namespace."
|
||||||
|
)
|
||||||
|
|
||||||
_session_last_route: dict | None = None
|
_session_last_route: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -243,6 +250,25 @@ def route_task_session(
|
|||||||
_record_route(result)
|
_record_route(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if required_role == "merger":
|
||||||
|
result = {
|
||||||
|
"task_type": task_type,
|
||||||
|
"required_role": required_role,
|
||||||
|
"active_role": active_role_kind,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"route_result": ROUTE_WRONG_ROLE,
|
||||||
|
"downstream_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
WRONG_ROLE_MERGER_MSG,
|
||||||
|
"Merger tasks cannot run in author or reviewer sessions.",
|
||||||
|
],
|
||||||
|
"message": WRONG_ROLE_MERGER_MSG,
|
||||||
|
"runtime_switching_supported": runtime_switching_supported,
|
||||||
|
"profile_switch_blocked": not runtime_switching_supported,
|
||||||
|
}
|
||||||
|
_record_route(result)
|
||||||
|
return result
|
||||||
|
|
||||||
if required_role == "author":
|
if required_role == "author":
|
||||||
route = ROUTE_TO_AUTHOR
|
route = ROUTE_TO_AUTHOR
|
||||||
message = (
|
message = (
|
||||||
|
|||||||
+186
-25
@@ -1,20 +1,45 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Live health-check script to verify Gitea MCP namespace connections.
|
"""Offline-only MCP namespace spawn probe (NOT IDE-namespace proof).
|
||||||
|
|
||||||
Spawns the MCP server processes as defined in the IDE's global config,
|
Spawns a *separate* MCP server process from config via subprocess.Popen,
|
||||||
performs the JSON-RPC handshake, and queries the tools list to verify
|
performs the JSON-RPC handshake, verifies the required tool is registered,
|
||||||
that the connection is fully operational and doesn't return EOF.
|
and invokes that tool. Results are classified with
|
||||||
|
``probe_source=offline_spawn``.
|
||||||
|
|
||||||
|
This path is useful for offline launch/registration debugging. It does
|
||||||
|
**not** prove the IDE-managed MCP client namespace is healthy (#543). For
|
||||||
|
workflow gates, pass live IDE call evidence with
|
||||||
|
``probe_source=client_namespace`` to ``gitea_assess_mcp_namespace_health``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def run_connection_test(name, config):
|
from mcp_namespace_health import REQUIRED_NAMESPACE_TOOLS, classify_namespace_probe
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_line(proc):
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
stderr_content = proc.stderr.read()
|
||||||
|
return None, stderr_content
|
||||||
|
return json.loads(line), None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_message(proc, payload):
|
||||||
|
proc.stdin.write(json.dumps(payload) + "\n")
|
||||||
|
proc.stdin.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def run_connection_test(name, config, *, required_tool=None, config_path=None):
|
||||||
print(f"Testing MCP connection for '{name}'...")
|
print(f"Testing MCP connection for '{name}'...")
|
||||||
command = config.get("command")
|
command = config.get("command")
|
||||||
args = config.get("args", [])
|
args = config.get("args", [])
|
||||||
env = config.get("env", {})
|
env = config.get("env", {})
|
||||||
|
tool_name = required_tool or REQUIRED_NAMESPACE_TOOLS.get(name) or "gitea_whoami"
|
||||||
|
|
||||||
# Merge current environment
|
# Merge current environment
|
||||||
run_env = os.environ.copy()
|
run_env = os.environ.copy()
|
||||||
@@ -31,8 +56,17 @@ def run_connection_test(name, config):
|
|||||||
bufsize=1,
|
bufsize=1,
|
||||||
env=run_env
|
env=run_env
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Failed to spawn process: {e}")
|
print(f" [FAIL] Failed to spawn process: {exc}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": str(exc)},
|
||||||
|
process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Send initialize request
|
# Send initialize request
|
||||||
@@ -48,26 +82,36 @@ def run_connection_test(name, config):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
_write_message(proc, init_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Read response
|
# Read response
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
stderr_content = proc.stderr.read()
|
|
||||||
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
|
print(f" [OK] Received initialize response: {str(res)[:150]}...")
|
||||||
|
|
||||||
# Send initialized notification
|
# Send initialized notification
|
||||||
init_notif = {
|
init_notif = {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"method": "notifications/initialized"
|
"method": "notifications/initialized"
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
_write_message(proc, init_notif)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
# Send tools/list request
|
# Send tools/list request
|
||||||
list_req = {
|
list_req = {
|
||||||
@@ -76,16 +120,27 @@ def run_connection_test(name, config):
|
|||||||
"params": {},
|
"params": {},
|
||||||
"id": 2
|
"id": 2
|
||||||
}
|
}
|
||||||
proc.stdin.write(json.dumps(list_req) + "\n")
|
_write_message(proc, list_req)
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
line = proc.stdout.readline()
|
res, stderr_content = _read_json_line(proc)
|
||||||
if not line:
|
if res is None:
|
||||||
print(" [FAIL] Received EOF on tools/list request.")
|
print(" [FAIL] Received EOF on tools/list request.")
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
res = json.loads(line)
|
|
||||||
if "error" in res:
|
if "error" in res:
|
||||||
print(f" [FAIL] Server returned error: {res['error']}")
|
print(f" [FAIL] Server returned error: {res['error']}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -94,16 +149,115 @@ def run_connection_test(name, config):
|
|||||||
tools = res.get("result", {}).get("tools", [])
|
tools = res.get("result", {}).get("tools", [])
|
||||||
tool_names = [t.get("name") for t in tools]
|
tool_names = [t.get("name") for t in tools]
|
||||||
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
|
||||||
|
if tool_name not in tool_names:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": "required tool missing"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Required tool '{tool_name}' is not registered.")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
call_req = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": tool_name, "arguments": {}},
|
||||||
|
"id": 3,
|
||||||
|
}
|
||||||
|
_write_message(proc, call_req)
|
||||||
|
|
||||||
|
call_res, stderr_content = _read_json_line(proc)
|
||||||
|
if call_res is None:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": stderr_content or "EOF"},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] Received EOF on {tool_name} invocation.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
if "error" in call_res:
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": False, "error": call_res["error"]},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}")
|
||||||
|
print(f" remediation: {' '.join(assessment['remediation'])}")
|
||||||
|
proc.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
assessment = classify_namespace_probe(
|
||||||
|
name,
|
||||||
|
required_tool=tool_name,
|
||||||
|
registered_tools=tool_names,
|
||||||
|
probe_result={"success": True, "result": call_res.get("result")},
|
||||||
|
process={
|
||||||
|
"pid": proc.pid,
|
||||||
|
"profile": env.get("GITEA_MCP_PROFILE"),
|
||||||
|
"env": env,
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
print(f" [OK] Successfully invoked required tool '{tool_name}'.")
|
||||||
|
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f" [FAIL] Error during handshake: {e}")
|
print(f" [FAIL] Error during handshake: {exc}")
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=os.environ.get(
|
||||||
|
"MCP_CONFIG_PATH",
|
||||||
|
os.path.expanduser("~/.gemini/config/mcp_config.json"),
|
||||||
|
),
|
||||||
|
help="Path to MCP config JSON.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--namespace",
|
||||||
|
action="append",
|
||||||
|
dest="namespaces",
|
||||||
|
help="Namespace to test. May be repeated.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = args.config
|
||||||
try:
|
try:
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
mcp_config = json.load(f)
|
mcp_config = json.load(f)
|
||||||
@@ -112,10 +266,17 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
servers = mcp_config.get("mcpServers", {})
|
servers = mcp_config.get("mcpServers", {})
|
||||||
|
namespaces = args.namespaces or list(REQUIRED_NAMESPACE_TOOLS)
|
||||||
failed = False
|
failed = False
|
||||||
for name in ["gitea-author", "gitea-reviewer"]:
|
for name in namespaces:
|
||||||
if name in servers:
|
if name in servers:
|
||||||
if not run_connection_test(name, servers[name]):
|
if not run_connection_test(
|
||||||
|
name,
|
||||||
|
servers[name],
|
||||||
|
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
|
||||||
|
config_path=config_path,
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
):
|
||||||
failed = True
|
failed = True
|
||||||
else:
|
else:
|
||||||
print(f"Server '{name}' not found in mcp_config.json")
|
print(f"Server '{name}' not found in mcp_config.json")
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
|
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Controller-closure baseline-proof gate tests (#529, criterion 6)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from controller_closure_baseline_proof import (
|
||||||
|
assess_controller_closure_baseline_proof,
|
||||||
|
)
|
||||||
|
from premerge_baseline_proof import (
|
||||||
|
CLEAN_PASS,
|
||||||
|
PREMERGE_BASELINE_PROVEN_FAILURE,
|
||||||
|
UNRESOLVED_REGRESSION_RISK,
|
||||||
|
)
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
|
||||||
|
# Complete pre-merge proof fields for a baseline claim (see #533).
|
||||||
|
_PROOF_FIELDS = (
|
||||||
|
"Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
|
||||||
|
"Exit status: 1\n"
|
||||||
|
"Failure signature: AssertionError: None is not true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllerClosureBaselineProof(unittest.TestCase):
|
||||||
|
def test_empty_report_skipped_and_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof("")
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["skipped"])
|
||||||
|
self.assertEqual(result["label"], CLEAN_PASS)
|
||||||
|
|
||||||
|
def test_none_report_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof(None)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["skipped"])
|
||||||
|
|
||||||
|
def test_clean_pass_closure_allowed(self):
|
||||||
|
result = assess_controller_closure_baseline_proof(
|
||||||
|
"Closing #529. Validation: full suite passed, exit status 0. Clean pass."
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["label"], CLEAN_PASS)
|
||||||
|
|
||||||
|
def test_expected_preexisting_failure_without_proof_blocked(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed. This is an expected "
|
||||||
|
"pre-existing failure, safe to close."
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["proven"])
|
||||||
|
self.assertEqual(result["label"], UNRESOLVED_REGRESSION_RISK)
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_current_master_reproduction_only_blocked(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed. Pre-existing baseline failure — "
|
||||||
|
"reproduced on current master after merge.\n"
|
||||||
|
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
|
||||||
|
"Exit status: 1\n"
|
||||||
|
"Failure signature: AssertionError: None is not true\n"
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_proven_baseline_closure_allowed(self):
|
||||||
|
report = (
|
||||||
|
"Closing #529. Full suite: 1 failed, an expected pre-existing "
|
||||||
|
"baseline failure proven on the PR pre-merge base commit.\n"
|
||||||
|
+ _PROOF_FIELDS
|
||||||
|
)
|
||||||
|
result = assess_controller_closure_baseline_proof(report)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestControllerCloseTaskKind(unittest.TestCase):
|
||||||
|
"""The composable validator must cover the controller_close task kind."""
|
||||||
|
|
||||||
|
def test_controller_close_blocks_unproven_baseline(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed. Expected pre-existing failure, closing the "
|
||||||
|
"tracking issue."
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "controller_close")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"premerge_baseline_proof" in f["rule_id"]
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_controller_close_alias_close_issue(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed. Expected pre-existing failure, closing the "
|
||||||
|
"tracking issue."
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "close_issue")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
|
||||||
|
def test_controller_close_allows_proven_baseline(self):
|
||||||
|
report = (
|
||||||
|
"Full suite: 1 failed, expected pre-existing baseline failure proven "
|
||||||
|
"on the PR pre-merge base commit.\n" + _PROOF_FIELDS
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "controller_close")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import gitea_mcp_server
|
||||||
|
import mcp_namespace_health
|
||||||
|
import review_merge_state_machine as rmsm
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpNamespaceHealth(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear()
|
||||||
|
|
||||||
|
def test_registered_tool_but_live_eof_blocks_merge(self):
|
||||||
|
result = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-reviewer",
|
||||||
|
required_tool="gitea_whoami",
|
||||||
|
registered_tools=["gitea_whoami", "gitea_list_profiles"],
|
||||||
|
probe_result={
|
||||||
|
"success": False,
|
||||||
|
"error": "client is closing: EOF",
|
||||||
|
},
|
||||||
|
process={
|
||||||
|
"pid": 4321,
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||||
|
"GITEA_TOKEN": "must-not-leak",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config_path="/Users/jasonwalker/.gemini/config/mcp_config.json",
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["healthy"])
|
||||||
|
self.assertEqual(result["error_type"], "namespace_eof")
|
||||||
|
self.assertTrue(result["required_tool_registered"])
|
||||||
|
self.assertFalse(result["required_tool_callable"])
|
||||||
|
self.assertTrue(result["blocks_merge_workflow"])
|
||||||
|
self.assertFalse(result["ide_namespace_proven"])
|
||||||
|
self.assertEqual(result["probe_source"], "client_namespace")
|
||||||
|
self.assertEqual(result["diagnostics"]["process_pid"], 4321)
|
||||||
|
self.assertEqual(result["diagnostics"]["profile"], "prgs-reviewer")
|
||||||
|
self.assertEqual(
|
||||||
|
result["diagnostics"]["env"]["GITEA_MCP_PROFILE"],
|
||||||
|
"prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertNotIn("GITEA_TOKEN", result["diagnostics"]["env"])
|
||||||
|
self.assertIn("gitea-reviewer", result["reasons"][0])
|
||||||
|
self.assertIn("gitea_whoami", result["reasons"][0])
|
||||||
|
|
||||||
|
def test_successful_client_namespace_invocation_is_ide_proven(self):
|
||||||
|
result = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-author",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True, "result": {"authenticated": True}},
|
||||||
|
process={"pid": 1234, "profile": "prgs-author"},
|
||||||
|
config_path="/tmp/mcp_config.json",
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["healthy"])
|
||||||
|
self.assertTrue(result["required_tool_registered"])
|
||||||
|
self.assertTrue(result["required_tool_callable"])
|
||||||
|
self.assertTrue(result["ide_namespace_proven"])
|
||||||
|
self.assertFalse(result["blocks_merge_workflow"])
|
||||||
|
self.assertIsNone(result["error_type"])
|
||||||
|
|
||||||
|
def test_offline_spawn_success_is_not_ide_proof(self):
|
||||||
|
result = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-merger",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True, "result": {}},
|
||||||
|
process={"pid": 99, "profile": "prgs-merger"},
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["healthy"])
|
||||||
|
self.assertFalse(result["ide_namespace_proven"])
|
||||||
|
self.assertFalse(result["blocks_merge_workflow"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("offline_spawn" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registered_missing_required_tool_fails_before_probe_success(self):
|
||||||
|
result = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-tools",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True, "result": {}},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["healthy"])
|
||||||
|
self.assertEqual(result["required_tool"], "gitea_list_profiles")
|
||||||
|
self.assertFalse(result["required_tool_registered"])
|
||||||
|
self.assertEqual(result["error_type"], "tool_missing")
|
||||||
|
|
||||||
|
def test_server_tool_exposes_same_assessment_and_records_session(self):
|
||||||
|
result = gitea_mcp_server.gitea_assess_mcp_namespace_health(
|
||||||
|
"gitea-merger",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": False, "error": "transport closed"},
|
||||||
|
process={"pid": 9876, "profile": "prgs-merger"},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error_type"], "namespace_eof")
|
||||||
|
self.assertEqual(result["namespace"], "gitea-merger")
|
||||||
|
self.assertEqual(result["diagnostics"]["process_pid"], 9876)
|
||||||
|
self.assertIn("gitea-merger", gitea_mcp_server._LIVE_NAMESPACE_HEALTH)
|
||||||
|
gate = gitea_mcp_server._live_namespace_health_gate("merge_pr")
|
||||||
|
self.assertTrue(gate)
|
||||||
|
self.assertTrue(any("gitea-merger" in r for r in gate))
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveNamespaceBlocksMerge(unittest.TestCase):
|
||||||
|
"""AC5: a broken live namespace must hard-block the review/merge state machine."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear()
|
||||||
|
|
||||||
|
def _merge_ready_completion(self):
|
||||||
|
# Completion through PRE_MERGE_RECHECK is the state where a clean merge
|
||||||
|
# is otherwise allowed (see test_merge_allowed_with_pre_merge_gates).
|
||||||
|
idx = rmsm.REVIEW_MERGE_STATES.index("PRE_MERGE_RECHECK")
|
||||||
|
return {state: True for state in rmsm.REVIEW_MERGE_STATES[: idx + 1]}
|
||||||
|
|
||||||
|
def _all_gates(self):
|
||||||
|
return {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
|
||||||
|
|
||||||
|
def test_assess_workflow_blockers_flags_live_namespace_broken(self):
|
||||||
|
clean = rmsm.assess_workflow_blockers()
|
||||||
|
self.assertFalse(clean["block"])
|
||||||
|
|
||||||
|
broken = rmsm.assess_workflow_blockers(live_namespace_broken=True)
|
||||||
|
self.assertTrue(broken["block"])
|
||||||
|
self.assertTrue(any("namespace" in r.lower() for r in broken["reasons"]))
|
||||||
|
|
||||||
|
def test_can_merge_blocks_even_when_all_gates_pass(self):
|
||||||
|
# Without the namespace blocker a fully-complete workflow can merge...
|
||||||
|
allowed = rmsm.can_merge(
|
||||||
|
self._merge_ready_completion(), pre_merge_gates=self._all_gates()
|
||||||
|
)
|
||||||
|
self.assertTrue(allowed["allowed"])
|
||||||
|
|
||||||
|
# ...but a broken live namespace overrides every satisfied gate.
|
||||||
|
blocked = rmsm.can_merge(
|
||||||
|
self._merge_ready_completion(),
|
||||||
|
pre_merge_gates=self._all_gates(),
|
||||||
|
live_namespace_broken=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(blocked["block"])
|
||||||
|
self.assertFalse(blocked["allowed"])
|
||||||
|
|
||||||
|
def test_workflow_status_forwards_namespace_blocker(self):
|
||||||
|
status = rmsm.workflow_status(
|
||||||
|
self._merge_ready_completion(), live_namespace_broken=True
|
||||||
|
)
|
||||||
|
self.assertFalse(status["merge_allowed"])
|
||||||
|
self.assertFalse(status["approve_allowed"])
|
||||||
|
|
||||||
|
def test_server_tool_blocks_merge_on_live_namespace_broken(self):
|
||||||
|
result = gitea_mcp_server.gitea_assess_review_merge_state_machine(
|
||||||
|
state_completion=self._merge_ready_completion(),
|
||||||
|
pre_merge_gates=self._all_gates(),
|
||||||
|
live_namespace_broken=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["blockers"]["block"])
|
||||||
|
self.assertFalse(result["merge"]["allowed"])
|
||||||
|
|
||||||
|
def test_classify_verdict_bridges_into_merge_block(self):
|
||||||
|
# The classify verdict is the intended feed for live_namespace_broken.
|
||||||
|
verdict = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-merger",
|
||||||
|
registered_tools=["gitea_whoami", "gitea_adopt_merger_pr_lease"],
|
||||||
|
probe_result={"success": False, "error": "client is closing: EOF"},
|
||||||
|
process={"pid": 555, "profile": "prgs-merger"},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["blocks_merge_workflow"])
|
||||||
|
blocked = rmsm.can_merge(
|
||||||
|
self._merge_ready_completion(),
|
||||||
|
pre_merge_gates=self._all_gates(),
|
||||||
|
live_namespace_broken=verdict["blocks_merge_workflow"],
|
||||||
|
)
|
||||||
|
self.assertFalse(blocked["allowed"])
|
||||||
|
|
||||||
|
def test_offline_spawn_does_not_authorize_mutation_gate(self):
|
||||||
|
gitea_mcp_server.gitea_assess_mcp_namespace_health(
|
||||||
|
"gitea-merger",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True, "result": {}},
|
||||||
|
probe_source="offline_spawn",
|
||||||
|
)
|
||||||
|
gate = gitea_mcp_server._live_namespace_health_gate("merge_pr")
|
||||||
|
self.assertTrue(gate)
|
||||||
|
self.assertTrue(any("offline_spawn" in r or "client_namespace" in r for r in gate))
|
||||||
|
|
||||||
|
def test_client_namespace_healthy_clears_mutation_gate(self):
|
||||||
|
gitea_mcp_server.gitea_assess_mcp_namespace_health(
|
||||||
|
"gitea-merger",
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True, "result": {}},
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
self.assertEqual(gitea_mcp_server._live_namespace_health_gate("merge_pr"), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -776,7 +776,6 @@ class TestMergePR(unittest.TestCase):
|
|||||||
|
|
||||||
# Clean ledger each merge test so residual/host durable #332 state
|
# Clean ledger each merge test so residual/host durable #332 state
|
||||||
# cannot short-circuit eligibility gates (#590 / #594).
|
# cannot short-circuit eligibility gates (#590 / #594).
|
||||||
# Host durable state can otherwise leak in when tests clear os.environ.
|
|
||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||||
@@ -2734,6 +2733,56 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
||||||
|
|
||||||
|
def test_close_issue_blocks_unproven_expected_preexisting_failure(self):
|
||||||
|
# #529: a closure report calling a non-zero suite exit an "expected
|
||||||
|
# pre-existing failure" without pre-merge proof must fail closed and
|
||||||
|
# never PATCH the issue state.
|
||||||
|
patched = []
|
||||||
|
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH":
|
||||||
|
patched.append(url)
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_close_issue(
|
||||||
|
issue_number=1,
|
||||||
|
closure_report=(
|
||||||
|
"Full suite: 1 failed. Expected pre-existing failure, safe to close."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(res.get("blocked"))
|
||||||
|
self.assertTrue(res.get("reasons"))
|
||||||
|
self.assertFalse(
|
||||||
|
patched, "must not PATCH issue state when the closure gate blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_close_issue_allows_proven_baseline_closure(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "issues/1" in url:
|
||||||
|
return {"state": "closed"}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "bug", "id": 2}]
|
||||||
|
if method == "GET" and "issues/1" in url:
|
||||||
|
return {"labels": [{"name": "bug"}]}
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_close_issue(
|
||||||
|
issue_number=1,
|
||||||
|
closure_report=(
|
||||||
|
"Full suite: 1 failed, expected pre-existing baseline failure "
|
||||||
|
"proven on the PR pre-merge base commit.\n"
|
||||||
|
"Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Command: venv/bin/python -m pytest -q\n"
|
||||||
|
"Exit status: 1\n"
|
||||||
|
"Failure signature: AssertionError: None is not true\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
|
||||||
def test_merge_pr_with_closes_removes_label(self):
|
def test_merge_pr_with_closes_removes_label(self):
|
||||||
import reviewer_pr_lease
|
import reviewer_pr_lease
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Post-merge moot validation wording + label tests (#529, criteria 1/4/5)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from post_merge_validation import (
|
||||||
|
BASELINE_ACCEPTED_OUTCOME,
|
||||||
|
BLOCKED_OUTCOME,
|
||||||
|
CLEAN_PASS_OUTCOME,
|
||||||
|
LABEL_BASELINE_ACCEPTED,
|
||||||
|
LABEL_BLOCKED,
|
||||||
|
LABEL_CLEAN_PASS,
|
||||||
|
LABEL_POST_MERGE_MOOT,
|
||||||
|
POST_MERGE_MOOT_OUTCOME,
|
||||||
|
assess_post_merge_validation,
|
||||||
|
process_state_label,
|
||||||
|
)
|
||||||
|
from final_report_validator import assess_final_report_validator
|
||||||
|
from issue_workflow_labels import CANONICAL_LABELS, VALIDATION_LABELS
|
||||||
|
|
||||||
|
_MOOT_PROOF = (
|
||||||
|
"Validation status: post-merge moot validation\n"
|
||||||
|
"PR state: merged\n"
|
||||||
|
"Merge commit sha: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
|
||||||
|
"Validation finding: full suite passed, for the record.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostMergeValidation(unittest.TestCase):
|
||||||
|
def test_not_applicable_when_no_merged_or_moot_signal(self):
|
||||||
|
result = assess_post_merge_validation(
|
||||||
|
"Review decision: approve. Validation: full suite passed."
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["skipped"])
|
||||||
|
|
||||||
|
def test_active_approval_on_merged_pr_blocked(self):
|
||||||
|
report = (
|
||||||
|
"PR is already merged. Review decision: approve. "
|
||||||
|
"Validation: full suite passed."
|
||||||
|
)
|
||||||
|
result = assess_post_merge_validation(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["outcome"], POST_MERGE_MOOT_OUTCOME)
|
||||||
|
self.assertEqual(result["process_state_label"], LABEL_POST_MERGE_MOOT)
|
||||||
|
|
||||||
|
def test_active_approval_on_merged_pr_via_structured_signal_blocked(self):
|
||||||
|
report = "Review decision: approve. Validation: full suite passed."
|
||||||
|
result = assess_post_merge_validation(report, pr_merged_or_closed=True)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_moot_wording_without_proof_blocked(self):
|
||||||
|
report = "Recording post-merge moot validation for this PR."
|
||||||
|
result = assess_post_merge_validation(report)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
def test_moot_wording_with_full_proof_allowed(self):
|
||||||
|
result = assess_post_merge_validation(_MOOT_PROOF)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertEqual(result["outcome"], POST_MERGE_MOOT_OUTCOME)
|
||||||
|
|
||||||
|
def test_merged_signal_only_guides_without_blocking(self):
|
||||||
|
result = assess_post_merge_validation(
|
||||||
|
"PR was already merged before review; recording findings."
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["safe_next_action"])
|
||||||
|
|
||||||
|
def test_process_state_label_mapping(self):
|
||||||
|
self.assertEqual(process_state_label(CLEAN_PASS_OUTCOME), LABEL_CLEAN_PASS)
|
||||||
|
self.assertEqual(
|
||||||
|
process_state_label(BASELINE_ACCEPTED_OUTCOME), LABEL_BASELINE_ACCEPTED
|
||||||
|
)
|
||||||
|
self.assertEqual(process_state_label(BLOCKED_OUTCOME), LABEL_BLOCKED)
|
||||||
|
self.assertEqual(
|
||||||
|
process_state_label(POST_MERGE_MOOT_OUTCOME), LABEL_POST_MERGE_MOOT
|
||||||
|
)
|
||||||
|
self.assertIsNone(process_state_label("nonsense"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationLabelsRegistered(unittest.TestCase):
|
||||||
|
def test_validation_labels_are_canonical(self):
|
||||||
|
for label in (
|
||||||
|
LABEL_CLEAN_PASS,
|
||||||
|
LABEL_BASELINE_ACCEPTED,
|
||||||
|
LABEL_BLOCKED,
|
||||||
|
LABEL_POST_MERGE_MOOT,
|
||||||
|
):
|
||||||
|
self.assertIn(label, VALIDATION_LABELS)
|
||||||
|
self.assertIn(label, CANONICAL_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostMergeValidationWiredIntoReview(unittest.TestCase):
|
||||||
|
def test_review_pr_blocks_active_approval_on_merged_pr(self):
|
||||||
|
report = (
|
||||||
|
"PR is already merged. Review decision: approve. "
|
||||||
|
"Validation: full suite passed."
|
||||||
|
)
|
||||||
|
result = assess_final_report_validator(report, "review_pr")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
f["rule_id"] == "reviewer.post_merge_validation"
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_review_pr_allows_proven_post_merge_moot(self):
|
||||||
|
result = assess_final_report_validator(_MOOT_PROOF, "review_pr")
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
f["rule_id"] == "reviewer.post_merge_validation"
|
||||||
|
for f in result["findings"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -287,6 +287,49 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
|
||||||
self.assertEqual(res["required_role_kind"], "merger")
|
self.assertEqual(res["required_role_kind"], "merger")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
|
def test_reviewer_role_with_merge_permission_still_cannot_merge(self, _auth, _api):
|
||||||
|
# #539: exact permission alone is insufficient. The active profile
|
||||||
|
# role must also be merger for merge_pr.
|
||||||
|
config = json.loads(json.dumps(CONFIG_RESOLVER))
|
||||||
|
config["profiles"]["reviewer-with-merge"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "reviewer-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.review", "gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||||
|
"execution_profile": "reviewer-with-merge",
|
||||||
|
}
|
||||||
|
self._write_config(config)
|
||||||
|
with patch.dict(os.environ, self._env("reviewer-with-merge")):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="merge_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["required_role_kind"], "merger")
|
||||||
|
self.assertEqual(res["active_role_kind"], "reviewer")
|
||||||
|
self.assertTrue(res["active_profile_permission_allowed"])
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
self.assertIn("cannot perform merger task", " ".join(res["task_role_guidance"]))
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "merger-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token merger-pass")
|
||||||
|
def test_merger_profile_can_resolve_merge_task(self, _auth, _api):
|
||||||
|
with patch.dict(os.environ, self._env("merger-profile")):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="merge_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["required_role_kind"], "merger")
|
||||||
|
self.assertEqual(res["active_role_kind"], "merger")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertFalse(res["stop_required"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_self_review_blocked_structured(self, mock_api):
|
def test_self_review_blocked_structured(self, mock_api):
|
||||||
# Test that self-approve (self-review gate) is blocked and returns structured reasons
|
# Test that self-approve (self-review gate) is blocked and returns structured reasons
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Tests for preserving and resuming prepared Gitea review drafts (#609)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
sys_path_root = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if sys_path_root not in sys.path:
|
||||||
|
sys.path.insert(0, sys_path_root)
|
||||||
|
|
||||||
|
import mcp_session_state
|
||||||
|
import gitea_mcp_server
|
||||||
|
import reviewer_pr_lease
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewDrafts(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",
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"PYTEST_CURRENT_TEST": "1", # skip runtime stale checks
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._env.start()
|
||||||
|
|
||||||
|
# Mock workspace binding to pass in test context
|
||||||
|
self._nwb_patch = patch(
|
||||||
|
"gitea_mcp_server.nwb.assess_namespace_mutation_workspace",
|
||||||
|
return_value={"block": False, "mutation_workspace": "/tmp/test-worktree"},
|
||||||
|
)
|
||||||
|
self._nwb_patch.start()
|
||||||
|
|
||||||
|
# Mock authentication headers
|
||||||
|
self._auth_patch = patch(
|
||||||
|
"gitea_mcp_server.get_auth_header",
|
||||||
|
return_value="Bearer mock-token",
|
||||||
|
)
|
||||||
|
self._auth_patch.start()
|
||||||
|
|
||||||
|
self._username_patch = patch(
|
||||||
|
"gitea_mcp_server._authenticated_username",
|
||||||
|
return_value="sysadmin",
|
||||||
|
)
|
||||||
|
self._username_patch.start()
|
||||||
|
|
||||||
|
# Clear/Mock local session lease in-memory state
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._username_patch.stop()
|
||||||
|
self._auth_patch.stop()
|
||||||
|
self._nwb_patch.stop()
|
||||||
|
self._env.stop()
|
||||||
|
self._tmpdir.cleanup()
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
def test_save_draft_success(self, mock_api):
|
||||||
|
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
|
||||||
|
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
|
||||||
|
mock_api.return_value = {
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
res = gitea_mcp_server.gitea_save_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
action="approve",
|
||||||
|
body="Good changes",
|
||||||
|
expected_head_sha="headsha1234567890",
|
||||||
|
validation_commands="pytest tests/",
|
||||||
|
validation_results="all pass",
|
||||||
|
blocker_reason="stale daemon",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("success"))
|
||||||
|
self.assertEqual(res.get("head_sha"), "headsha1234567890")
|
||||||
|
|
||||||
|
# Load draft and verify fields
|
||||||
|
draft = mcp_session_state.load_state(
|
||||||
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(draft)
|
||||||
|
self.assertEqual(draft.get("pr_number"), 587)
|
||||||
|
self.assertEqual(draft.get("action"), "approve")
|
||||||
|
self.assertEqual(draft.get("body"), "Good changes")
|
||||||
|
self.assertEqual(draft.get("validation_commands"), "pytest tests/")
|
||||||
|
self.assertEqual(draft.get("validation_results"), "all pass")
|
||||||
|
self.assertEqual(draft.get("blocker_reason"), "stale daemon")
|
||||||
|
self.assertEqual(os.path.realpath(draft.get("worktree_path")), os.path.realpath("/tmp/test-worktree"))
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
@patch("gitea_mcp_server.gitea_submit_pr_review")
|
||||||
|
def test_resume_draft_and_submit_success(self, mock_submit, mock_comments, mock_api):
|
||||||
|
# 1. Save draft
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_mcp_server.gitea_save_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
action="approve",
|
||||||
|
body="Good changes",
|
||||||
|
expected_head_sha="headsha1234567890",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed local session lease
|
||||||
|
reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": 587,
|
||||||
|
"session_id": "session-1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock Gitea comments to return active reviewer lease owned by us
|
||||||
|
mock_comments.return_value = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user": {"username": "sysadmin"},
|
||||||
|
"body": (
|
||||||
|
"<!-- mcp-review-lease:v1 -->\n"
|
||||||
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||||
|
"pr: #587\n"
|
||||||
|
"reviewer_identity: sysadmin\n"
|
||||||
|
"profile: prgs-reviewer\n"
|
||||||
|
"session_id: session-1234\n"
|
||||||
|
"phase: claimed\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_submit.return_value = {"performed": True, "success": True}
|
||||||
|
|
||||||
|
# 2. Resume draft
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
submit=True,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res.get("success"))
|
||||||
|
self.assertTrue(res.get("marked_ready"))
|
||||||
|
self.assertTrue(res.get("submitted"))
|
||||||
|
mock_submit.assert_called_once()
|
||||||
|
|
||||||
|
# Verify draft was deleted after successful submit
|
||||||
|
draft = mcp_session_state.load_state(
|
||||||
|
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertIsNone(draft)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
def test_resume_draft_fails_checks(self, mock_comments, mock_api):
|
||||||
|
# Save a draft
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
gitea_mcp_server.gitea_save_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
action="approve",
|
||||||
|
body="Good changes",
|
||||||
|
expected_head_sha="headsha1234567890",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Seed local session lease
|
||||||
|
reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": 587,
|
||||||
|
"session_id": "session-1234",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock Gitea comments to return active reviewer lease owned by us
|
||||||
|
mock_comments.return_value = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user": {"username": "sysadmin"},
|
||||||
|
"body": (
|
||||||
|
"<!-- mcp-review-lease:v1 -->\n"
|
||||||
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||||
|
"pr: #587\n"
|
||||||
|
"reviewer_identity: sysadmin\n"
|
||||||
|
"profile: prgs-reviewer\n"
|
||||||
|
"session_id: session-1234\n"
|
||||||
|
"phase: claimed\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Case A: PR closed
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "closed",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertIn("PR #587 is not open", res.get("reasons")[0])
|
||||||
|
|
||||||
|
# Case B: Head changed
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha_NEW"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertIn("PR head SHA changed", res.get("reasons")[0])
|
||||||
|
|
||||||
|
# Case C: Target branch changed
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha_NEW"},
|
||||||
|
}
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertIn("target branch SHA changed", res.get("reasons")[0])
|
||||||
|
|
||||||
|
# Case D: Worktree mismatch
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/different-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
||||||
@@ -237,5 +237,113 @@ class TestReviewerLeaseMcpGate(unittest.TestCase):
|
|||||||
self.assertTrue(any("lease" in r.lower() for r in reasons))
|
self.assertTrue(any("lease" in r.lower() for r in reasons))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseHandoffDiagnose(unittest.TestCase):
|
||||||
|
"""#599: canonical next-action diagnosis for open-PR lease handoff."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_no_lease_next_action_acquire(self):
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[],
|
||||||
|
pr_number=592,
|
||||||
|
current_session_id="me-1",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
proposed_worktree="branches/review-pr-592",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], "no_lease")
|
||||||
|
self.assertEqual(result["next_action"], leases.NEXT_ACTION_ACQUIRE)
|
||||||
|
self.assertIsNone(result["active_lease"])
|
||||||
|
|
||||||
|
def test_foreign_active_wait_pr592_style(self):
|
||||||
|
# Instructed lease gone; newer foreign claim is active.
|
||||||
|
old = _lease_comment(592, "23139-da018dab43ba", phase="released")
|
||||||
|
old["id"] = 8640
|
||||||
|
active = _lease_comment(592, "51515-2bfb70f0685f", phase="claimed")
|
||||||
|
active["id"] = 8647
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[old, active],
|
||||||
|
pr_number=592,
|
||||||
|
current_session_id="fresh-session",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
proposed_worktree="branches/review-pr-592-issue-590",
|
||||||
|
instructed_session_id="23139-da018dab43ba",
|
||||||
|
instructed_comment_id=8640,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["classification"],
|
||||||
|
"instructed_lease_missing_with_replacement",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["next_action"], leases.NEXT_ACTION_WAIT)
|
||||||
|
self.assertTrue(result["instructed_lease_missing_with_replacement"])
|
||||||
|
self.assertEqual(result["active_lease"]["session_id"], "51515-2bfb70f0685f")
|
||||||
|
self.assertEqual(result["active_lease"]["comment_id"], 8647)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
|
def test_foreign_reclaimable_release_expired(self):
|
||||||
|
reclaim = _lease_comment(
|
||||||
|
592, "foreign-old", phase="claimed", minutes_ago=65
|
||||||
|
)
|
||||||
|
reclaim["id"] = 99
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[reclaim],
|
||||||
|
pr_number=592,
|
||||||
|
current_session_id="me-1",
|
||||||
|
current_reviewer_identity="sysadmin",
|
||||||
|
proposed_worktree="branches/review-pr-592",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], "foreign_reclaimable")
|
||||||
|
self.assertEqual(
|
||||||
|
result["next_action"], leases.NEXT_ACTION_RELEASE_EXPIRED_LEASE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_own_active_resume(self):
|
||||||
|
head = "a" * 40
|
||||||
|
claim = _lease_comment(592, "me-1", phase="claimed", candidate_head=head)
|
||||||
|
claim["id"] = 10
|
||||||
|
leases.record_session_lease({
|
||||||
|
"pr_number": 592,
|
||||||
|
"session_id": "me-1",
|
||||||
|
"candidate_head": head,
|
||||||
|
"comment_id": 10,
|
||||||
|
}, lease_provenance=mla.build_lease_provenance(
|
||||||
|
source=mla.SOURCE_ACQUIRE,
|
||||||
|
comment_id=10,
|
||||||
|
))
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[claim],
|
||||||
|
pr_number=592,
|
||||||
|
current_session_id="me-1",
|
||||||
|
current_reviewer_identity="rev1",
|
||||||
|
proposed_worktree="branches/review-pr382",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], "own_active")
|
||||||
|
self.assertEqual(
|
||||||
|
result["next_action"], leases.NEXT_ACTION_RESUME_EXACT_OWNER_SESSION
|
||||||
|
)
|
||||||
|
self.assertTrue(result["mutation_allowed"])
|
||||||
|
|
||||||
|
def test_worktree_binding_mismatch(self):
|
||||||
|
claim = _lease_comment(592, "me-1", phase="claimed")
|
||||||
|
claim["id"] = 11
|
||||||
|
# _lease_comment uses branches/review-pr382
|
||||||
|
result = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
[claim],
|
||||||
|
pr_number=592,
|
||||||
|
current_session_id="me-1",
|
||||||
|
current_reviewer_identity="rev1",
|
||||||
|
proposed_worktree="branches/review-pr-592-issue-590",
|
||||||
|
env_bound_worktree="branches/review-pr-538",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["classification"], "worktree_binding_mismatch")
|
||||||
|
self.assertEqual(
|
||||||
|
result["next_action"], leases.NEXT_ACTION_REPAIR_WORKTREE_BINDING
|
||||||
|
)
|
||||||
|
self.assertFalse(result["worktree_binding"]["match"])
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -52,6 +52,20 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"execution_profile": "prgs-reviewer",
|
"execution_profile": "prgs-reviewer",
|
||||||
},
|
},
|
||||||
|
"prgs-merger": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "merger",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.merge", "gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
||||||
|
],
|
||||||
|
"execution_profile": "prgs-merger",
|
||||||
|
},
|
||||||
"prgs-reconciler": {
|
"prgs-reconciler": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
@@ -104,6 +118,7 @@ class TestRoleSessionRouter(unittest.TestCase):
|
|||||||
"GITEA_MCP_PROFILE": profile,
|
"GITEA_MCP_PROFILE": profile,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +242,31 @@ class TestRoleSessionRouter(unittest.TestCase):
|
|||||||
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
|
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
|
||||||
self.assertTrue(route["downstream_allowed"])
|
self.assertTrue(route["downstream_allowed"])
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
|
def test_merge_task_under_reviewer_profile_wrong_role_stop(self, _auth, _api):
|
||||||
|
# #539: a reviewer session must not pass the pre-task merge route
|
||||||
|
# even if its config accidentally includes gitea.pr.merge.
|
||||||
|
with patch.dict(os.environ, self._env("prgs-reviewer")):
|
||||||
|
route = mcp_server.gitea_route_task_session(
|
||||||
|
task_type="merge_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(route["required_role"], "merger")
|
||||||
|
self.assertEqual(route["route_result"], role_session_router.ROUTE_WRONG_ROLE)
|
||||||
|
self.assertFalse(route["downstream_allowed"])
|
||||||
|
self.assertIn("merger", route["message"].lower())
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token merger-pass")
|
||||||
|
def test_merge_task_under_merger_profile_allowed(self, _auth, _api):
|
||||||
|
with patch.dict(os.environ, self._env("prgs-merger")):
|
||||||
|
route = mcp_server.gitea_route_task_session(
|
||||||
|
task_type="merge_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(route["required_role"], "merger")
|
||||||
|
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
|
||||||
|
self.assertTrue(route["downstream_allowed"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
def test_author_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
|
def test_author_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||||
|
|
||||||
|
Enforces review 443 remediation:
|
||||||
|
|
||||||
|
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||||
|
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||||
|
restart is operator-owned; ADR is the authoritative split.
|
||||||
|
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||||
|
(stop → report → operator reload → re-verify parity) and is not a full
|
||||||
|
promotion ledger requirement.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADR = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "architecture"
|
||||||
|
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
)
|
||||||
|
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
|
||||||
|
CROSS_LINK_DOCS = (
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||||
|
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_exists_with_policy_core():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||||
|
assert "#615" in text
|
||||||
|
assert "stable control runtime" in text.lower()
|
||||||
|
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_operator_docs_cross_link_adr():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
assert ADR_BASENAME in text, (
|
||||||
|
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||||
|
f"(issue #615 acceptance criterion 2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||||
|
text = _read(ADR)
|
||||||
|
# Acceptance section must require guide/runbook cross-links.
|
||||||
|
assert "Operator guide / runbooks cross-link" in text or (
|
||||||
|
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||||
|
and "Acceptance" in text
|
||||||
|
)
|
||||||
|
# Cross-link must not remain only as optional tooling item #4.
|
||||||
|
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||||
|
"## 6. Acceptance", 1
|
||||||
|
)[0]
|
||||||
|
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||||
|
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||||
|
)
|
||||||
|
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||||
|
"must cross-link" in text.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
# Forbidden historical self-service instruction.
|
||||||
|
forbidden = (
|
||||||
|
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||||
|
"profile environment variable before claiming or working on any tasks"
|
||||||
|
)
|
||||||
|
assert forbidden not in runbooks, (
|
||||||
|
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||||
|
)
|
||||||
|
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||||
|
assert ADR_BASENAME in runbooks
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||||
|
section = section.split("## Safety notes", 1)[0]
|
||||||
|
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||||
|
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||||
|
assert "Operator-owned" in section or "operator" in section.lower()
|
||||||
|
assert "worktree_path" in section
|
||||||
|
collapsed = " ".join(section.lower().split())
|
||||||
|
assert "client reconnect" in collapsed
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "must not" in lower and "restart" in lower
|
||||||
|
assert "operator" in lower and "reload" in lower
|
||||||
|
assert "supersedes" in lower
|
||||||
|
assert "llm" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "2.6" in text
|
||||||
|
assert "post-merge" in lower or "post merge" in lower
|
||||||
|
assert "parity" in lower and "stale" in lower
|
||||||
|
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||||
|
assert "stop" in lower
|
||||||
|
assert "report" in lower
|
||||||
|
assert "operator" in lower
|
||||||
|
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||||
|
"startup/current-head" in lower
|
||||||
|
)
|
||||||
|
# Not a full promotion ledger for routine reload.
|
||||||
|
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||||
|
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||||
|
)
|
||||||
|
# Stale parity listed among unhealthy triggers.
|
||||||
|
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||||
|
assert "parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
|
assert marker not in text, f"{path} contains {marker!r}"
|
||||||
Reference in New Issue
Block a user