Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2429d9d2e8 | ||
|
|
16cd871fbd | ||
|
|
783ea88a01 | ||
|
|
036b78e31e | ||
|
|
08c3488d7c | ||
|
|
4f2fd9a8b1 | ||
|
|
4185ee1417 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
ebd06b73c9 | ||
|
|
af9f1c5944 | ||
|
|
f83d2c2027 | ||
|
|
93781af7e9 | ||
|
|
314615ec2c | ||
|
|
52013791d4 | ||
|
|
2ac7519792 | ||
|
|
a32c68e24b | ||
|
|
7186718cfd | ||
|
|
964505654f | ||
|
|
008cd3fd74 | ||
|
|
11ffe0f9dd | ||
|
|
683649424b | ||
|
|
ac531dda05 | ||
|
|
36781ebef7 | ||
|
|
2941ec529c | ||
|
|
eddb68818e | ||
|
|
b98e8dfa1a | ||
|
|
86651a3d05 | ||
|
|
3f3f880147 | ||
|
|
6094069ef6 | ||
|
|
6913ac98f1 | ||
|
|
faf36b5bdf | ||
|
|
05b4f13e96 | ||
|
|
57b2023611 | ||
|
|
5debfcf178 | ||
|
|
27d75facd4 | ||
|
|
2b4c291f12 | ||
|
|
b0ae1605c3 | ||
|
|
c738e5b10d | ||
|
|
fe9faec741 | ||
|
|
7f2a5a3d3c | ||
|
|
946d6c2f81 | ||
|
|
64b83cd1fb | ||
|
|
23535e30bc | ||
|
|
351ef3899b | ||
|
|
1334b0b320 | ||
|
|
975674d7f5 | ||
|
|
8d1098f916 | ||
|
|
49aa3b2812 | ||
|
|
94004f05ac | ||
|
|
8d6d3cb014 | ||
|
|
f558b80cc8 | ||
|
|
223cde1b94 | ||
|
|
9b96085d8d | ||
|
|
ddb1dd941b | ||
|
|
d7c29afde5 | ||
|
|
7acd55d2d2 | ||
|
|
46709537c1 | ||
|
|
62ab4b4d95 | ||
|
|
d9cf26ddbf | ||
|
|
b71dfe9696 | ||
|
|
22d4735170 | ||
|
|
d57ca94ec5 | ||
|
|
09d8db5b9e | ||
|
|
3550262613 | ||
|
|
3cb05284b3 | ||
|
|
84b841c727 |
@@ -0,0 +1,98 @@
|
|||||||
|
"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||||
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
||||||
|
"""Return raw git branch-delete commands cited in *text*."""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
commands: list[str] = []
|
||||||
|
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
||||||
|
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||||
|
return list(dict.fromkeys(commands))
|
||||||
|
|
||||||
|
|
||||||
|
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
||||||
|
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
|
||||||
|
commands = raw_branch_delete_commands(text)
|
||||||
|
reasons = [
|
||||||
|
(
|
||||||
|
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
||||||
|
f"{command}"
|
||||||
|
)
|
||||||
|
for command in commands
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"commands": commands,
|
||||||
|
"reasons": reasons,
|
||||||
|
"safe_next_action": (
|
||||||
|
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
|
||||||
|
"helper with explicit branch.delete capability"
|
||||||
|
if reasons
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_merged_pr_branch_cleanup(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_branch: str,
|
||||||
|
merged: bool,
|
||||||
|
remote_branch_exists: bool,
|
||||||
|
open_pr_heads: set[str],
|
||||||
|
head_on_target: bool | None,
|
||||||
|
delete_capability_allowed: bool,
|
||||||
|
confirmation: str | None,
|
||||||
|
protected_branches: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a merged PR source branch can be deleted via MCP."""
|
||||||
|
protected = protected_branches or PROTECTED_BRANCHES
|
||||||
|
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not merged:
|
||||||
|
reasons.append("PR is not merged")
|
||||||
|
if not remote_branch_exists:
|
||||||
|
reasons.append("remote branch already absent")
|
||||||
|
if not head_branch:
|
||||||
|
reasons.append("PR head branch is missing")
|
||||||
|
if head_branch in protected:
|
||||||
|
reasons.append(f"branch '{head_branch}' is protected")
|
||||||
|
if head_branch in open_pr_heads:
|
||||||
|
reasons.append("an open PR still references this head branch")
|
||||||
|
if head_on_target is False:
|
||||||
|
reasons.append("PR head is not an ancestor of the target branch")
|
||||||
|
if head_on_target is None:
|
||||||
|
reasons.append("PR head ancestry could not be proven")
|
||||||
|
if not delete_capability_allowed:
|
||||||
|
reasons.append("gitea.branch.delete capability is not allowed")
|
||||||
|
if confirmation != expected_confirmation:
|
||||||
|
reasons.append(
|
||||||
|
"confirmation must equal "
|
||||||
|
f"'{expected_confirmation}' for branch cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
safe = not reasons
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_branch": head_branch,
|
||||||
|
"expected_confirmation": expected_confirmation,
|
||||||
|
"remote_branch_exists": remote_branch_exists,
|
||||||
|
"safe_to_delete": safe,
|
||||||
|
"block_reasons": reasons,
|
||||||
|
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||||
|
}
|
||||||
+1151
File diff suppressed because it is too large
Load Diff
@@ -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,53 @@
|
|||||||
|
# Control-plane DB substrate (#613)
|
||||||
|
|
||||||
|
**Status:** Implemented (SQLite single-writer MVP)
|
||||||
|
|
||||||
|
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
|
||||||
|
|
||||||
|
**Module:** `control_plane_db.py`
|
||||||
|
|
||||||
|
## Architecture statement
|
||||||
|
|
||||||
|
> **DB coordinates, Gitea records, Sentry/GlitchTip observe; the bridge is the only path that turns observations into Gitea work.**
|
||||||
|
|
||||||
|
## What this ships
|
||||||
|
|
||||||
|
| Capability | Notes |
|
||||||
|
|------------|--------|
|
||||||
|
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
|
||||||
|
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
|
||||||
|
| Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head |
|
||||||
|
| Heartbeat / release / expire | Lease lifecycle helpers |
|
||||||
|
| Terminal-lock index | Routing signal for #600 (terminal path first) |
|
||||||
|
| `incident_links` | Provider-neutral link model for #612 — **not** assignable work; scope keys NULL-safe |
|
||||||
|
|
||||||
|
## Hard rules (enforced in code)
|
||||||
|
|
||||||
|
1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents.
|
||||||
|
2. Two concurrent sessions cannot both receive an active assignment on the same open work item (second gets `wait`).
|
||||||
|
3. Merged/closed work items return `no_safe_work` at assign time, and `require_valid_assignment` fails closed if the work item becomes terminal later.
|
||||||
|
4. Assignments pin `expected_head_sha`; mutations fail closed if the work item head drifts.
|
||||||
|
5. SQLite path is the **single-writer MVP** (`GITEA_CONTROL_PLANE_DB`, default under `~/.cache/gitea-tools/control-plane/`). Multi-session multi-host production requires **Postgres** or a **single allocator daemon** (ADR §6).
|
||||||
|
6. `incident_links` optional scope fields are stored as empty strings (never NULL) so UNIQUE is canonical across minimal upserts.
|
||||||
|
7. Legacy `incident_links` migration collapses NULL-scope duplicates **only** when Gitea targets **and** all meaningful observation metadata agree (fingerprint, status, event_count, permalink, timestamps, linked PRs, etc.). Conflicting metadata fails closed — no silent discard.
|
||||||
|
|
||||||
|
## Dependency chain
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB (this) → #600 allocator API → #612 incident bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
- **#600** must call this substrate (not file locks / comment-only leases alone) for completion.
|
||||||
|
- **#612** must write `incident_links` here and create **Gitea issues**; the allocator assigns those issues, not raw incidents.
|
||||||
|
|
||||||
|
## Non-goals (intentionally deferred)
|
||||||
|
|
||||||
|
- Full `gitea_allocate_next_work` routing policy (REQUEST_CHANGES → author, etc.) — **#600**
|
||||||
|
- Sentry/GlitchTip provider adapters and auto-watchdog — **#612**
|
||||||
|
- Gitea comment/label mirror writers (may be added by allocator tooling later)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest tests/test_control_plane_db.py -q
|
||||||
|
```
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# ADR: MCP allocator, control-plane DB, and Sentry/GlitchTip incident bridge architecture
|
||||||
|
|
||||||
|
- **Status:** Accepted (implementation pending; blocks code for #600 / #612 / #613)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issues:**
|
||||||
|
- [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) — control-plane DB (first)
|
||||||
|
- [#600](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/600) — allocator API (second)
|
||||||
|
- [#612](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/612) — Sentry/GlitchTip incident bridge (third)
|
||||||
|
- **Related:** existing GlitchTip contracts (`glitchtip-to-gitea-workflow-design.md`, `glitchtip-gitea-deduplication-linking-design.md`), trust boundaries (`tool-boundaries.md`, `safety-model.md`), current leases (`pr_work_lease.py`, `issue_lock_store.py`)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
Multiple LLM sessions can independently inspect the Gitea queue and start the same issue or PR. Existing coordination (Gitea comment leases, local issue-lock files, labels) often detects collisions **after** work has started. Parallel issues #600, #612, and #613 each describe part of a fix; without one ADR they risk overlapping stores, wrong dependency order, and trust-boundary violations.
|
||||||
|
|
||||||
|
This ADR is the canonical architecture decision **before** implementing those issues.
|
||||||
|
|
||||||
|
## 2. Decision summary (core)
|
||||||
|
|
||||||
|
| Layer | Owns | Must not |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Control-plane DB** | Sessions, atomic assignment, leases, heartbeats, terminal-lock index, events, incident links | Bypass Gitea workflow gates or replace issue/PR history |
|
||||||
|
| **Gitea** | Durable work record: issues, PRs, comments, labels, reviews, merges | Be the only concurrency lock under multi-session load |
|
||||||
|
| **Sentry / GlitchTip** | Incidents, events, provider UI | Assign work, approve/merge/close, or mutate Gitea outside the bridge |
|
||||||
|
| **Incident bridge** | Provider adapters, reconcile/create Gitea issues, link storage upsert, optional provider writeback | Hand raw provider incidents to the allocator as work items |
|
||||||
|
|
||||||
|
**One-liner:** **DB coordinates. Gitea records. Sentry/GlitchTip observe. The bridge is the only path that turns observations into Gitea work. The allocator assigns only Gitea issues/PRs, never raw monitoring incidents.**
|
||||||
|
|
||||||
|
## 3. Dependency order
|
||||||
|
|
||||||
|
Implementation **must** follow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#613 control-plane DB → #600 allocator API → #612 incident bridge
|
||||||
|
(atomic substrate) (routing policy) (feeds Gitea work)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Issue | Role | Depends on |
|
||||||
|
|-------|------|------------|
|
||||||
|
| **#613** | Durable coordination DB + lease/assignment transactions | — |
|
||||||
|
| **#600** | `gitea_allocate_next_work` policy and tool surface | **#613** (hard) |
|
||||||
|
| **#612** | Multi-project Sentry/GlitchTip → Gitea bridge | **#613** for `incident_links` index; **#600** before treating bridge-created issues as allocator feed in multi-worker prod |
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
|
||||||
|
1. Do **not** implement #600 on file locks / comment-only leases and call it done.
|
||||||
|
2. Do **not** ship #612 as a second assignment system for raw incidents.
|
||||||
|
3. Partial previews (read-only list tools, schema stubs) may land earlier if they do not claim “allocator complete” or “bridge complete.”
|
||||||
|
|
||||||
|
## 4. Authority boundaries
|
||||||
|
|
||||||
|
### 4.1 Gitea (durable source of truth for work)
|
||||||
|
|
||||||
|
Gitea remains authoritative for:
|
||||||
|
|
||||||
|
- Issue and PR identity, titles, bodies, state (open/closed/merged)
|
||||||
|
- Comments, reviews, approvals, REQUEST_CHANGES
|
||||||
|
- Labels and workflow status labels (`status:ready`, `status:in-progress`, …)
|
||||||
|
- Merges, closes, branch refs as recorded by Gitea/git hosting
|
||||||
|
|
||||||
|
Operators and auditors read **history** from Gitea.
|
||||||
|
|
||||||
|
### 4.2 Control-plane DB (coordination / index)
|
||||||
|
|
||||||
|
The control-plane DB is authoritative for **live multi-session coordination**:
|
||||||
|
|
||||||
|
- Which session holds which assignment/lease
|
||||||
|
- Heartbeat freshness and expiry
|
||||||
|
- Terminal-lock index for routing
|
||||||
|
- Event log of allocation/lease transitions
|
||||||
|
- `incident_links` index (see §8)
|
||||||
|
|
||||||
|
It is **not** a substitute for Gitea history. When DB and Gitea disagree on durable work state (e.g. PR already merged), **Gitea wins**; the DB is reconciled.
|
||||||
|
|
||||||
|
### 4.3 Sentry / GlitchTip (observe)
|
||||||
|
|
||||||
|
Providers own incident lifecycle and raw event data. They:
|
||||||
|
|
||||||
|
- Must **not** bypass Gitea gates
|
||||||
|
- Must **not** assign LLM work
|
||||||
|
- May receive **writeback** of resolution status only through the bridge, when configured and supported
|
||||||
|
|
||||||
|
### 4.4 Trust boundaries for credentials
|
||||||
|
|
||||||
|
Aligned with `docs/tool-boundaries.md` and `docs/safety-model.md`:
|
||||||
|
|
||||||
|
- **One MCP server process per trust boundary** remains the default.
|
||||||
|
- Monitor API tokens (Sentry/GlitchTip) live in the **bridge/orchestrator boundary** (or a dedicated observability write/read profile), **not** blindly inside every Gitea MCP author/reviewer/merger process.
|
||||||
|
- Gitea tokens stay on Gitea MCP profiles only.
|
||||||
|
- Orchestrators compose services; they must not become a single credential pool that silently mixes Gitea write + monitor tokens into every worker.
|
||||||
|
|
||||||
|
Tool names such as `gitea_observability_*` may exist as a **namespace façade** only if the runtime still enforces separate credential scopes (e.g. bridge process vs pure Gitea mutation process).
|
||||||
|
|
||||||
|
## 5. Allocator rules (#600 on top of #613)
|
||||||
|
|
||||||
|
### 5.1 Worker contract
|
||||||
|
|
||||||
|
- Workers **do not self-select** work under the standard multi-LLM workflow.
|
||||||
|
- Workers call **`gitea_allocate_next_work`** (with `apply=true` only when taking work).
|
||||||
|
- Mutations that claim exclusive work require a **valid assignment and lease** from the control-plane DB.
|
||||||
|
- Return values include at least: role, issue/PR number, expected head SHA (when applicable), lease ID, expiry, allowed actions, forbidden actions — or a non-assignment outcome (`WAIT`, terminal-path block, no safe work, needs controller, etc.).
|
||||||
|
|
||||||
|
### 5.2 Atomic reserve
|
||||||
|
|
||||||
|
- **Assignment creation and lease creation happen in one DB transaction.**
|
||||||
|
- Two concurrent sessions **must not** receive the same issue/PR as assigned work.
|
||||||
|
- On contention: second session gets **WAIT** or **owner-resume**, never a duplicate lease.
|
||||||
|
|
||||||
|
### 5.3 Routing policy
|
||||||
|
|
||||||
|
| Condition | Route |
|
||||||
|
|-----------|--------|
|
||||||
|
| Current-head **REQUEST_CHANGES** | **Author** (not reviewer/merger) |
|
||||||
|
| **Stale** approval (approval not on current head) | **Reviewer** |
|
||||||
|
| **Clean** approval on current head, mergeable | **Merger** |
|
||||||
|
| Contested / contaminated approval | **Diagnosis / reconciler** (controller path) |
|
||||||
|
| Active **foreign** lease | **WAIT** or **owner-resume** |
|
||||||
|
| **Terminal-review** lock present | **Terminal-path resolution first** before downstream review work |
|
||||||
|
| Merged / closed PR | **Never assign** |
|
||||||
|
| Issue locked by sanctioned lease | **Never assign** to another worker unless lease cleanup/adoption is sanctioned |
|
||||||
|
|
||||||
|
### 5.4 Relationship to Gitea mirrors
|
||||||
|
|
||||||
|
After a successful assignment, the allocator (or sanctioned tooling) may update Gitea comments/labels so humans and audits see state. Those mirrors **are not** the sole lock source.
|
||||||
|
|
||||||
|
## 6. Control-plane DB topology (#613)
|
||||||
|
|
||||||
|
### 6.1 Preferred architecture (multi-daemon / Proxmox)
|
||||||
|
|
||||||
|
For true multi-session concurrency (multiple MCP daemons, multiple hosts, or Proxmox VMs):
|
||||||
|
|
||||||
|
**Prefer either:**
|
||||||
|
|
||||||
|
1. **Shared Postgres** used by all Gitea MCP profiles / allocator callers, **or**
|
||||||
|
2. A **single allocator daemon** (sole writer to the coordination store) that all workers call over MCP/RPC.
|
||||||
|
|
||||||
|
Both satisfy: one transactional authority for “who has this work item.”
|
||||||
|
|
||||||
|
### 6.2 SQLite MVP
|
||||||
|
|
||||||
|
SQLite is acceptable **only** for a **single-writer MVP**:
|
||||||
|
|
||||||
|
- One process owns writes (allocator daemon or single co-located MCP server), **or**
|
||||||
|
- Documented single-host single-daemon experiments with file locking and no multi-host claims.
|
||||||
|
|
||||||
|
SQLite is **not** sufficient to declare multi-session production readiness when four independent LLM sessions talk to four MCP processes without a shared writer.
|
||||||
|
|
||||||
|
### 6.3 Suggested entities (normative shape)
|
||||||
|
|
||||||
|
Minimum logical entities (names may vary; semantics must not):
|
||||||
|
|
||||||
|
1. **sessions** — session_id, role/profile, namespace, pid, started_at, last_heartbeat_at, status
|
||||||
|
2. **work_items** — remote/org/repo, kind ∈ {`issue`, `pr`}, number, state, priority, current_head_sha, updated_at
|
||||||
|
3. **leases** — lease_id, work_item_id, session_id, role, phase, expires_at, heartbeat_at, status
|
||||||
|
4. **assignments** — assignment_id, work_item_id, session_id, allowed_action(s), expected_head_sha, status
|
||||||
|
5. **terminal_locks** — repo/org, terminal_pr, review_id, decision, status, cleanup_state
|
||||||
|
6. **events** — event_id, work_item_id, type, message, created_at
|
||||||
|
7. **incident_links** — provider-neutral link model (see §8)
|
||||||
|
|
||||||
|
**Explicit non-kind:** do **not** use `work_items.kind = sentry_incident` (or glitchtip_incident) as an assignable work unit. Incidents become work only after the bridge creates/links a **Gitea issue**.
|
||||||
|
|
||||||
|
## 7. Lease migration (avoid split-brain)
|
||||||
|
|
||||||
|
### 7.1 Target state
|
||||||
|
|
||||||
|
- **Primary** for live coordination: **control-plane DB** leases and assignments.
|
||||||
|
- **Gitea comment leases** (e.g. `<!-- mcp-review-lease:v1 -->`) and **local issue-lock files** become:
|
||||||
|
- **mirrors** of DB state for human visibility / recovery, and/or
|
||||||
|
- written **only** through the allocator (or sanctioned lease tools that update DB + mirror in one workflow).
|
||||||
|
|
||||||
|
### 7.2 Migration rules
|
||||||
|
|
||||||
|
1. New exclusive claims go through DB assignment+lease first.
|
||||||
|
2. Comment lease / file lock writers that skip the DB are **deprecated** once #613+#600 land.
|
||||||
|
3. Reconciler tooling heals: expired DB lease, orphan Gitea comment, orphan local file — fail closed when ambiguous.
|
||||||
|
4. **Split-brain is a defect:** “DB free + Gitea comment leased” or “DB leased + Gitea free” must be detectable and reconcilable; production paths must not rely on two independent writers.
|
||||||
|
|
||||||
|
### 7.3 Heartbeats
|
||||||
|
|
||||||
|
- Heartbeats update the **DB**.
|
||||||
|
- Optional Gitea summaries must avoid comment spam (periodic summary or edit-in-place policy).
|
||||||
|
|
||||||
|
## 8. Observability bridge (#612)
|
||||||
|
|
||||||
|
### 8.1 Role
|
||||||
|
|
||||||
|
The bridge:
|
||||||
|
|
||||||
|
1. Scans configured Sentry and/or GlitchTip projects (multi-project mappings).
|
||||||
|
2. Deduplicates against existing links / Gitea issues.
|
||||||
|
3. Creates or updates **normal Gitea issues** (labels, sanitized body, provider URL).
|
||||||
|
4. Upserts **one** canonical `incident_links` row.
|
||||||
|
5. Optionally writes resolution status back to the provider when supported.
|
||||||
|
6. Never approves, merges, closes, or otherwise bypasses Gitea workflow gates.
|
||||||
|
|
||||||
|
### 8.2 Allocator interaction
|
||||||
|
|
||||||
|
- The allocator sees observability work **only after** a Gitea issue exists (typically `status:ready` + observability labels).
|
||||||
|
- **Do not assign raw Sentry/GlitchTip incidents** as work items.
|
||||||
|
- Bridge AC “allocator can see linked issues” means: **as ordinary Gitea issues**, not a special incident queue.
|
||||||
|
|
||||||
|
### 8.3 Provider adapters and trust
|
||||||
|
|
||||||
|
- Separate **Sentry** and **GlitchTip** adapters; document API differences; do not assume writeback parity.
|
||||||
|
- Self-hosted base URLs supported (e.g. `https://sentry.prgs.cc`).
|
||||||
|
- Tokens from environment / secret store only.
|
||||||
|
- Prefer phase-1 **explicit reconcile / dry-run** before unsupervised watchdog auto-filing, consistent with existing GlitchTip filing safety contracts.
|
||||||
|
|
||||||
|
### 8.4 Home of implementation
|
||||||
|
|
||||||
|
Filing/orchestration may live in:
|
||||||
|
|
||||||
|
- a control-plane / bridge package or profile, and/or
|
||||||
|
- Gitea-Tools as operator-facing tools that **delegate** to that boundary,
|
||||||
|
|
||||||
|
but must not violate §4.4 credential isolation.
|
||||||
|
|
||||||
|
## 9. Incident link storage (single source of truth)
|
||||||
|
|
||||||
|
### 9.1 Canonical model: `incident_links` (provider-neutral)
|
||||||
|
|
||||||
|
Prefer a **provider-neutral** model over Sentry-only `sentry_links`:
|
||||||
|
|
||||||
|
| Field (logical) | Purpose |
|
||||||
|
|-----------------|---------|
|
||||||
|
| provider | `sentry` \| `glitchtip` \| … |
|
||||||
|
| provider_base_url | Self-hosted base |
|
||||||
|
| provider_org / provider_project | Mapping key |
|
||||||
|
| provider_issue_id / provider_short_id | Provider identity |
|
||||||
|
| provider_permalink | Human URL |
|
||||||
|
| fingerprint | Stable dedupe key when available |
|
||||||
|
| gitea_org / gitea_repo / gitea_issue_number | Linked work |
|
||||||
|
| linked_pr_numbers | Optional PR association |
|
||||||
|
| first_seen / last_seen / event_count | Summary metrics (safe) |
|
||||||
|
| status | link lifecycle |
|
||||||
|
| release_resolved_at / last_sync_at | Sync bookkeeping |
|
||||||
|
|
||||||
|
### 9.2 Gitea as human mirror
|
||||||
|
|
||||||
|
- Issue body markers / structured comments (e.g. HTML comment metadata) remain the **human- and audit-visible mirror**.
|
||||||
|
- They must stay consistent with `incident_links` but are **not** a second independent write path for inventing links without the bridge.
|
||||||
|
|
||||||
|
### 9.3 One truth
|
||||||
|
|
||||||
|
- **Do not** maintain two independent systems of record (e.g. full mapping only in #612 storage **and** a separate #613 `sentry_links` with different semantics).
|
||||||
|
- #612 implementation **must** use the same `incident_links` model introduced under #613 (or a single agreed store both reference).
|
||||||
|
|
||||||
|
## 10. Security and redaction
|
||||||
|
|
||||||
|
Mandatory for bridge payloads, Gitea issue bodies/comments, DB-stored event summaries, and logs:
|
||||||
|
|
||||||
|
- No API tokens, DSNs, passwords, cookies, auth headers
|
||||||
|
- No keychain IDs, private config contents, raw session-state files, full prompt bodies
|
||||||
|
- Sanitize stack locals and request data; store only safe tags/context
|
||||||
|
- Logs must never print provider tokens or DSNs
|
||||||
|
- Redaction tests are required for #612
|
||||||
|
|
||||||
|
## 11. Non-goals
|
||||||
|
|
||||||
|
- Replace Gitea as the durable workflow record
|
||||||
|
- Let Sentry/GlitchTip assign work or mutate Gitea workflow state outside sanctioned tools
|
||||||
|
- Let the bridge approve, merge, close, release, or bypass Gitea gates
|
||||||
|
- Implement #600 on file locks / comments alone and call allocator complete
|
||||||
|
- Treat raw monitoring incidents as `work_items` for the allocator
|
||||||
|
- Put monitor tokens into every Gitea MCP worker process by default
|
||||||
|
|
||||||
|
## 12. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Clear implementation order and ownership
|
||||||
|
- Multi-session safety becomes testable at the DB layer
|
||||||
|
- Observability becomes assignable work without special-casing the allocator
|
||||||
|
- Trust boundaries stay compatible with existing control-plane docs
|
||||||
|
|
||||||
|
### Costs / risks
|
||||||
|
|
||||||
|
- Migration from comment/file leases requires reconciler work
|
||||||
|
- Shared Postgres or single allocator daemon is operational cost
|
||||||
|
- Bridge must be careful not to spam Gitea issues (dedupe, caps, policy modes)
|
||||||
|
|
||||||
|
### Follow-ups (documentation only until implemented)
|
||||||
|
|
||||||
|
1. Update #600, #612, and #613 bodies to **link this ADR** and restate hard dependencies.
|
||||||
|
2. Implement #613 schema + atomic assign/lease.
|
||||||
|
3. Implement #600 against the DB.
|
||||||
|
4. Implement #612 against `incident_links` + Gitea create/update.
|
||||||
|
5. Deprecate dual writers for leases.
|
||||||
|
|
||||||
|
## 13. Acceptance criteria for *this* ADR
|
||||||
|
|
||||||
|
This document is accepted when:
|
||||||
|
|
||||||
|
1. It is merged into `docs/architecture/` on the default branch.
|
||||||
|
2. #600 / #612 / #613 (or their PRs) reference this path as the architecture source of truth.
|
||||||
|
3. No implementation PR for those issues claims completion without conforming to §§2–11.
|
||||||
|
|
||||||
|
## 14. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
|
||||||
@@ -54,6 +54,25 @@ Use `-q` for a compact summary and `-v` to see individual test names.
|
|||||||
./venv/bin/python -m pytest tests/ -q
|
./venv/bin/python -m pytest tests/ -q
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Web UI suite (#436)
|
||||||
|
|
||||||
|
Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
|
||||||
|
read-only guards, registry/prompt/queue loaders, and optional child-issue
|
||||||
|
modules when present on the branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-webui
|
||||||
|
./scripts/ci-webui-check # skip unless the diff touches web UI paths
|
||||||
|
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
|
||||||
|
needs Gitea credentials or MCP daemon credential access. Set
|
||||||
|
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.
|
||||||
|
|
||||||
|
Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
|
||||||
|
`webui/`, `tests/test_webui_*`, or `docs/webui*`.
|
||||||
|
|
||||||
### Run targeted tests
|
### Run targeted tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -661,6 +661,21 @@ session, clearing hung background terminals, switching to MCP-native commit, and
|
|||||||
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
||||||
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||||
|
|
||||||
|
### Terminal launcher diagnostics (#556)
|
||||||
|
|
||||||
|
When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`),
|
||||||
|
do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||||
|
|
||||||
|
1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns
|
||||||
|
categorized failure: missing cwd, cwd not a directory, missing executable,
|
||||||
|
missing runtime wrapper, missing shell, probe timeout, or session launcher
|
||||||
|
failure.
|
||||||
|
2. Call `gitea_get_shell_health` for the shell circuit-breaker state.
|
||||||
|
3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher
|
||||||
|
before allowing git/pytest-oriented work; on failure it returns
|
||||||
|
`BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics.
|
||||||
|
4. Emit the canonical `blocked-diagnose-report.md` template and stop.
|
||||||
|
|
||||||
### Create an issue / child issues
|
### Create an issue / child issues
|
||||||
|
|
||||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||||
@@ -796,6 +811,32 @@ author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
|||||||
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||||
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||||
|
|
||||||
|
### Superseded PR / satisfied issue reconciliation (#525)
|
||||||
|
|
||||||
|
Closing duplicate or superseded PRs after a canonical PR has merged is
|
||||||
|
**reconciler** work. Do not switch to `prgs-author` only to close the duplicate
|
||||||
|
PR, close the satisfied issue, or file a narrow follow-up discovered during
|
||||||
|
reconciliation.
|
||||||
|
|
||||||
|
- **Profile:** `prgs-reconciler`.
|
||||||
|
- **Tasks:** `reconcile_close_superseded_pr`,
|
||||||
|
`reconcile_close_satisfied_issue`, and
|
||||||
|
`reconcile_create_followup_issue`.
|
||||||
|
- **Tool:** `gitea_reconcile_superseded_by_merged_pr`.
|
||||||
|
- **Required proof:**
|
||||||
|
1. Live target PR state is open, but it is not independently required and no
|
||||||
|
mergeable work remains.
|
||||||
|
2. Live superseding PR state is closed and merged.
|
||||||
|
3. Superseding PR head is an ancestor of freshly fetched `master`.
|
||||||
|
4. Merge commit SHA is recorded.
|
||||||
|
5. Canonical close comment cites the superseding PR and merge commit.
|
||||||
|
6. Linked issue closure is attempted only when the issue is open and
|
||||||
|
explicitly satisfied by the superseding PR.
|
||||||
|
- **Fail closed:** missing ancestry proof, missing canonical comment,
|
||||||
|
mergeable target PR, same target/superseding PR, or missing
|
||||||
|
`gitea.pr.close` / `gitea.issue.close` capability.
|
||||||
|
- **Prompt:** `As prgs-reconciler, reconcile PR #N as superseded by merged PR #M; post the canonical close comment, close the superseded PR, and close issue #K only if the merged PR fully satisfies it.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
|
|
||||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||||
@@ -1008,6 +1049,72 @@ Special states:
|
|||||||
Final reports must not claim a comment was posted when
|
Final reports must not claim a comment was posted when
|
||||||
`canonical_comment_validation.allowed` is false (#496 AC14).
|
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||||
|
|
||||||
|
## Stale #332 review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
#332 hard-stops a reviewer session after a terminal live review mutation
|
||||||
|
(`approve` / `request_changes`). After #559 those locks are **durable** under
|
||||||
|
`~/.cache/gitea-tools/session-state/review_decision_lock-<profile>.json`, so they
|
||||||
|
can outlive the PR they protected.
|
||||||
|
|
||||||
|
### When cleanup is **allowed**
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` from a **reviewer** profile when:
|
||||||
|
|
||||||
|
1. A durable review-decision lock has a terminal live mutation, **and**
|
||||||
|
2. Live Gitea state shows that terminal PR is already **merged** or **closed**
|
||||||
|
(so no same-PR merge sequence remains), **and**
|
||||||
|
3. The active profile identity matches the lock, **and**
|
||||||
|
4. Authenticated identity can be verified.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1) Assess only (default)
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=prgs, ...)
|
||||||
|
|
||||||
|
# 2) Apply after is_moot=true and cleanup_allowed=true
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<merged-or-closed-pr>,
|
||||||
|
remote=prgs,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful apply:
|
||||||
|
|
||||||
|
* clears in-memory + durable decision lock for that profile
|
||||||
|
* returns a structured `audit` payload
|
||||||
|
* posts an audit comment on the mooted PR when `gitea.pr.comment` is allowed
|
||||||
|
(`post_audit_comment=true` default)
|
||||||
|
|
||||||
|
Same-profile auto-expire: if `gitea_merge_pr` succeeds on a PR that this same
|
||||||
|
profile just approved, the decision lock is cleared after merge. Cross-profile
|
||||||
|
locks (reviewer vs merger) still require the cleanup tool.
|
||||||
|
|
||||||
|
### When cleanup is **forbidden**
|
||||||
|
|
||||||
|
Do **not** clear when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active #332 hard-stop — continue merge
|
||||||
|
for that approve, or stop after request_changes)
|
||||||
|
* live PR state cannot be fetched (fail closed)
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* identity cannot be verified
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
**Never** use manual deletion of session-state files as the normal workflow.
|
||||||
|
**Never** use cleanup to skip review of an open PR or to bypass eligibility /
|
||||||
|
mark-ready / submit gates on active work.
|
||||||
|
|
||||||
|
### Related tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `gitea_cleanup_stale_review_decision_lock` | Moot decision-lock cleanup (#594) |
|
||||||
|
| `gitea_authorize_review_correction` | Operator-approved correction after a **mistaken** live review on still-active work (#211) |
|
||||||
|
| `gitea_cleanup_post_merge_moot_lease` | Moot **lease** cleanup after merge (#515) — different object |
|
||||||
|
|
||||||
## Fail-closed behavior
|
## Fail-closed behavior
|
||||||
|
|
||||||
Before any mutating action the workflow verifies identity, active profile,
|
Before any mutating action the workflow verifies identity, active profile,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Web UI deployment boundary (#435)
|
||||||
|
|
||||||
|
The MCP Control Plane web UI is an **internal operator console**, not a
|
||||||
|
customer-facing application. The MVP assumes local or trusted-network access
|
||||||
|
only.
|
||||||
|
|
||||||
|
## MVP deployment model
|
||||||
|
|
||||||
|
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||||
|
- **Authentication:** none in MVP — protection comes from network placement
|
||||||
|
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||||
|
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||||
|
embedded in HTML, JavaScript, or browser storage
|
||||||
|
|
||||||
|
Do **not** expose the UI on the public internet without an access layer.
|
||||||
|
|
||||||
|
## Beyond localhost
|
||||||
|
|
||||||
|
If the UI must be reachable outside the operator laptop:
|
||||||
|
|
||||||
|
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
|
||||||
|
authenticated staff reach the service.
|
||||||
|
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
|
||||||
|
without understanding the exposure.
|
||||||
|
3. Set explicit override env vars only after access controls are in place:
|
||||||
|
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
|
||||||
|
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
|
||||||
|
|
||||||
|
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
|
||||||
|
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
|
||||||
|
| `WEBUI_PORT` | `8765` | Listen port |
|
||||||
|
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
|
||||||
|
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
|
||||||
|
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
|
||||||
|
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
|
||||||
|
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
|
||||||
|
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
|
||||||
|
|
||||||
|
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
|
||||||
|
the server when a page needs live Gitea data (e.g. `/queue`). They are not
|
||||||
|
shipped to the browser.
|
||||||
|
|
||||||
|
## Health / deployment metadata
|
||||||
|
|
||||||
|
`GET /health` includes a `deployment` object with bind disposition, runtime
|
||||||
|
assumption paths, and the client-secret policy. Use it to verify an instance is
|
||||||
|
configured for internal-only operation.
|
||||||
|
|
||||||
|
## Non-goals (MVP)
|
||||||
|
|
||||||
|
- Full SSO or session login in the UI
|
||||||
|
- Hosting on the public internet without Access/VPN/WARP
|
||||||
|
- Embedding Gitea tokens in the frontend bundle
|
||||||
+80
-3
@@ -29,6 +29,13 @@ Optional environment variables:
|
|||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||||
| `WEBUI_PORT` | `8765` | Listen port |
|
| `WEBUI_PORT` | `8765` | Listen port |
|
||||||
|
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
|
||||||
|
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
|
||||||
|
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
|
||||||
|
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
|
||||||
|
|
||||||
|
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||||
|
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||||
|
|
||||||
## Routes (MVP)
|
## Routes (MVP)
|
||||||
|
|
||||||
@@ -49,8 +56,9 @@ Optional environment variables:
|
|||||||
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
||||||
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
||||||
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
||||||
| `/leases` | Stub — lease visibility (#433) |
|
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
|
||||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
| `/api/actions` | JSON action registry with capability metadata |
|
||||||
|
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||||
| `/leases` | Lease and collision visibility (#433) |
|
| `/leases` | Lease and collision visibility (#433) |
|
||||||
| `/api/leases` | JSON lease/collision export |
|
| `/api/leases` | JSON lease/collision export |
|
||||||
|
|
||||||
@@ -97,6 +105,15 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
|||||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||||
instead of an empty queue (fail closed).
|
instead of an empty queue (fail closed).
|
||||||
|
|
||||||
|
## Gated actions (#434)
|
||||||
|
|
||||||
|
`/actions` registers future write actions (claim, comment, review, merge,
|
||||||
|
delete branch, create PR/issue). Each entry declares the MCP tool, required
|
||||||
|
permission, and profile role from `task_capability_map.py` — aligned with
|
||||||
|
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
|
||||||
|
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
|
||||||
|
MCP tools.
|
||||||
|
|
||||||
## Worktree hygiene (#432)
|
## Worktree hygiene (#432)
|
||||||
|
|
||||||
`/worktrees` scans local `branches/` directories and registered git worktrees.
|
`/worktrees` scans local `branches/` directories and registered git worktrees.
|
||||||
@@ -106,6 +123,66 @@ referenced by the issue lock file are flagged as anomalies (#404). The page
|
|||||||
includes a copy/paste canonical cleanup prompt only — no deletion actions.
|
includes a copy/paste canonical cleanup prompt only — no deletion actions.
|
||||||
|
|
||||||
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
|
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
|
||||||
|
|
||||||
|
## Lease visibility (#433)
|
||||||
|
|
||||||
|
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||||
|
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||||
|
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||||
|
and duplicate local branches per issue. Links to collision-history backend
|
||||||
|
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||||
|
|
||||||
|
## Runtime health (#430)
|
||||||
|
|
||||||
|
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||||
|
project: active profile and role kind, authenticated identity (when credentials
|
||||||
|
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||||
|
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||||
|
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||||
|
no tokens or MCP restart actions are exposed.
|
||||||
|
|
||||||
|
## Deployment boundary (#435)
|
||||||
|
|
||||||
|
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||||
|
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
|
||||||
|
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
|
|
||||||
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
|
||||||
|
|
||||||
|
## Tests (#436)
|
||||||
|
|
||||||
|
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-webui
|
||||||
|
```
|
||||||
|
|
||||||
|
CI / Jenkins multibranch can call the path-filtered gate (runs only when the
|
||||||
|
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/ci-webui-check
|
||||||
|
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
|
||||||
|
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
|
||||||
|
credentials, so CI can run without MCP daemon credential access. Set
|
||||||
|
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.
|
||||||
|
|
||||||
|
Or invoke unittest directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
|
||||||
|
```
|
||||||
|
|
||||||
## Lease visibility (#433)
|
## Lease visibility (#433)
|
||||||
|
|
||||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||||
@@ -128,4 +205,4 @@ no tokens or MCP restart actions are exposed.
|
|||||||
```bash
|
```bash
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,5 +15,7 @@
|
|||||||
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
||||||
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
||||||
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
||||||
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
8. **Terminal review hard-stop (#332)** — After a terminal live review mutation, only same-PR merge after `approve` may continue. Durable locks (#559) must not be deleted by hand.
|
||||||
9. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
9. **Stale decision-lock cleanup (#594)** — `gitea_cleanup_stale_review_decision_lock` may clear a durable #332 lock **only** when the last terminal mutation's PR is live-state **merged or closed**, identity/profile gates pass, and apply uses a reviewer-capable profile. Open/ambiguous locks stay fail-closed. Successful cleanup records a durable audit trail.
|
||||||
|
10. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||||
|
11. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
||||||
@@ -11,8 +11,10 @@ import inspect
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import branch_cleanup_guard
|
||||||
import issue_acceptance_gate
|
import issue_acceptance_gate
|
||||||
import issue_lock_provenance
|
import issue_lock_provenance
|
||||||
|
import merger_lease_adoption
|
||||||
import reviewer_handoff_consistency
|
import reviewer_handoff_consistency
|
||||||
import thread_state_ledger_validator
|
import thread_state_ledger_validator
|
||||||
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
|
||||||
@@ -38,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 = {
|
||||||
@@ -49,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 = {
|
||||||
@@ -60,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(
|
||||||
@@ -848,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,
|
||||||
*,
|
*,
|
||||||
@@ -1262,6 +1290,24 @@ def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_manual_lease_proof_handoff(report_text: str) -> list[dict[str, str]]:
|
||||||
|
"""Block merge/review handoffs that claim unsafe lease seeding (#535)."""
|
||||||
|
result = merger_lease_adoption.assess_manual_lease_proof_handoff(report_text)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"shared.manual_lease_proof_handoff",
|
||||||
|
result.get("reasons") or [],
|
||||||
|
field="Merge mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=(
|
||||||
|
"use gitea_adopt_merger_pr_lease or gitea_acquire_reviewer_pr_lease; "
|
||||||
|
"cite lease_proof_source / adoption_comment_id; never claim manual "
|
||||||
|
"seeded/injected lease proof"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
def _rule_shared_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]:
|
||||||
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text)
|
||||||
if not result.get("applicable") or result.get("valid"):
|
if not result.get("applicable") or result.get("valid"):
|
||||||
@@ -1294,6 +1340,17 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_shared_raw_branch_delete_bypass(report_text: str) -> list[dict[str, str]]:
|
||||||
|
result = branch_cleanup_guard.assess_raw_branch_delete_report(report_text)
|
||||||
|
if not result["block"]:
|
||||||
|
return []
|
||||||
|
return _findings_from_reasons(
|
||||||
|
"shared.raw_branch_delete_bypass",
|
||||||
|
result["reasons"],
|
||||||
|
field="Cleanup mutations",
|
||||||
|
severity="block",
|
||||||
|
safe_next_action=result["safe_next_action"],
|
||||||
|
)
|
||||||
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_workflow_load_boundary(report_text: str) -> list[dict[str, str]]:
|
||||||
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
"""#403: require structured workflow-load helper result, not file-view narrative."""
|
||||||
if not report_text.strip():
|
if not report_text.strip():
|
||||||
@@ -1447,7 +1504,9 @@ def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, st
|
|||||||
_SHARED_ISSUE_LOCK_RULES = (
|
_SHARED_ISSUE_LOCK_RULES = (
|
||||||
_rule_shared_issue_lock_external_state,
|
_rule_shared_issue_lock_external_state,
|
||||||
_rule_shared_manual_lock_pr_override,
|
_rule_shared_manual_lock_pr_override,
|
||||||
|
_rule_shared_manual_lease_proof_handoff,
|
||||||
_rule_shared_author_reviewer_same_run,
|
_rule_shared_author_reviewer_same_run,
|
||||||
|
_rule_shared_raw_branch_delete_bypass,
|
||||||
_rule_shared_canonical_state_update,
|
_rule_shared_canonical_state_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1481,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,
|
||||||
@@ -1573,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,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1469
-32
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
"""Pre-create issue content gate (#582).
|
||||||
|
|
||||||
|
Blocks issue creation when the title/body is a *vague reference* to
|
||||||
|
out-of-band content the LLM cannot prove it has ("the drafted issue",
|
||||||
|
"the prepared issue", "the issue we discussed", "the previous draft")
|
||||||
|
with no durable source pointer.
|
||||||
|
|
||||||
|
The rule from #582: an LLM must not fabricate an issue from stale chat
|
||||||
|
memory. When the requested issue content is a vague reference and no
|
||||||
|
durable source pointer (existing issue/PR/comment, checked-in file, URL,
|
||||||
|
or scratchpad path) is present, the gate fails closed and the caller must
|
||||||
|
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.
|
||||||
|
|
||||||
|
This gate intentionally does NOT require a non-empty body: title-only
|
||||||
|
issue creation remains allowed for callers that explicitly want it. It
|
||||||
|
only blocks content that is a placeholder reference to something the
|
||||||
|
current context does not contain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# Vague reference phrases that point at out-of-band draft content. Stored
|
||||||
|
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
|
||||||
|
# can be matched against normalized field text.
|
||||||
|
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
|
||||||
|
"the drafted issue",
|
||||||
|
"drafted issue",
|
||||||
|
"the prepared issue",
|
||||||
|
"prepared issue",
|
||||||
|
"the issue we discussed",
|
||||||
|
"the issue we talked about",
|
||||||
|
"the one we discussed",
|
||||||
|
"the previous draft",
|
||||||
|
"previous draft",
|
||||||
|
"the draft above",
|
||||||
|
"the issue above",
|
||||||
|
"as discussed",
|
||||||
|
"as we discussed",
|
||||||
|
"as previously discussed",
|
||||||
|
"per our discussion",
|
||||||
|
"per our conversation",
|
||||||
|
"per our chat",
|
||||||
|
"see above",
|
||||||
|
"same as before",
|
||||||
|
"the issue from earlier",
|
||||||
|
"the issue i drafted",
|
||||||
|
"the issue you drafted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A field that only restates a vague phrase (optionally with a leading verb
|
||||||
|
# such as "create"/"add"/"open"/"file") is still a vague reference.
|
||||||
|
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")
|
||||||
|
|
||||||
|
# Durable source pointers: if any of these appears, the content points at a
|
||||||
|
# retrievable source of truth and is NOT treated as a fabricated reference.
|
||||||
|
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
|
||||||
|
re.compile(r"#\d+"), # #402
|
||||||
|
re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I), # issue 402 / PR #17
|
||||||
|
re.compile(r"\bcomment\s+#?\d+", re.I), # comment 8155
|
||||||
|
re.compile(r"https?://\S+", re.I), # URL
|
||||||
|
re.compile( # checked-in file
|
||||||
|
r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
|
||||||
|
),
|
||||||
|
re.compile(r"\bscratchpad\b", re.I), # scratchpad path
|
||||||
|
re.compile(r"(?:^|\s)/[\w./-]+"), # absolute path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Lowercase, punctuation-stripped, whitespace-collapsed text."""
|
||||||
|
norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
|
||||||
|
norm = re.sub(r"[^\w\s]", " ", norm)
|
||||||
|
norm = re.sub(r"\s+", " ", norm).strip()
|
||||||
|
return norm
|
||||||
|
|
||||||
|
|
||||||
|
def has_durable_source_pointer(text: str) -> bool:
|
||||||
|
"""True when the raw text references a durable, retrievable source."""
|
||||||
|
raw = text or ""
|
||||||
|
return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)
|
||||||
|
|
||||||
|
|
||||||
|
def find_vague_reference(text: str) -> str | None:
|
||||||
|
"""Return the vague phrase a field is dominated by, else ``None``.
|
||||||
|
|
||||||
|
A field is a vague reference only when it contains a known vague phrase,
|
||||||
|
carries no durable source pointer, and the phrase dominates the field
|
||||||
|
(the field is essentially just the phrase). Long, specific bodies that
|
||||||
|
merely contain "as discussed" in passing are not blocked.
|
||||||
|
"""
|
||||||
|
if has_durable_source_pointer(text):
|
||||||
|
return None
|
||||||
|
norm = normalize_text(text)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
stripped = norm
|
||||||
|
for verb in _LEADING_VERBS:
|
||||||
|
if stripped.startswith(verb + " "):
|
||||||
|
stripped = stripped[len(verb) + 1:]
|
||||||
|
for phrase in VAGUE_REFERENCE_PHRASES:
|
||||||
|
if phrase in norm and len(stripped) <= len(phrase) + 12:
|
||||||
|
return phrase
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_issue_content(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate whether proposed issue content is durable enough to create.
|
||||||
|
|
||||||
|
Returns a dict with ``complete`` (bool), ``missing_fields``,
|
||||||
|
``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
|
||||||
|
``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
|
||||||
|
operator override that forces ``complete`` True.
|
||||||
|
"""
|
||||||
|
t = (title or "").strip()
|
||||||
|
b = (body or "").strip()
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
vague_fields: list[str] = []
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if not t:
|
||||||
|
missing_fields.append("title")
|
||||||
|
reasons.append("issue title is required")
|
||||||
|
else:
|
||||||
|
phrase = find_vague_reference(t)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("title")
|
||||||
|
reasons.append(
|
||||||
|
f"title is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if b:
|
||||||
|
phrase = find_vague_reference(b)
|
||||||
|
if phrase:
|
||||||
|
vague_fields.append("body")
|
||||||
|
reasons.append(
|
||||||
|
f"body is a vague reference ('{phrase}') with no durable "
|
||||||
|
"content or source pointer"
|
||||||
|
)
|
||||||
|
|
||||||
|
complete = allow_incomplete or (not missing_fields and not vague_fields)
|
||||||
|
return {
|
||||||
|
"complete": complete,
|
||||||
|
"missing_fields": missing_fields,
|
||||||
|
"vague_fields": vague_fields,
|
||||||
|
"reasons": reasons,
|
||||||
|
"diagnose": "; ".join(reasons),
|
||||||
|
"has_durable_source_pointer": has_durable_source_pointer(b)
|
||||||
|
or has_durable_source_pointer(t),
|
||||||
|
"override_applied": bool(
|
||||||
|
allow_incomplete and (missing_fields or vague_fields)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pre_create_issue_content_gate(
|
||||||
|
title: str,
|
||||||
|
body: str = "",
|
||||||
|
*,
|
||||||
|
allow_incomplete: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Content gate wrapper mirroring the duplicate gate shape.
|
||||||
|
|
||||||
|
``performed`` is True when the content is durable enough to create (or an
|
||||||
|
explicit override was supplied). When False, the caller must return
|
||||||
|
BLOCKED + DIAGNOSE and must not create the issue.
|
||||||
|
"""
|
||||||
|
assessment = assess_issue_content(
|
||||||
|
title, body, allow_incomplete=allow_incomplete
|
||||||
|
)
|
||||||
|
assessment["performed"] = assessment["complete"]
|
||||||
|
return assessment
|
||||||
@@ -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 []
|
||||||
@@ -6,6 +6,9 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||||
|
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||||
|
|
||||||
from role_session_router import (
|
from role_session_router import (
|
||||||
python_bytes_have_conflict_markers,
|
python_bytes_have_conflict_markers,
|
||||||
skip_python_scan_walk_root,
|
skip_python_scan_walk_root,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ after formal approval at the current PR head.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -133,6 +134,115 @@ def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def describe_session_lease_proof(
|
||||||
|
session: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Canonical merge-report lease proof fields (#535).
|
||||||
|
|
||||||
|
Surfaces how the in-session lease was established so merge handoffs can
|
||||||
|
distinguish sanctioned MCP acquire/adopt paths from bare manual seeding.
|
||||||
|
"""
|
||||||
|
if not session:
|
||||||
|
return {
|
||||||
|
"lease_proof_source": None,
|
||||||
|
"lease_recovery_reason": None,
|
||||||
|
"lease_proof_kind": "none",
|
||||||
|
"lease_proof_sanctioned": False,
|
||||||
|
"lease_proof_comment_id": None,
|
||||||
|
"lease_adopted_from_session_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
provenance = session.get("lease_provenance") or {}
|
||||||
|
if not isinstance(provenance, dict):
|
||||||
|
provenance = {}
|
||||||
|
source = (provenance.get("source") or "").strip() or None
|
||||||
|
sanctioned = is_sanctioned_session_lease(session)
|
||||||
|
reason = provenance.get("adoption_reason")
|
||||||
|
if isinstance(reason, str):
|
||||||
|
reason = reason.strip() or None
|
||||||
|
else:
|
||||||
|
reason = None
|
||||||
|
|
||||||
|
if source == SOURCE_ADOPT and sanctioned:
|
||||||
|
kind = "sanctioned_adoption"
|
||||||
|
reason = reason or DEFAULT_ADOPTION_REASON
|
||||||
|
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||||
|
kind = "sanctioned_acquire"
|
||||||
|
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||||
|
kind = "sanctioned_heartbeat"
|
||||||
|
elif source:
|
||||||
|
kind = "unsanctioned"
|
||||||
|
else:
|
||||||
|
# Session present without provenance = classic manual _SESSION_LEASE seed.
|
||||||
|
kind = "unsanctioned_manual_seed"
|
||||||
|
|
||||||
|
comment_id = provenance.get("comment_id")
|
||||||
|
if comment_id is None:
|
||||||
|
comment_id = session.get("comment_id")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lease_proof_source": source,
|
||||||
|
"lease_recovery_reason": reason,
|
||||||
|
"lease_proof_kind": kind,
|
||||||
|
"lease_proof_sanctioned": sanctioned,
|
||||||
|
"lease_proof_comment_id": comment_id,
|
||||||
|
"lease_adopted_from_session_id": provenance.get("adopted_from_session_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Phrases that claim non-MCP / fabricated lease proof in handoffs (#535).
|
||||||
|
_UNSAFE_LEASE_PROOF_CLAIM_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"manually\s+seeded\s+lease|"
|
||||||
|
r"manual(?:ly)?\s+seed(?:ed)?\s+(?:lease|proof)|"
|
||||||
|
r"seeded\s+lease\s+proof|"
|
||||||
|
r"injected\s+lease|"
|
||||||
|
r"lease\s+proof\s+injected|"
|
||||||
|
r"manual\s+_?SESSION_LEASE|"
|
||||||
|
r"_SESSION_LEASE\s+seed|"
|
||||||
|
r"unsanctioned\s+lease\s+proof"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Evidence that the report used the sanctioned MCP adoption/acquire path.
|
||||||
|
_SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||||
|
r"(?is)\b("
|
||||||
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
r"sanctioned_adoption|"
|
||||||
|
r"mcp-review-lease-adoption"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||||
|
"""Controller audit: block handoffs that claim unsafe lease seeding (#535).
|
||||||
|
|
||||||
|
Reports may discuss manual seeding as a blocked/historical anti-pattern only
|
||||||
|
when they also cite sanctioned MCP adoption/acquire evidence. Bare claims of
|
||||||
|
manual/seeded/injected lease proof without that evidence fail closed.
|
||||||
|
"""
|
||||||
|
text = report_text or ""
|
||||||
|
if not _UNSAFE_LEASE_PROOF_CLAIM_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
if _SANCTIONED_LEASE_EVIDENCE_RE.search(text):
|
||||||
|
return {"proven": True, "block": False, "reasons": []}
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": [
|
||||||
|
"report claims manual/seeded/injected/unsanctioned lease proof without "
|
||||||
|
"sanctioned MCP adoption evidence (use gitea_adopt_merger_pr_lease / "
|
||||||
|
"gitea_acquire_reviewer_pr_lease and cite lease_proof_source; #535)"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_adopt_merger_lease(
|
def assess_adopt_merger_lease(
|
||||||
comments: list[dict],
|
comments: list[dict],
|
||||||
*,
|
*,
|
||||||
|
|||||||
+161
-1
@@ -8,6 +8,9 @@ available unless explicit recovery-mode proof is supplied.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||||
@@ -366,4 +369,161 @@ def assess_gitea_operation_path(
|
|||||||
else "proceed with native MCP"
|
else "proceed with native MCP"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_terminal_probe_command() -> list[str]:
|
||||||
|
return ["git", "--version"] if shutil.which("git") else ["echo", "ok"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_executable(cwd: str | None, executable: str | None) -> str | None:
|
||||||
|
if not executable:
|
||||||
|
return None
|
||||||
|
if os.path.isabs(executable):
|
||||||
|
if os.path.exists(executable) and os.access(executable, os.X_OK):
|
||||||
|
return executable
|
||||||
|
return None
|
||||||
|
if "/" in executable or "\\" in executable:
|
||||||
|
target_cwd = cwd or os.getcwd()
|
||||||
|
full_path = os.path.abspath(os.path.join(target_cwd, executable))
|
||||||
|
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
||||||
|
return full_path
|
||||||
|
return None
|
||||||
|
return shutil.which(executable)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]:
|
||||||
|
"""Inspect and categorize command spawn failures (#556)."""
|
||||||
|
diagnostics = {
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": command,
|
||||||
|
"cwd_exists": True,
|
||||||
|
"cwd_is_dir": True,
|
||||||
|
"shell_exists": True,
|
||||||
|
"executable_exists": True,
|
||||||
|
"resolved_executable": None,
|
||||||
|
"error_type": "session launcher failure",
|
||||||
|
"error_msg": (
|
||||||
|
"Subprocess spawn failed despite valid CWD and executable. This may be due "
|
||||||
|
"to sandboxing, permission restrictions, or system resource limits."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cwd:
|
||||||
|
if not os.path.exists(cwd):
|
||||||
|
diagnostics["cwd_exists"] = False
|
||||||
|
diagnostics["error_type"] = "missing cwd"
|
||||||
|
diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}"
|
||||||
|
return diagnostics
|
||||||
|
if not os.path.isdir(cwd):
|
||||||
|
diagnostics["cwd_is_dir"] = False
|
||||||
|
diagnostics["error_type"] = "cwd is not a directory"
|
||||||
|
diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}"
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
exe = command[0] if command else None
|
||||||
|
if exe:
|
||||||
|
resolved_exe = _resolve_executable(cwd, exe)
|
||||||
|
diagnostics["resolved_executable"] = resolved_exe
|
||||||
|
if not resolved_exe:
|
||||||
|
diagnostics["executable_exists"] = False
|
||||||
|
if "venv" in exe or "pytest" in exe:
|
||||||
|
diagnostics["error_type"] = "missing runtime wrapper"
|
||||||
|
diagnostics["error_msg"] = (
|
||||||
|
"Virtual environment runtime wrapper/executable not found or not "
|
||||||
|
f"executable: {exe}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
diagnostics["error_type"] = "missing executable"
|
||||||
|
diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}"
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
# Check common shell availability
|
||||||
|
has_any_shell = False
|
||||||
|
for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"):
|
||||||
|
if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
|
||||||
|
has_any_shell = True
|
||||||
|
break
|
||||||
|
if not has_any_shell:
|
||||||
|
diagnostics["shell_exists"] = False
|
||||||
|
diagnostics["error_type"] = "missing shell"
|
||||||
|
diagnostics["error_msg"] = (
|
||||||
|
"No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is "
|
||||||
|
"available or executable."
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def probe_terminal_spawn(
|
||||||
|
cwd: str | None = None,
|
||||||
|
command: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Proactively probe command spawning to detect launcher health (#556)."""
|
||||||
|
probe_cmd = command or default_terminal_probe_command()
|
||||||
|
diag = diagnose_terminal_failure(cwd, probe_cmd)
|
||||||
|
if diag["error_type"] != "session launcher failure":
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": diag["error_msg"],
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
probe_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=cwd,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return {
|
||||||
|
"healthy": True,
|
||||||
|
"error_type": None,
|
||||||
|
"error_msg": "",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
# Non-zero status still proves the launcher spawned the process.
|
||||||
|
return {
|
||||||
|
"healthy": True,
|
||||||
|
"error_type": None,
|
||||||
|
"error_msg": "",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"exit_code": proc.returncode,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": diag["error_msg"],
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "probe timeout",
|
||||||
|
"error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": diag["error_type"],
|
||||||
|
"error_msg": f"Subprocess spawn failed with exception: {exc}",
|
||||||
|
"cwd": cwd,
|
||||||
|
"command": probe_cmd,
|
||||||
|
"diagnostics": diag,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
),
|
||||||
|
}
|
||||||
+26
-2
@@ -16,11 +16,35 @@ or the local remote URL is unavailable (best-effort corroboration only).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
REMEDIATION = (
|
REMEDIATION = (
|
||||||
"Pass explicit org= and repo= matching the local git remote, "
|
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||||
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools."
|
"accept those parameters (including mcp_get_control_plane_guide), "
|
||||||
|
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools. "
|
||||||
|
"Do not pass org/repo to tools whose schema does not list them."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# https://host/org/repo.git or git@host:org/repo.git
|
||||||
|
_REMOTE_URL_SLUG_RE = re.compile(
|
||||||
|
r"(?:[:/])(?P<org>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_org_repo_from_remote_url(remote_url: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Best-effort parse of org/repo from a git remote URL."""
|
||||||
|
url = (remote_url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _REMOTE_URL_SLUG_RE.search(url)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
org = (match.group("org") or "").strip()
|
||||||
|
repo = (match.group("repo") or "").strip()
|
||||||
|
if not org or not repo:
|
||||||
|
return None
|
||||||
|
return org, repo
|
||||||
|
|
||||||
|
|
||||||
def assess_remote_repo_match(
|
def assess_remote_repo_match(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -4842,6 +4842,22 @@ RECONCILER_CLOSE_REPORT_FIELDS = (
|
|||||||
"no review/merge confirmation",
|
"no review/merge confirmation",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RECONCILER_SUPERSESSION_REPORT_FIELDS = (
|
||||||
|
"identity/profile",
|
||||||
|
"supersession close capability proof",
|
||||||
|
"target PR live state",
|
||||||
|
"target PR independent-work proof",
|
||||||
|
"superseding PR merged state",
|
||||||
|
"superseding merge commit SHA",
|
||||||
|
"target branch SHA",
|
||||||
|
"superseding ancestry proof",
|
||||||
|
"canonical close comment",
|
||||||
|
"linked issue status",
|
||||||
|
"PR close result",
|
||||||
|
"issue close result",
|
||||||
|
"no review/merge confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_reconciler_close_gate(
|
def assess_reconciler_close_gate(
|
||||||
*,
|
*,
|
||||||
@@ -4988,6 +5004,157 @@ def assess_reconciler_close_gate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_reconciler_supersession_close_gate(
|
||||||
|
*,
|
||||||
|
target_pr_number: int | None,
|
||||||
|
target_pr_state: str | None,
|
||||||
|
target_pr_mergeable: bool | None,
|
||||||
|
target_independently_required: bool | None,
|
||||||
|
superseding_pr_number: int | None,
|
||||||
|
superseding_pr_state: str | None,
|
||||||
|
superseding_pr_merged: bool,
|
||||||
|
superseding_merge_commit_sha: str | None,
|
||||||
|
superseding_head_sha: str | None,
|
||||||
|
target_branch: str | None,
|
||||||
|
target_branch_sha: str | None,
|
||||||
|
superseding_head_is_ancestor_of_target: bool | None,
|
||||||
|
canonical_comment_valid: bool,
|
||||||
|
canonical_comment_mentions_superseding_pr: bool,
|
||||||
|
canonical_comment_mentions_merge_commit: bool,
|
||||||
|
close_pr_capability: bool,
|
||||||
|
close_issue_capability: bool = False,
|
||||||
|
target_issue_state: str | None = None,
|
||||||
|
issue_satisfied_by_superseding: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Gate reconciler closure of PRs/issues superseded by a merged PR.
|
||||||
|
|
||||||
|
This is stricter than already-landed cleanup: the target PR may be closed
|
||||||
|
only after a named superseding PR is live-verified merged, its head is on a
|
||||||
|
freshly fetched target branch, the target PR is proven not independently
|
||||||
|
required, and a canonical close comment cites the superseding PR and merge
|
||||||
|
commit.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not isinstance(target_pr_number, int) or target_pr_number <= 0:
|
||||||
|
reasons.append("target PR number missing or invalid (#525)")
|
||||||
|
if not isinstance(superseding_pr_number, int) or superseding_pr_number <= 0:
|
||||||
|
reasons.append("superseding PR number missing or invalid (#525)")
|
||||||
|
if target_pr_number and superseding_pr_number and (
|
||||||
|
target_pr_number == superseding_pr_number
|
||||||
|
):
|
||||||
|
reasons.append("target PR and superseding PR must differ (#525)")
|
||||||
|
if (target_pr_state or "").strip().lower() != "open":
|
||||||
|
reasons.append("target PR must be live-verified open before close (#525)")
|
||||||
|
if target_pr_mergeable is True:
|
||||||
|
reasons.append(
|
||||||
|
"target PR is still mergeable; prove it is superseded before close (#525)"
|
||||||
|
)
|
||||||
|
if target_independently_required is not False:
|
||||||
|
reasons.append(
|
||||||
|
"target PR independent-work proof missing; close requires explicit "
|
||||||
|
"proof that no required work remains (#525)"
|
||||||
|
)
|
||||||
|
if (superseding_pr_state or "").strip().lower() != "closed":
|
||||||
|
reasons.append("superseding PR must be live-verified closed (#525)")
|
||||||
|
if superseding_pr_merged is not True:
|
||||||
|
reasons.append("superseding PR must be live-verified merged (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_merge_commit_sha or "").strip()):
|
||||||
|
reasons.append("superseding merge commit SHA missing or invalid (#525)")
|
||||||
|
if not _FULL_SHA.match((superseding_head_sha or "").strip()):
|
||||||
|
reasons.append("superseding head SHA missing or invalid (#525)")
|
||||||
|
if not (target_branch or "").strip():
|
||||||
|
reasons.append("target branch missing (#525)")
|
||||||
|
if not _FULL_SHA.match((target_branch_sha or "").strip()):
|
||||||
|
reasons.append("target branch SHA missing or invalid (#525)")
|
||||||
|
if superseding_head_is_ancestor_of_target is None:
|
||||||
|
reasons.append("superseding ancestry proof not checked (#525)")
|
||||||
|
if not canonical_comment_valid:
|
||||||
|
reasons.append("canonical close comment failed validation (#525)")
|
||||||
|
if not canonical_comment_mentions_superseding_pr:
|
||||||
|
reasons.append("canonical comment must cite superseding PR (#525)")
|
||||||
|
if not canonical_comment_mentions_merge_commit:
|
||||||
|
reasons.append("canonical comment must cite merge commit SHA (#525)")
|
||||||
|
|
||||||
|
never_allowed = {
|
||||||
|
"review_allowed": False,
|
||||||
|
"approve_allowed": False,
|
||||||
|
"request_changes_allowed": False,
|
||||||
|
"merge_allowed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"outcome": "GATE_NOT_PROVEN",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"prove superseding PR state, target branch ancestry, target "
|
||||||
|
"supersession, and canonical comment before closing"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if superseding_head_is_ancestor_of_target is False:
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSEDING_PR_NOT_ON_TARGET",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
f"superseding PR #{superseding_pr_number} head is not an "
|
||||||
|
f"ancestor of {target_branch}; supersession close denied (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": "re-fetch target branch and re-check ancestry",
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if close_pr_capability is not True:
|
||||||
|
return {
|
||||||
|
"outcome": "RECOVERY_HANDOFF_REQUIRED",
|
||||||
|
"pr_close_allowed": False,
|
||||||
|
"issue_close_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
"exact gitea.pr.close capability not proven; produce a "
|
||||||
|
"recovery handoff instead of closing (#525)"
|
||||||
|
],
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"launch a reconciler profile with gitea.pr.close and replay "
|
||||||
|
"the live-state proof"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_close_allowed = (
|
||||||
|
(target_issue_state or "").strip().lower() == "open"
|
||||||
|
and issue_satisfied_by_superseding is True
|
||||||
|
and close_issue_capability is True
|
||||||
|
)
|
||||||
|
issue_reasons = []
|
||||||
|
if (target_issue_state or "").strip().lower() == "closed":
|
||||||
|
issue_reasons.append("linked issue already closed; no issue close attempted (#525)")
|
||||||
|
elif target_issue_state and not issue_close_allowed:
|
||||||
|
issue_reasons.append(
|
||||||
|
"issue close requires an open issue satisfied by the superseding "
|
||||||
|
"merged PR and exact gitea.issue.close capability (#525)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outcome": "SUPERSESSION_CLOSE_ALLOWED",
|
||||||
|
"pr_close_allowed": True,
|
||||||
|
"issue_close_allowed": issue_close_allowed,
|
||||||
|
"reasons": issue_reasons,
|
||||||
|
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||||
|
"safe_next_action": (
|
||||||
|
"post the canonical comment, close the superseded PR, and close "
|
||||||
|
"the linked issue only when issue_close_allowed is true"
|
||||||
|
),
|
||||||
|
**never_allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Identity disclosure (#305)
|
# Identity disclosure (#305)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+259
-12
@@ -190,18 +190,28 @@ def find_active_reviewer_lease(
|
|||||||
pr_number: int,
|
pr_number: int,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
"""Return the newest unexpired non-terminal lease for *pr_number*.
|
||||||
|
|
||||||
|
Append-only lease markers form a ledger: the **newest** marker is
|
||||||
|
authoritative. A later terminal phase (``released`` / ``done`` /
|
||||||
|
``blocked``) ends the lease even when older ``claimed`` markers remain
|
||||||
|
on the thread (#577). Skipping only terminal markers and walking older
|
||||||
|
claims incorrectly re-arms a lease after a successful release.
|
||||||
|
"""
|
||||||
now = now or datetime.now(timezone.utc)
|
now = now or datetime.now(timezone.utc)
|
||||||
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
entries = list(reversed(_lease_entries(comments, pr_number=pr_number)))
|
||||||
phase = (lease.get("phase") or "").strip().lower()
|
if not entries:
|
||||||
if phase in _TERMINAL_PHASES:
|
return None
|
||||||
continue
|
newest = entries[0]
|
||||||
if _lease_expired(lease, now=now):
|
phase = (newest.get("phase") or "").strip().lower()
|
||||||
continue
|
if phase in _TERMINAL_PHASES:
|
||||||
if phase in _ACTIVE_PHASES or phase:
|
return None
|
||||||
lease = dict(lease)
|
if _lease_expired(newest, now=now):
|
||||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
return None
|
||||||
return lease
|
if phase in _ACTIVE_PHASES or phase:
|
||||||
|
lease = dict(newest)
|
||||||
|
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||||
|
return lease
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -512,4 +522,241 @@ def assess_lease_inventory(
|
|||||||
"active_review_leases": active,
|
"active_review_leases": active,
|
||||||
"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",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
+34
-3
@@ -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",
|
||||||
@@ -80,6 +83,7 @@ AUTHOR_TASKS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
"reconcile_already_landed_pr",
|
"reconcile_already_landed_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -97,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",
|
||||||
@@ -109,9 +113,13 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_already_landed_pr": "reconciler",
|
"reconcile_already_landed_pr": "reconciler",
|
||||||
"reconcile_already_landed": "reconciler",
|
"reconcile_already_landed": "reconciler",
|
||||||
"reconcile-landed-pr": "reconciler",
|
"reconcile-landed-pr": "reconciler",
|
||||||
|
"cleanup_merged_pr_branch": "reconciler",
|
||||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||||
"reconcile_close_landed_pr": "reconciler",
|
"reconcile_close_landed_pr": "reconciler",
|
||||||
"reconcile_close_landed_issue": "reconciler",
|
"reconcile_close_landed_issue": "reconciler",
|
||||||
|
"reconcile_close_superseded_pr": "reconciler",
|
||||||
|
"reconcile_close_satisfied_issue": "reconciler",
|
||||||
|
"reconcile_create_followup_issue": "reconciler",
|
||||||
}
|
}
|
||||||
|
|
||||||
WRONG_ROLE_REVIEWER_MSG = (
|
WRONG_ROLE_REVIEWER_MSG = (
|
||||||
@@ -123,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
|
||||||
|
|
||||||
|
|
||||||
@@ -238,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 = (
|
||||||
@@ -406,4 +437,4 @@ def assess_infra_stop(project_root: str | None = None) -> dict:
|
|||||||
|
|
||||||
def check_mid_merge(project_root: str | None = None) -> bool:
|
def check_mid_merge(project_root: str | None = None) -> bool:
|
||||||
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
||||||
return assess_infra_stop(project_root)["infra_stop"]
|
return assess_infra_stop(project_root)["infra_stop"]
|
||||||
|
|||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Path-filtered CI gate for the internal web UI (#436).
|
||||||
|
# Runs the hermetic web UI unittest suite when a change touches UI surfaces.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
if [[ "${WEBUI_CI_FORCE:-}" == "1" ]]; then
|
||||||
|
exec "$script_dir/test-webui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
base_ref="${WEBUI_CI_BASE_REF:-}"
|
||||||
|
if [[ -z "$base_ref" ]]; then
|
||||||
|
if git rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD prgs/master 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$base_ref" ]]; then
|
||||||
|
base_ref="${GITHUB_BASE_REF:-${CHANGE_TARGET:-master}}"
|
||||||
|
if git rev-parse --verify "origin/$base_ref" >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD "origin/$base_ref")"
|
||||||
|
elif git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
|
||||||
|
base_ref="$(git merge-base HEAD "$base_ref")"
|
||||||
|
else
|
||||||
|
base_ref="HEAD~1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed="$(git diff --name-only "$base_ref" HEAD 2>/dev/null || true)"
|
||||||
|
if [[ -z "$changed" ]]; then
|
||||||
|
echo "ci-webui-check: no changed files vs $base_ref; skipping web UI suite"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||||
|
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||||
|
python_bin="$repo_root/venv/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s\n' "$changed" | "$python_bin" -c "
|
||||||
|
import sys
|
||||||
|
from webui.ci_paths import should_run_webui_ci
|
||||||
|
paths = [line.strip() for line in sys.stdin if line.strip()]
|
||||||
|
raise SystemExit(0 if should_run_webui_ci(paths) else 1)
|
||||||
|
"; then
|
||||||
|
echo "ci-webui-check: web UI surface changed; running suite"
|
||||||
|
exec "$script_dir/test-webui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-webui-check: no web UI paths in diff; skipping suite"
|
||||||
|
exit 0
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
python_bin="${WEBUI_TEST_PYTHON:-python3}"
|
||||||
|
if [[ -x "$repo_root/venv/bin/python" ]]; then
|
||||||
|
python_bin="$repo_root/venv/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pattern="${WEBUI_TEST_PATTERN:-test_webui_*.py}"
|
||||||
|
export WEBUI_TEST_OFFLINE="${WEBUI_TEST_OFFLINE:-1}"
|
||||||
|
exec "$python_bin" -m unittest discover -s tests -p "$pattern" "$@"
|
||||||
@@ -344,6 +344,41 @@ If edits are needed, make the smallest correction necessary and report the corre
|
|||||||
|
|
||||||
Do not silently change requested meaning.
|
Do not silently change requested meaning.
|
||||||
|
|
||||||
|
## 14a. Enforced content preflight (#582)
|
||||||
|
|
||||||
|
`gitea_create_issue` runs a **content preflight gate** before duplicate
|
||||||
|
search or creation. It fails closed (returns `BLOCKED + DIAGNOSE`, no issue
|
||||||
|
created) when the title/body is a *vague reference* to out-of-band draft
|
||||||
|
content the session cannot prove it holds, and no durable source pointer is
|
||||||
|
present.
|
||||||
|
|
||||||
|
An LLM must never fabricate issue content from stale chat memory. If asked to
|
||||||
|
create "the drafted issue" / "the prepared issue" / "the issue we discussed"
|
||||||
|
/ "the previous draft" without the full content in the current context or a
|
||||||
|
durable source pointer, stop and return `BLOCKED + DIAGNOSE` naming the exact
|
||||||
|
vague/missing fields.
|
||||||
|
|
||||||
|
A **durable source pointer** is one of: an existing issue/PR reference
|
||||||
|
(`#582`), a comment id (`comment 8155`), a checked-in file path
|
||||||
|
(`task_capability_map.py`), a URL, or a scratchpad path. When a pointer is
|
||||||
|
present, retrieve the real content from it before creating.
|
||||||
|
|
||||||
|
The gate does **not** require a non-empty body; explicit title-only creation
|
||||||
|
stays allowed. It only blocks placeholder references. An operator may bypass
|
||||||
|
it with `allow_incomplete_content=True` (report the override).
|
||||||
|
|
||||||
|
**Invalid prompts (must block):**
|
||||||
|
|
||||||
|
* "Create the drafted issue."
|
||||||
|
* "File the prepared issue we discussed."
|
||||||
|
* "Open the previous draft."
|
||||||
|
|
||||||
|
**Valid prompts (proceed):**
|
||||||
|
|
||||||
|
* Full title + body + acceptance criteria supplied inline.
|
||||||
|
* "Create the issue drafted in `#582`." (durable pointer → fetch and use it)
|
||||||
|
* "Create the issue from `scratchpad/issue-draft.md`." (durable file pointer)
|
||||||
|
|
||||||
## 15. Labels, assignees, and metadata
|
## 15. Labels, assignees, and metadata
|
||||||
|
|
||||||
Apply labels, assignees, milestones, or project fields only if:
|
Apply labels, assignees, milestones, or project fields only if:
|
||||||
|
|||||||
@@ -826,6 +826,75 @@ The final report must identify:
|
|||||||
* whether same-PR merge continuation was allowed
|
* whether same-PR merge continuation was allowed
|
||||||
* whether the run stopped as required
|
* whether the run stopped as required
|
||||||
|
|
||||||
|
## 26A-1. Stale durable review-decision lock cleanup (#594)
|
||||||
|
|
||||||
|
After #559, the review-decision lock is durable under
|
||||||
|
`~/.cache/gitea-tools/session-state/` (for example
|
||||||
|
`review_decision_lock-prgs-reviewer.json`). That durability can outlive the work
|
||||||
|
it protects: a terminal `approve` / `request_changes` on a PR that is already
|
||||||
|
**merged or closed** still hard-stops later unrelated reviews under #332.
|
||||||
|
|
||||||
|
**Do not** delete session-state files by hand. Use the canonical MCP path.
|
||||||
|
|
||||||
|
### When cleanup is allowed
|
||||||
|
|
||||||
|
Use `gitea_cleanup_stale_review_decision_lock` when:
|
||||||
|
|
||||||
|
1. `gitea_mark_final_review_decision` / live review is blocked by
|
||||||
|
`terminal review mutation already consumed ... (#332)`.
|
||||||
|
2. Live Gitea state for the **last terminal mutation's PR** is **merged** or
|
||||||
|
**closed** (no same-PR merge sequence remains).
|
||||||
|
3. Active profile identity matches the durable lock (reviewer profile with
|
||||||
|
`gitea.pr.review` for `apply=true`).
|
||||||
|
4. Optional pin: pass `expected_terminal_pr` to the prior terminal PR number
|
||||||
|
(for example `586` when resuming after a merged #586 lock).
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_whoami
|
||||||
|
gitea_resolve_task_capability(task="cleanup_stale_review_decision_lock")
|
||||||
|
gitea_cleanup_stale_review_decision_lock(apply=false, remote=..., org=..., repo=...)
|
||||||
|
# inspect is_moot / cleanup_allowed / last_terminal_pr
|
||||||
|
gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=true,
|
||||||
|
expected_terminal_pr=<last_terminal_pr>,
|
||||||
|
remote=..., org=..., repo=...,
|
||||||
|
)
|
||||||
|
gitea_resolve_task_capability(task="review_pr")
|
||||||
|
gitea_load_review_workflow()
|
||||||
|
# continue formal mark + submit for the *new* PR
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful `apply=true` clears memory + durable store and returns an audit
|
||||||
|
record (and posts a durable PR comment on the terminal PR when
|
||||||
|
`post_audit_comment` is true and comment permission allows).
|
||||||
|
|
||||||
|
Same-profile auto-expire: after `gitea_merge_pr` succeeds for the PR that this
|
||||||
|
profile just approved, the decision lock for that profile is cleared
|
||||||
|
automatically. Cross-profile locks (for example reviewer approve, merger merge)
|
||||||
|
still require `gitea_cleanup_stale_review_decision_lock`.
|
||||||
|
|
||||||
|
### When cleanup is forbidden
|
||||||
|
|
||||||
|
Refuse / fail-closed — keep #332 hard-stop — when:
|
||||||
|
|
||||||
|
* the last terminal PR is still **open** (active review or merge sequence may
|
||||||
|
still apply)
|
||||||
|
* live PR state cannot be fetched (ambiguous)
|
||||||
|
* no lock or no terminal live mutation exists
|
||||||
|
* profile identity does not match the lock
|
||||||
|
* apply requested without authenticated identity or `gitea.pr.review`
|
||||||
|
* `expected_terminal_pr` does not match the last terminal PR
|
||||||
|
|
||||||
|
Do **not** use cleanup to:
|
||||||
|
|
||||||
|
* bypass #332 on an open PR
|
||||||
|
* replace `gitea_authorize_review_correction` for a mistaken review on a still
|
||||||
|
active PR (#211)
|
||||||
|
* auto-approve or auto-merge any PR
|
||||||
|
* justify `rm` of session-state files
|
||||||
|
|
||||||
## 26B. Per-PR reviewer lease (#407)
|
## 26B. Per-PR reviewer lease (#407)
|
||||||
|
|
||||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||||
@@ -936,6 +1005,14 @@ Confirm:
|
|||||||
|
|
||||||
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
|
||||||
|
|
||||||
|
Do not delete source branches from reviewer mode with raw git commands. Commands
|
||||||
|
such as `git branch -d <branch>` and `git push <remote> --delete <branch>` are
|
||||||
|
not proof of authorized cleanup and bypass MCP role/capability gates. Merged PR
|
||||||
|
source branch cleanup must use an explicit MCP cleanup tool such as
|
||||||
|
`gitea_cleanup_merged_pr_branch` or another approved cleanup helper with exact
|
||||||
|
`gitea.branch.delete` authority, merged-PR proof, no open PR using the branch,
|
||||||
|
target-branch ancestry proof, a `branches/` worktree path, and cleanup mutations
|
||||||
|
reported separately from review mutations.
|
||||||
Review, baseline, and merge-simulation worktrees created during this run are
|
Review, baseline, and merge-simulation worktrees created during this run are
|
||||||
transient and are removed automatically at successful completion once they are
|
transient and are removed automatically at successful completion once they are
|
||||||
clean, carry no open PR, and hold no active lease (#401). Use
|
clean, carry no open PR, and hold no active lease (#401). Use
|
||||||
@@ -1035,6 +1112,8 @@ Blocked handoffs must say to rerun the full workflow after the blocker clears.
|
|||||||
|
|
||||||
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
If the blocker is a terminal review mutation already consumed in the current run, the handoff must say that the next run must start from the beginning and must not reuse stale ready/approved state.
|
||||||
|
|
||||||
|
If the referenced terminal PR is already **merged or closed** on live Gitea, the durable #332 decision lock is **moot**. Do **not** delete session-state files by hand. Use `gitea_cleanup_stale_review_decision_lock` (assess with `apply=false`, then `apply=true` with `expected_terminal_pr` set) from the reviewer profile after identity/profile checks (#594). Cleanup is **forbidden** while that PR is still open.
|
||||||
|
|
||||||
## 30. Final report must be precise
|
## 30. Final report must be precise
|
||||||
|
|
||||||
Include:
|
Include:
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594).
|
||||||
|
|
||||||
|
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||||
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||||
|
work they protect: when the referenced PR is already merged or closed, no
|
||||||
|
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
|
||||||
|
new terminal reviews.
|
||||||
|
|
||||||
|
This module is pure policy (no Gitea I/O). The MCP tool
|
||||||
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||||
|
helpers, and only then clears durable state when cleanup is allowed.
|
||||||
|
|
||||||
|
Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the
|
||||||
|
canonical path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS.
|
||||||
|
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||||||
|
|
||||||
|
|
||||||
|
def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||||
|
"""Return the last terminal live mutation on *lock*, or None."""
|
||||||
|
if not lock:
|
||||||
|
return None
|
||||||
|
terminals = [
|
||||||
|
m
|
||||||
|
for m in (lock.get("live_mutations") or [])
|
||||||
|
if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS
|
||||||
|
]
|
||||||
|
return terminals[-1] if terminals else None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
|
||||||
|
"""Normalize a Gitea PR payload into merge/closed flags."""
|
||||||
|
pr = pr_live or {}
|
||||||
|
merged = bool(pr.get("merged") or pr.get("merged_at"))
|
||||||
|
state = (pr.get("state") or "").strip().lower() or None
|
||||||
|
closed = state == "closed"
|
||||||
|
return {
|
||||||
|
"pr_state": state,
|
||||||
|
"pr_merged": merged,
|
||||||
|
"pr_merged_or_closed": merged or closed,
|
||||||
|
"merge_commit_sha": pr.get("merge_commit_sha")
|
||||||
|
or pr.get("merged_commit_sha"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_summary(lock: dict | None) -> dict[str, Any] | None:
|
||||||
|
"""LLM-safe summary of a decision lock (no secrets)."""
|
||||||
|
if lock is None:
|
||||||
|
return None
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
mutations = list(lock.get("live_mutations") or [])
|
||||||
|
return {
|
||||||
|
"task": lock.get("task"),
|
||||||
|
"remote": lock.get("remote"),
|
||||||
|
"org": lock.get("org") or lock.get("ready_org"),
|
||||||
|
"repo": lock.get("repo") or lock.get("ready_repo"),
|
||||||
|
"profile_identity": lock.get("profile_identity")
|
||||||
|
or lock.get("session_profile_lock")
|
||||||
|
or lock.get("session_profile"),
|
||||||
|
"session_profile": lock.get("session_profile"),
|
||||||
|
"ready_pr_number": lock.get("ready_pr_number"),
|
||||||
|
"ready_action": lock.get("ready_action"),
|
||||||
|
"live_mutations_count": len(mutations),
|
||||||
|
"last_terminal": (
|
||||||
|
{
|
||||||
|
"pr_number": last.get("pr_number"),
|
||||||
|
"action": last.get("action"),
|
||||||
|
"review_id": last.get("review_id"),
|
||||||
|
}
|
||||||
|
if last
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"correction_authorized": bool(lock.get("correction_authorized")),
|
||||||
|
"updated_at": lock.get("updated_at") or lock.get("recorded_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_stale_review_decision_lock(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_live: dict | None = None,
|
||||||
|
pr_lookup_error: str | None = None,
|
||||||
|
active_profile_identity: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether a durable review-decision lock is stale/moot (#594).
|
||||||
|
|
||||||
|
*pr_live* must be the live Gitea payload for the **last terminal
|
||||||
|
mutation's PR**. When no lock / no terminal mutation exists, cleanup is
|
||||||
|
not applicable. When the PR is still open (or lookup fails), cleanup is
|
||||||
|
forbidden and #332 hard-stop remains in force.
|
||||||
|
"""
|
||||||
|
summary = lock_summary(lock)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"has_lock": lock is not None,
|
||||||
|
"lock_summary": summary,
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"is_moot": False,
|
||||||
|
"cleanup_allowed": False,
|
||||||
|
"profile_match": True,
|
||||||
|
"pr_state": None,
|
||||||
|
"pr_merged": False,
|
||||||
|
"pr_merged_or_closed": False,
|
||||||
|
"merge_commit_sha": None,
|
||||||
|
"reasons": [],
|
||||||
|
"recovery_tool": "gitea_cleanup_stale_review_decision_lock",
|
||||||
|
}
|
||||||
|
|
||||||
|
if lock is None:
|
||||||
|
result["reasons"].append("no review decision lock present")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Profile identity gate (caller may re-check with env; pure assess still
|
||||||
|
# reports mismatch when active identity is supplied).
|
||||||
|
stored = (
|
||||||
|
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
active = (active_profile_identity or "").strip()
|
||||||
|
if active and stored and active != stored:
|
||||||
|
result["profile_match"] = False
|
||||||
|
result["reasons"].append(
|
||||||
|
"review decision lock profile identity does not match active "
|
||||||
|
f"profile '{active}' (fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"review decision lock has no terminal live mutations; nothing to clear"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
pr_number = last.get("pr_number")
|
||||||
|
action = last.get("action")
|
||||||
|
result["last_terminal_pr"] = pr_number
|
||||||
|
result["last_terminal_action"] = action
|
||||||
|
|
||||||
|
if pr_number is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"last terminal mutation missing pr_number; refuse cleanup (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_lookup_error:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"could not load live state for PR #{pr_number}: {pr_lookup_error} "
|
||||||
|
"(fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_live is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"live PR state required for terminal PR #{pr_number} before cleanup "
|
||||||
|
"(fail closed, #594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
live = classify_pr_live_state(pr_live)
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"pr_state": live["pr_state"],
|
||||||
|
"pr_merged": live["pr_merged"],
|
||||||
|
"pr_merged_or_closed": live["pr_merged_or_closed"],
|
||||||
|
"merge_commit_sha": live["merge_commit_sha"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not live["pr_merged_or_closed"]:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal review mutation on open PR #{pr_number} is still active "
|
||||||
|
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
|
||||||
|
result["is_moot"] = True
|
||||||
|
result["cleanup_allowed"] = True
|
||||||
|
result["reasons"].append(
|
||||||
|
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
|
||||||
|
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_cleanup_audit_record(
|
||||||
|
*,
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
applied: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured durable audit payload for a cleanup attempt."""
|
||||||
|
last = (assessment.get("lock_summary") or {}).get("last_terminal") or {}
|
||||||
|
return {
|
||||||
|
"event": "stale_review_decision_lock_cleanup",
|
||||||
|
"issue_ref": "#594",
|
||||||
|
"applied": bool(applied),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"),
|
||||||
|
"last_terminal_action": assessment.get("last_terminal_action")
|
||||||
|
or last.get("action"),
|
||||||
|
"pr_state": assessment.get("pr_state"),
|
||||||
|
"pr_merged": assessment.get("pr_merged"),
|
||||||
|
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||||
|
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
||||||
|
"prior_live_mutations_count": (
|
||||||
|
(assessment.get("lock_summary") or {}).get("live_mutations_count")
|
||||||
|
),
|
||||||
|
"prior_profile_identity": (
|
||||||
|
(assessment.get("lock_summary") or {}).get("profile_identity")
|
||||||
|
),
|
||||||
|
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||||||
|
"is_moot": assessment.get("is_moot"),
|
||||||
|
"reasons": list(assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for a durable Gitea audit comment after cleanup."""
|
||||||
|
status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)"
|
||||||
|
lines = [
|
||||||
|
"## Stale #332 review-decision lock cleanup (#594)",
|
||||||
|
"",
|
||||||
|
f"Status: **{status}**",
|
||||||
|
"",
|
||||||
|
f"- actor: `{audit.get('actor_username')}`",
|
||||||
|
f"- profile: `{audit.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{audit.get('timestamp')}`",
|
||||||
|
f"- last terminal: `{audit.get('last_terminal_action')}` on "
|
||||||
|
f"PR #{audit.get('last_terminal_pr')}",
|
||||||
|
f"- PR state: `{audit.get('pr_state')}` "
|
||||||
|
f"(merged={audit.get('pr_merged')})",
|
||||||
|
f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`",
|
||||||
|
f"- prior live_mutations_count: "
|
||||||
|
f"`{audit.get('prior_live_mutations_count')}`",
|
||||||
|
f"- prior profile_identity: `{audit.get('prior_profile_identity')}`",
|
||||||
|
"",
|
||||||
|
"Manual deletion of session-state files is **not** the workflow.",
|
||||||
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -96,10 +96,25 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.approve",
|
"permission": "gitea.pr.approve",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #594: clear durable #332 decision lock only when last terminal PR is
|
||||||
|
# already merged/closed (moot). Apply path requires reviewer review
|
||||||
|
# permission; assessment itself uses gitea.read inside the tool.
|
||||||
|
"cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_cleanup_stale_review_decision_lock": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
"cleanup_merged_pr_branch": {
|
||||||
|
"permission": "gitea.branch.delete",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"commit_files": {
|
"commit_files": {
|
||||||
"permission": "gitea.repo.commit",
|
"permission": "gitea.repo.commit",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -146,6 +161,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.close",
|
"permission": "gitea.issue.close",
|
||||||
"role": "reconciler",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
|
"reconcile_close_superseded_pr": {
|
||||||
|
"permission": "gitea.pr.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_close_satisfied_issue": {
|
||||||
|
"permission": "gitea.issue.close",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"reconcile_create_followup_issue": {
|
||||||
|
"permission": "gitea.issue.create",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"post_heartbeat": {
|
"post_heartbeat": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
+185
-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,16 @@ 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,
|
||||||
|
):
|
||||||
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")
|
||||||
|
|||||||
+44
-1
@@ -1,4 +1,5 @@
|
|||||||
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
"""Shared pytest fixtures for the Gitea-Tools test suite."""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,13 +17,46 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
the next test. It must never replace verify_mutation_authority with a
|
the next test. It must never replace verify_mutation_authority with a
|
||||||
no-op: individual tests that need a specific authority state set it up
|
no-op: individual tests that need a specific authority state set it up
|
||||||
explicitly.
|
explicitly.
|
||||||
|
|
||||||
|
Session-state isolation (#559 / #590 / #594): many tests use
|
||||||
|
``patch.dict(os.environ, ..., clear=True)``, which drops
|
||||||
|
``GITEA_MCP_SESSION_STATE_DIR``. Relying only on the env var therefore
|
||||||
|
re-exposes the operator host cache
|
||||||
|
(``~/.cache/gitea-tools/session-state``) and its review-mutation ledger.
|
||||||
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
_state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-")
|
||||||
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", _state_tmp.name)
|
state_dir = _state_tmp.name
|
||||||
|
monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", state_dir)
|
||||||
|
try:
|
||||||
|
import mcp_session_state
|
||||||
|
except Exception:
|
||||||
|
mcp_session_state = None
|
||||||
|
if mcp_session_state is not None:
|
||||||
|
# Survive patch.dict(..., clear=True) that drops the env var (#590).
|
||||||
|
# Still honor an explicit GITEA_MCP_SESSION_STATE_DIR when tests set
|
||||||
|
# their own temp dir (see tests/test_mcp_session_state.py).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state, "DEFAULT_STATE_DIR", state_dir, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _isolated_default_state_dir(
|
||||||
|
_fallback: str = state_dir,
|
||||||
|
_env_key: str = mcp_session_state.STATE_DIR_ENV,
|
||||||
|
) -> str:
|
||||||
|
raw = (os.environ.get(_env_key) or "").strip()
|
||||||
|
return raw or _fallback
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_session_state,
|
||||||
|
"default_state_dir",
|
||||||
|
_isolated_default_state_dir,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -32,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)
|
||||||
@@ -40,6 +75,14 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
|
monkeypatch.setattr(mcp_server, "_preflight_capability_baseline_porcelain", None)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
|
monkeypatch.setattr(mcp_server, "_preflight_resolved_task", None)
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
|
monkeypatch.setattr(mcp_server, "_preflight_reviewer_violation_files", [])
|
||||||
|
# Unit tests must not depend on live host MCP process health. Tests that
|
||||||
|
# use patch.dict(..., clear=True) drop PYTEST_CURRENT_TEST, which would
|
||||||
|
# otherwise re-enable the stale-runtime probe against operator daemons.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_server,
|
||||||
|
"_check_mcp_runtimes_diagnostics",
|
||||||
|
lambda *args, **kwargs: [],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
import review_workflow_load
|
import review_workflow_load
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import branch_cleanup_guard as guard # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import task_capability_map # noqa: E402
|
||||||
|
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||||
|
from mcp_server import gitea_cleanup_merged_pr_branch # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "token fake"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRawBranchDeleteGuard(unittest.TestCase):
|
||||||
|
def test_detects_local_and_remote_raw_git_delete_commands(self):
|
||||||
|
text = """
|
||||||
|
Cleanup mutations:
|
||||||
|
- git branch -d feat/issue-485-lease-comments-non-list-guard
|
||||||
|
- git push prgs --delete feat/issue-485-lease-comments-non-list-guard
|
||||||
|
"""
|
||||||
|
commands = guard.raw_branch_delete_commands(text)
|
||||||
|
self.assertEqual(len(commands), 2)
|
||||||
|
self.assertIn("git branch -d", commands[0])
|
||||||
|
self.assertIn("git push prgs --delete", commands[1])
|
||||||
|
|
||||||
|
def test_final_report_blocks_raw_delete_as_cleanup_proof(self):
|
||||||
|
report = """
|
||||||
|
## Controller Handoff
|
||||||
|
- Task: review_pr
|
||||||
|
- Cleanup mutations: git push prgs --delete feat/branch
|
||||||
|
"""
|
||||||
|
result = assess_final_report_validator(report, "review_pr")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("shared.raw_branch_delete_bypass" in r for r in result["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cleanup_task_requires_branch_delete_capability(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("cleanup_merged_pr_branch"),
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("cleanup_merged_pr_branch"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Example-Org",
|
||||||
|
"repo": "Example-Repo",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
self.mock_all = patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||||
|
return_value=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_reviewer_without_delete_authority_fails_closed(self):
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": ["gitea.branch.delete"],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
|
||||||
|
branch="feat/branch",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||||
|
self.mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_root_checkout_cleanup_fails_closed(self):
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch",
|
||||||
|
branch="feat/branch",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertIn("root checkout", " ".join(res["reasons"]))
|
||||||
|
self.mock_api.assert_not_called()
|
||||||
|
|
||||||
|
def test_authorized_cleanup_deletes_through_api_path(self):
|
||||||
|
branch = "feat/issue-485-lease-comments-non-list-guard"
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = [
|
||||||
|
{
|
||||||
|
"number": 487,
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-08T01:00:00Z",
|
||||||
|
"head": {"ref": branch, "sha": "a" * 40},
|
||||||
|
"base": {"ref": "master"},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["performed"])
|
||||||
|
delete_calls = [
|
||||||
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(delete_calls), 1)
|
||||||
|
self.assertIn("branches/feat%2Fissue-485", delete_calls[0].args[1])
|
||||||
|
|
||||||
|
def test_confirmation_required_before_delete(self):
|
||||||
|
branch = "feat/issue-485-lease-comments-non-list-guard"
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "branch-cleanup",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = [
|
||||||
|
{
|
||||||
|
"number": 487,
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-08T01:00:00Z",
|
||||||
|
"head": {"ref": branch, "sha": "a" * 40},
|
||||||
|
"base": {"ref": "master"},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
]
|
||||||
|
res = gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=487,
|
||||||
|
confirmation="wrong",
|
||||||
|
branch=branch,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path="/tmp/repo/branches/cleanup",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertIn("confirmation", " ".join(res["reasons"]))
|
||||||
|
delete_calls = [
|
||||||
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertFalse(delete_calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
"""Tests for control-plane DB substrate (#613)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from control_plane_db import (
|
||||||
|
ControlPlaneDB,
|
||||||
|
InvalidWorkKindError,
|
||||||
|
LeaseRequiredError,
|
||||||
|
WORK_KINDS,
|
||||||
|
_ts,
|
||||||
|
_utc_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneDBTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
|
||||||
|
self.db = ControlPlaneDB(self.db_path)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_schema_and_architecture_meta(self) -> None:
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
rows = dict(conn.execute("SELECT key, value FROM schema_meta").fetchall())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(rows["schema_version"], "2")
|
||||||
|
self.assertIn("DB coordinates", rows["architecture"])
|
||||||
|
self.assertIn("bridge", rows["architecture"].lower())
|
||||||
|
|
||||||
|
def test_rejects_raw_incident_as_work_kind(self) -> None:
|
||||||
|
with self.assertRaises(InvalidWorkKindError):
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="sentry_incident",
|
||||||
|
number=1,
|
||||||
|
)
|
||||||
|
with self.assertRaises(InvalidWorkKindError):
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="glitchtip_incident",
|
||||||
|
number=9,
|
||||||
|
)
|
||||||
|
self.assertEqual(WORK_KINDS, frozenset({"issue", "pr"}))
|
||||||
|
|
||||||
|
def test_atomic_assign_and_lease_fields(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s-a", role="author", profile="prgs-author")
|
||||||
|
result = self.db.assign_and_lease(
|
||||||
|
session_id="s-a",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
kind="issue",
|
||||||
|
number=613,
|
||||||
|
expected_head_sha="abc123",
|
||||||
|
allowed_actions=("implement", "comment"),
|
||||||
|
forbidden_actions=("approve", "merge"),
|
||||||
|
)
|
||||||
|
self.assertEqual(result.outcome, "assigned")
|
||||||
|
self.assertIsNotNone(result.assignment_id)
|
||||||
|
self.assertIsNotNone(result.lease_id)
|
||||||
|
self.assertEqual(result.role, "author")
|
||||||
|
self.assertEqual(result.work_kind, "issue")
|
||||||
|
self.assertEqual(result.work_number, 613)
|
||||||
|
self.assertEqual(result.expected_head_sha, "abc123")
|
||||||
|
self.assertIn("implement", result.allowed_actions)
|
||||||
|
self.assertIn("merge", result.forbidden_actions)
|
||||||
|
self.assertIsNotNone(result.expires_at)
|
||||||
|
|
||||||
|
def test_second_session_waits_on_foreign_lease(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
self.db.upsert_session(session_id="s2", role="author")
|
||||||
|
first = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=100,
|
||||||
|
expected_head_sha="deadbeef",
|
||||||
|
)
|
||||||
|
self.assertEqual(first.outcome, "assigned")
|
||||||
|
second = self.db.assign_and_lease(
|
||||||
|
session_id="s2",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=100,
|
||||||
|
expected_head_sha="deadbeef",
|
||||||
|
)
|
||||||
|
self.assertEqual(second.outcome, "wait")
|
||||||
|
self.assertEqual(second.owner_session_id, "s1")
|
||||||
|
|
||||||
|
def test_owner_resume_refreshes_lease(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="reviewer")
|
||||||
|
a = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="reviewer",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=50,
|
||||||
|
expected_head_sha="head-50",
|
||||||
|
)
|
||||||
|
b = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="reviewer",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=50,
|
||||||
|
expected_head_sha="head-50",
|
||||||
|
)
|
||||||
|
self.assertEqual(b.outcome, "assigned")
|
||||||
|
self.assertEqual(b.lease_id, a.lease_id)
|
||||||
|
self.assertIn("owner-resume", b.reason)
|
||||||
|
|
||||||
|
def test_require_valid_assignment_gates_mutations(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=7,
|
||||||
|
allowed_actions=("implement",),
|
||||||
|
forbidden_actions=("merge",),
|
||||||
|
)
|
||||||
|
proof = self.db.require_valid_assignment(
|
||||||
|
session_id="s1",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=7,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
self.assertEqual(proof["session_id"], "s1")
|
||||||
|
with self.assertRaises(LeaseRequiredError):
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="s1",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=7,
|
||||||
|
action="merge",
|
||||||
|
)
|
||||||
|
with self.assertRaises(LeaseRequiredError):
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="s-other",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=7,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_lease_allows_reassign(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
self.db.upsert_session(session_id="s2", role="author")
|
||||||
|
past = _utc_now() - timedelta(hours=1)
|
||||||
|
# Create lease already expired by using negative TTL edge via direct assign then expire
|
||||||
|
assigned = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=3,
|
||||||
|
lease_ttl_seconds=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(assigned.outcome, "assigned")
|
||||||
|
# Force expiry in DB
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE leases SET expires_at = ? WHERE lease_id = ?",
|
||||||
|
(_ts(past), assigned.lease_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
n = self.db.expire_stale_leases()
|
||||||
|
self.assertGreaterEqual(n, 1)
|
||||||
|
second = self.db.assign_and_lease(
|
||||||
|
session_id="s2",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=3,
|
||||||
|
)
|
||||||
|
self.assertEqual(second.outcome, "assigned")
|
||||||
|
self.assertEqual(second.session_id, "s2")
|
||||||
|
|
||||||
|
def test_merged_work_never_assigned(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="merger")
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=99,
|
||||||
|
state="merged",
|
||||||
|
)
|
||||||
|
result = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="merger",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=99,
|
||||||
|
expected_head_sha="merged-head",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.outcome, "no_safe_work")
|
||||||
|
|
||||||
|
def test_four_concurrent_sessions_unique_assignments(self) -> None:
|
||||||
|
"""Four concurrent assigners on four different issues — all succeed uniquely.
|
||||||
|
|
||||||
|
Also two concurrent assigners on the *same* issue: at most one assigned.
|
||||||
|
"""
|
||||||
|
for i in range(4):
|
||||||
|
self.db.upsert_session(session_id=f"sess-{i}", role="author")
|
||||||
|
|
||||||
|
def claim_unique(i: int):
|
||||||
|
return self.db.assign_and_lease(
|
||||||
|
session_id=f"sess-{i}",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=1000 + i,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
results = [f.result() for f in as_completed([pool.submit(claim_unique, i) for i in range(4)])]
|
||||||
|
self.assertEqual({r.outcome for r in results}, {"assigned"})
|
||||||
|
numbers = sorted(r.work_number for r in results)
|
||||||
|
self.assertEqual(numbers, [1000, 1001, 1002, 1003])
|
||||||
|
|
||||||
|
# Contention on one item
|
||||||
|
self.db.upsert_session(session_id="c1", role="author")
|
||||||
|
self.db.upsert_session(session_id="c2", role="author")
|
||||||
|
self.db.upsert_session(session_id="c3", role="author")
|
||||||
|
self.db.upsert_session(session_id="c4", role="author")
|
||||||
|
barrier = threading.Barrier(4)
|
||||||
|
outcomes: list[str] = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def contend(sid: str) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
res = self.db.assign_and_lease(
|
||||||
|
session_id=sid,
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=7777,
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
outcomes.append(res.outcome)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=contend, args=(f"c{i}",)) for i in range(1, 5)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
self.assertEqual(outcomes.count("assigned"), 1)
|
||||||
|
self.assertEqual(outcomes.count("wait"), 3)
|
||||||
|
|
||||||
|
def test_terminal_lock_index(self) -> None:
|
||||||
|
self.db.set_terminal_lock(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
terminal_pr=332,
|
||||||
|
review_id="rev-1",
|
||||||
|
decision="approve",
|
||||||
|
)
|
||||||
|
row = self.db.get_active_terminal_lock(remote="prgs", org="o", repo="r")
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
assert row is not None
|
||||||
|
self.assertEqual(row["terminal_pr"], 332)
|
||||||
|
self.assertEqual(row["status"], "active")
|
||||||
|
|
||||||
|
def test_incident_links_not_work_items(self) -> None:
|
||||||
|
link = self.db.upsert_incident_link(
|
||||||
|
provider="sentry",
|
||||||
|
provider_base_url="https://sentry.prgs.cc",
|
||||||
|
provider_org="prgs",
|
||||||
|
provider_project="gitea-tools-mcp",
|
||||||
|
provider_issue_id="12345",
|
||||||
|
gitea_org="Scaled-Tech-Consulting",
|
||||||
|
gitea_repo="Gitea-Tools",
|
||||||
|
gitea_issue_number=9001,
|
||||||
|
fingerprint="fp-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(link["gitea_issue_number"], 9001)
|
||||||
|
found = self.db.get_incident_link_for_gitea_issue(
|
||||||
|
gitea_org="Scaled-Tech-Consulting",
|
||||||
|
gitea_repo="Gitea-Tools",
|
||||||
|
gitea_issue_number=9001,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(found)
|
||||||
|
# Linking does not create a work_item of incident kind
|
||||||
|
with self.assertRaises(InvalidWorkKindError):
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
kind="sentry",
|
||||||
|
number=12345,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_heartbeat_and_release(self) -> None:
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
a = self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=1,
|
||||||
|
)
|
||||||
|
hb = self.db.heartbeat_lease(a.lease_id, session_id="s1")
|
||||||
|
self.assertEqual(hb["lease_id"], a.lease_id)
|
||||||
|
self.db.release_lease(a.lease_id, session_id="s1")
|
||||||
|
# After release another session can claim
|
||||||
|
self.db.upsert_session(session_id="s2", role="author")
|
||||||
|
b = self.db.assign_and_lease(
|
||||||
|
session_id="s2",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(b.outcome, "assigned")
|
||||||
|
self.assertEqual(b.session_id, "s2")
|
||||||
|
|
||||||
|
def test_require_valid_assignment_rejects_stale_head(self) -> None:
|
||||||
|
"""Assignment must not authorize mutations after work-item head drifts."""
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=42,
|
||||||
|
expected_head_sha="head-v1",
|
||||||
|
allowed_actions=("implement",),
|
||||||
|
)
|
||||||
|
# Head drifts after assignment
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=42,
|
||||||
|
current_head_sha="head-v2",
|
||||||
|
)
|
||||||
|
with self.assertRaises(LeaseRequiredError) as ctx:
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="s1",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=42,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
self.assertIn("stale head", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_require_valid_assignment_rejects_terminal_state(self) -> None:
|
||||||
|
"""Assignment must not authorize mutations after work item is merged/closed."""
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=55,
|
||||||
|
expected_head_sha="abc",
|
||||||
|
allowed_actions=("implement",),
|
||||||
|
)
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=55,
|
||||||
|
state="merged",
|
||||||
|
current_head_sha="abc",
|
||||||
|
)
|
||||||
|
with self.assertRaises(LeaseRequiredError) as ctx:
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="s1",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=55,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
self.assertIn("terminal", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=56,
|
||||||
|
state="open",
|
||||||
|
)
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=56,
|
||||||
|
allowed_actions=("implement",),
|
||||||
|
)
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=56,
|
||||||
|
state="closed",
|
||||||
|
)
|
||||||
|
with self.assertRaises(LeaseRequiredError):
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="s1",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="issue",
|
||||||
|
number=56,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pr_assignment_requires_expected_head_sha(self) -> None:
|
||||||
|
"""PR assign and mutation must fail closed without a head pin."""
|
||||||
|
self.db.upsert_session(session_id="s1", role="author")
|
||||||
|
with self.assertRaises(LeaseRequiredError) as ctx:
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="s1",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=88,
|
||||||
|
expected_head_sha=None,
|
||||||
|
allowed_actions=("implement",),
|
||||||
|
)
|
||||||
|
self.assertIn("expected_head_sha", str(ctx.exception))
|
||||||
|
|
||||||
|
# Legacy path: force an unpinned PR assignment into the DB, then
|
||||||
|
# populate head and prove mutation is still rejected.
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=89,
|
||||||
|
current_head_sha=None,
|
||||||
|
)
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
wid = conn.execute(
|
||||||
|
"SELECT work_item_id FROM work_items WHERE kind='pr' AND number=89"
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at, status)
|
||||||
|
VALUES ('legacy', 'author', '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z', 'active')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO leases(
|
||||||
|
lease_id, work_item_id, session_id, role, phase,
|
||||||
|
expires_at, heartbeat_at, status
|
||||||
|
) VALUES (
|
||||||
|
'lease-legacy', ?, 'legacy', 'author', 'claimed',
|
||||||
|
'2099-01-01T00:00:00Z', '2020-01-01T00:00:00Z', 'active'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(wid,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assignments(
|
||||||
|
assignment_id, work_item_id, session_id, lease_id,
|
||||||
|
allowed_actions, forbidden_actions, expected_head_sha,
|
||||||
|
role, status, created_at
|
||||||
|
) VALUES (
|
||||||
|
'asn-legacy', ?, 'legacy', 'lease-legacy',
|
||||||
|
'["implement"]', '["merge"]', NULL,
|
||||||
|
'author', 'active', '2020-01-01T00:00:00Z'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(wid,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.db.upsert_work_item(
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=89,
|
||||||
|
current_head_sha="populated-head",
|
||||||
|
)
|
||||||
|
with self.assertRaises(LeaseRequiredError) as ctx2:
|
||||||
|
self.db.require_valid_assignment(
|
||||||
|
session_id="legacy",
|
||||||
|
remote="prgs",
|
||||||
|
org="o",
|
||||||
|
repo="r",
|
||||||
|
kind="pr",
|
||||||
|
number=89,
|
||||||
|
action="implement",
|
||||||
|
)
|
||||||
|
self.assertIn("expected_head_sha pin", str(ctx2.exception))
|
||||||
|
|
||||||
|
def test_migrate_duplicate_null_scope_incident_links(self) -> None:
|
||||||
|
"""Legacy NULL-scope duplicates must migrate without UNIQUE crash."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from control_plane_db import ControlPlaneDB, ControlPlaneError
|
||||||
|
|
||||||
|
# Build a v1-like table with nullable scope columns and insert dups
|
||||||
|
# that collapse under normalization, then open ControlPlaneDB on it.
|
||||||
|
path = os.path.join(self._tmp.name, "legacy_dups.sqlite3")
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE incident_links (
|
||||||
|
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_base_url TEXT,
|
||||||
|
provider_org TEXT,
|
||||||
|
provider_project TEXT,
|
||||||
|
provider_issue_id TEXT NOT NULL,
|
||||||
|
provider_short_id TEXT,
|
||||||
|
provider_permalink TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
|
gitea_org TEXT NOT NULL,
|
||||||
|
gitea_repo TEXT NOT NULL,
|
||||||
|
gitea_issue_number INTEGER NOT NULL,
|
||||||
|
linked_pr_numbers TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
last_seen TEXT,
|
||||||
|
event_count INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
release_resolved_at TEXT,
|
||||||
|
last_sync_at TEXT,
|
||||||
|
UNIQUE (
|
||||||
|
provider, provider_base_url, provider_org,
|
||||||
|
provider_project, provider_issue_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO incident_links(
|
||||||
|
provider, provider_base_url, provider_org, provider_project,
|
||||||
|
provider_issue_id, gitea_org, gitea_repo, gitea_issue_number, status
|
||||||
|
) VALUES
|
||||||
|
('sentry', NULL, NULL, NULL, 'dup-1', 'org', 'repo', 10, 'open'),
|
||||||
|
('sentry', NULL, NULL, NULL, 'dup-1', 'org', 'repo', 10, 'open');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# SQLite allows two NULL-scope rows with same provider/issue under UNIQUE.
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
|
||||||
|
self.assertEqual(n, 2)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
db = ControlPlaneDB(path)
|
||||||
|
conn2 = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
n2 = conn2.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
|
||||||
|
rows = conn2.execute(
|
||||||
|
"SELECT provider_base_url, provider_org, provider_project, gitea_issue_number "
|
||||||
|
"FROM incident_links"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn2.close()
|
||||||
|
self.assertEqual(n2, 1)
|
||||||
|
self.assertEqual(rows[0][0], "")
|
||||||
|
self.assertEqual(rows[0][1], "")
|
||||||
|
self.assertEqual(rows[0][2], "")
|
||||||
|
self.assertEqual(rows[0][3], 10)
|
||||||
|
# Touch to silence unused import in type checkers if needed
|
||||||
|
self.assertTrue(issubclass(ControlPlaneError, Exception))
|
||||||
|
del db
|
||||||
|
|
||||||
|
def test_migrate_conflicting_duplicate_incident_links_fails_closed(self) -> None:
|
||||||
|
"""Conflicting Gitea targets for the same provider key must fail closed."""
|
||||||
|
import sqlite3
|
||||||
|
from control_plane_db import ControlPlaneDB, ControlPlaneError
|
||||||
|
|
||||||
|
path = os.path.join(self._tmp.name, "legacy_conflict.sqlite3")
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE incident_links (
|
||||||
|
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_base_url TEXT,
|
||||||
|
provider_org TEXT,
|
||||||
|
provider_project TEXT,
|
||||||
|
provider_issue_id TEXT NOT NULL,
|
||||||
|
gitea_org TEXT NOT NULL,
|
||||||
|
gitea_repo TEXT NOT NULL,
|
||||||
|
gitea_issue_number INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
UNIQUE (
|
||||||
|
provider, provider_base_url, provider_org,
|
||||||
|
provider_project, provider_issue_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO incident_links(
|
||||||
|
provider, provider_base_url, provider_org, provider_project,
|
||||||
|
provider_issue_id, gitea_org, gitea_repo, gitea_issue_number
|
||||||
|
) VALUES
|
||||||
|
('sentry', NULL, NULL, NULL, 'dup-c', 'org', 'repo', 1),
|
||||||
|
('sentry', NULL, NULL, NULL, 'dup-c', 'org', 'repo', 2);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with self.assertRaises(ControlPlaneError) as ctx:
|
||||||
|
ControlPlaneDB(path)
|
||||||
|
self.assertIn("conflicting", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_migrate_conflicting_observation_metadata_fails_closed(self) -> None:
|
||||||
|
"""Same provider key + same Gitea target but differing obs metadata must fail closed.
|
||||||
|
|
||||||
|
Regression for silent data loss: migration used to keep lowest link_id and
|
||||||
|
delete peers after comparing only Gitea targets (#619 RC3).
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from control_plane_db import ControlPlaneDB, ControlPlaneError
|
||||||
|
|
||||||
|
path = os.path.join(self._tmp.name, "legacy_meta_conflict.sqlite3")
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE incident_links (
|
||||||
|
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_base_url TEXT,
|
||||||
|
provider_org TEXT,
|
||||||
|
provider_project TEXT,
|
||||||
|
provider_issue_id TEXT NOT NULL,
|
||||||
|
provider_short_id TEXT,
|
||||||
|
provider_permalink TEXT,
|
||||||
|
fingerprint TEXT,
|
||||||
|
gitea_org TEXT NOT NULL,
|
||||||
|
gitea_repo TEXT NOT NULL,
|
||||||
|
gitea_issue_number INTEGER NOT NULL,
|
||||||
|
linked_pr_numbers TEXT,
|
||||||
|
first_seen TEXT,
|
||||||
|
last_seen TEXT,
|
||||||
|
event_count INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
release_resolved_at TEXT,
|
||||||
|
last_sync_at TEXT,
|
||||||
|
UNIQUE (
|
||||||
|
provider, provider_base_url, provider_org,
|
||||||
|
provider_project, provider_issue_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
INSERT INTO incident_links(
|
||||||
|
provider, provider_base_url, provider_org, provider_project,
|
||||||
|
provider_issue_id, provider_permalink, fingerprint,
|
||||||
|
gitea_org, gitea_repo, gitea_issue_number,
|
||||||
|
event_count, status, first_seen, last_seen
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'sentry', NULL, NULL, NULL, 'dup-meta',
|
||||||
|
'https://sentry.example/issues/1', 'fingerprint-A',
|
||||||
|
'org', 'repo', 42,
|
||||||
|
1, 'open', '2026-01-01T00:00:00Z', '2026-01-01T01:00:00Z'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'sentry', NULL, NULL, NULL, 'dup-meta',
|
||||||
|
'https://sentry.example/issues/1', 'fingerprint-B',
|
||||||
|
'org', 'repo', 42,
|
||||||
|
99, 'resolved', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
|
||||||
|
self.assertEqual(n, 2)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with self.assertRaises(ControlPlaneError) as ctx:
|
||||||
|
ControlPlaneDB(path)
|
||||||
|
msg = str(ctx.exception).lower()
|
||||||
|
self.assertIn("conflicting", msg)
|
||||||
|
self.assertIn("observation metadata", msg)
|
||||||
|
|
||||||
|
# Rows must still be present — migration must not delete before failing.
|
||||||
|
conn2 = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
remaining = conn2.execute("SELECT COUNT(*) FROM incident_links").fetchone()[0]
|
||||||
|
fps = {
|
||||||
|
r[0]
|
||||||
|
for r in conn2.execute(
|
||||||
|
"SELECT fingerprint FROM incident_links ORDER BY link_id"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn2.close()
|
||||||
|
self.assertEqual(remaining, 2)
|
||||||
|
self.assertEqual(fps, {"fingerprint-A", "fingerprint-B"})
|
||||||
|
|
||||||
|
def test_incident_links_minimal_upsert_is_canonical(self) -> None:
|
||||||
|
"""Repeated minimal upserts must update one row (NULL-safe uniqueness)."""
|
||||||
|
a = self.db.upsert_incident_link(
|
||||||
|
provider="sentry",
|
||||||
|
provider_issue_id="inc-1",
|
||||||
|
gitea_org="org",
|
||||||
|
gitea_repo="repo",
|
||||||
|
gitea_issue_number=1,
|
||||||
|
)
|
||||||
|
b = self.db.upsert_incident_link(
|
||||||
|
provider="sentry",
|
||||||
|
provider_issue_id="inc-1",
|
||||||
|
gitea_org="org",
|
||||||
|
gitea_repo="repo",
|
||||||
|
gitea_issue_number=2,
|
||||||
|
# omit optional scope fields again
|
||||||
|
)
|
||||||
|
c = self.db.upsert_incident_link(
|
||||||
|
provider="sentry",
|
||||||
|
provider_issue_id="inc-1",
|
||||||
|
gitea_org="org",
|
||||||
|
gitea_repo="repo",
|
||||||
|
gitea_issue_number=3,
|
||||||
|
provider_base_url=None,
|
||||||
|
provider_org="",
|
||||||
|
provider_project=" ",
|
||||||
|
)
|
||||||
|
self.assertEqual(a["link_id"], b["link_id"])
|
||||||
|
self.assertEqual(b["link_id"], c["link_id"])
|
||||||
|
self.assertEqual(c["gitea_issue_number"], 3)
|
||||||
|
self.assertEqual(c["provider_base_url"], "")
|
||||||
|
self.assertEqual(c["provider_org"], "")
|
||||||
|
self.assertEqual(c["provider_project"], "")
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
n = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM incident_links WHERE provider_issue_id = ?",
|
||||||
|
("inc-1",),
|
||||||
|
).fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -781,6 +781,103 @@ class TestMergePrRules(unittest.TestCase):
|
|||||||
self.assertEqual(result["grade"], "A")
|
self.assertEqual(result["grade"], "A")
|
||||||
self.assertFalse(result["blocked"])
|
self.assertFalse(result["blocked"])
|
||||||
|
|
||||||
|
def test_manual_seeded_lease_claim_blocks_without_mcp_evidence(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: merge completed",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: merged using manually seeded lease proof",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
"- Active profile: prgs-merger",
|
||||||
|
"- Role kind: merger",
|
||||||
|
"- Merge capability source: profile",
|
||||||
|
"- Explicit operator authorization: MERGE PR 203",
|
||||||
|
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Approval at current head: yes",
|
||||||
|
"- Merge result: PR merged successfully",
|
||||||
|
"- Cleanup status: complete",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(report, "merge_pr")
|
||||||
|
self.assertTrue(result["blocked"])
|
||||||
|
codes = {f.get("rule_id") for f in result.get("findings") or []}
|
||||||
|
self.assertIn("shared.manual_lease_proof_handoff", codes)
|
||||||
|
|
||||||
|
def test_manual_seeded_claim_allowed_when_adoption_cited(self):
|
||||||
|
lines = [
|
||||||
|
"## Controller Handoff",
|
||||||
|
"",
|
||||||
|
"- Task: merge PR #203",
|
||||||
|
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"- Role: merger",
|
||||||
|
"- Identity: sysadmin / prgs-merger",
|
||||||
|
"- Issue/PR: #182 / PR #203",
|
||||||
|
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Files changed: review_proofs.py",
|
||||||
|
"- Validation: pytest in branches/review-203",
|
||||||
|
"- Mutations: merge completed",
|
||||||
|
"- File edits by reviewer: none",
|
||||||
|
"- Worktree/index mutations: none",
|
||||||
|
"- Git ref mutations: git fetch prgs master",
|
||||||
|
"- MCP/Gitea mutations: gitea_adopt_merger_pr_lease then merge",
|
||||||
|
"- Review mutations: none",
|
||||||
|
"- Merge mutations: historical manual seed replaced by sanctioned adoption",
|
||||||
|
"- Cleanup mutations: none",
|
||||||
|
"- External-state mutations: none",
|
||||||
|
"- Read-only diagnostics: metadata check",
|
||||||
|
"- Current status: PR merged",
|
||||||
|
"- Blockers: none",
|
||||||
|
"- Next: none",
|
||||||
|
"- Safety: review and merge are separate roles",
|
||||||
|
"- Selected PR: #203",
|
||||||
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Push occurred during validation: no",
|
||||||
|
"- Active profile: prgs-merger",
|
||||||
|
"- Role kind: merger",
|
||||||
|
"- Merge capability source: profile",
|
||||||
|
"- Explicit operator authorization: MERGE PR 203",
|
||||||
|
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
|
"- Approval at current head: yes",
|
||||||
|
"- lease_proof_source: gitea_adopt_merger_pr_lease",
|
||||||
|
"- lease_proof_kind: sanctioned_adoption",
|
||||||
|
"- adoption_comment_id: 8288",
|
||||||
|
"- Merge result: PR merged successfully",
|
||||||
|
"- Cleanup status: complete",
|
||||||
|
]
|
||||||
|
report = "\n".join(lines)
|
||||||
|
result = assess_final_report_validator(report, "merge_pr")
|
||||||
|
self.assertFalse(result["blocked"])
|
||||||
|
codes = {f.get("rule_id") for f in result.get("findings") or []}
|
||||||
|
self.assertNotIn("shared.manual_lease_proof_handoff", codes)
|
||||||
|
|
||||||
def test_missing_merger_fields_blocks(self):
|
def test_missing_merger_fields_blocks(self):
|
||||||
lines = [
|
lines = [
|
||||||
"## Controller Handoff",
|
"## Controller Handoff",
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from issue_content_gate import ( # noqa: E402
|
||||||
|
assess_issue_content,
|
||||||
|
find_vague_reference,
|
||||||
|
has_durable_source_pointer,
|
||||||
|
normalize_text,
|
||||||
|
pre_create_issue_content_gate,
|
||||||
|
)
|
||||||
|
from mcp_server import gitea_create_issue # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
ISSUE_WRITE_ENV = {
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeAndPointers(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_normalize_strips_punctuation_and_case(self):
|
||||||
|
self.assertEqual(normalize_text(" The Drafted-Issue! "), "the drafted issue")
|
||||||
|
|
||||||
|
def test_issue_ref_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("See #402 for the spec"))
|
||||||
|
|
||||||
|
def test_file_path_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("Draft in task_capability_map.py"))
|
||||||
|
|
||||||
|
def test_scratchpad_is_durable_pointer(self):
|
||||||
|
self.assertTrue(has_durable_source_pointer("Full text in scratchpad/draft.md"))
|
||||||
|
|
||||||
|
def test_url_is_durable_pointer(self):
|
||||||
|
self.assertTrue(
|
||||||
|
has_durable_source_pointer("https://gitea.prgs.cc/issues/1 has it")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_plain_text_has_no_durable_pointer(self):
|
||||||
|
self.assertFalse(has_durable_source_pointer("just some words here"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindVagueReference(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_bare_vague_phrases_are_flagged(self):
|
||||||
|
for text in (
|
||||||
|
"the drafted issue",
|
||||||
|
"The prepared issue",
|
||||||
|
"the issue we discussed",
|
||||||
|
"the previous draft",
|
||||||
|
"Create the drafted issue",
|
||||||
|
"please file the prepared issue",
|
||||||
|
):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
self.assertIsNotNone(find_vague_reference(text))
|
||||||
|
|
||||||
|
def test_durable_pointer_defeats_vague_reference(self):
|
||||||
|
# Same vague phrase but with a durable pointer -> not vague.
|
||||||
|
self.assertIsNone(find_vague_reference("the drafted issue in #402"))
|
||||||
|
self.assertIsNone(
|
||||||
|
find_vague_reference("the prepared issue: see scratchpad/draft.md")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_long_specific_body_with_incidental_phrase_is_not_vague(self):
|
||||||
|
body = (
|
||||||
|
"As discussed, the create_issue tool must reject vague references. "
|
||||||
|
"Add a preflight check that lists the exact missing fields and "
|
||||||
|
"returns BLOCKED + DIAGNOSE with concrete acceptance criteria."
|
||||||
|
)
|
||||||
|
self.assertIsNone(find_vague_reference(body))
|
||||||
|
|
||||||
|
def test_concrete_short_body_is_not_vague(self):
|
||||||
|
self.assertIsNone(find_vague_reference("Fix null deref in auth handler"))
|
||||||
|
|
||||||
|
def test_empty_is_not_vague(self):
|
||||||
|
self.assertIsNone(find_vague_reference(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessIssueContent(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_missing_title_is_incomplete(self):
|
||||||
|
result = assess_issue_content("", "some body")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("title", result["missing_fields"])
|
||||||
|
|
||||||
|
def test_vague_body_is_incomplete_and_diagnosed(self):
|
||||||
|
result = assess_issue_content("Add the thing", "the drafted issue")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("body", result["vague_fields"])
|
||||||
|
self.assertIn("vague reference", result["diagnose"])
|
||||||
|
|
||||||
|
def test_vague_title_is_incomplete(self):
|
||||||
|
result = assess_issue_content("the prepared issue", "")
|
||||||
|
self.assertFalse(result["complete"])
|
||||||
|
self.assertIn("title", result["vague_fields"])
|
||||||
|
|
||||||
|
def test_complete_content_passes(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"Block vague issue creation",
|
||||||
|
"Add a preflight gate that rejects placeholder references.",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
self.assertEqual(result["missing_fields"], [])
|
||||||
|
self.assertEqual(result["vague_fields"], [])
|
||||||
|
|
||||||
|
def test_title_only_is_allowed(self):
|
||||||
|
# Empty body must NOT block: title-only creation stays allowed.
|
||||||
|
result = assess_issue_content("A concrete specific title", "")
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
|
||||||
|
def test_durable_pointer_body_passes(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"Implement the drafted change",
|
||||||
|
"the drafted issue is fully specified in #582",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
|
||||||
|
def test_allow_incomplete_override(self):
|
||||||
|
result = assess_issue_content(
|
||||||
|
"the drafted issue", "the drafted issue", allow_incomplete=True
|
||||||
|
)
|
||||||
|
self.assertTrue(result["complete"])
|
||||||
|
self.assertTrue(result["override_applied"])
|
||||||
|
|
||||||
|
def test_gate_wrapper_sets_performed(self):
|
||||||
|
blocked = pre_create_issue_content_gate("Add x", "the drafted issue")
|
||||||
|
self.assertFalse(blocked["performed"])
|
||||||
|
ok = pre_create_issue_content_gate("Add x", "concrete actionable body")
|
||||||
|
self.assertTrue(ok["performed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueContentMCPGate(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_blocks_vague_reference_without_creating(
|
||||||
|
self, _auth, mock_get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="Create the drafted issue",
|
||||||
|
body="the drafted issue",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIn("content_gate", result)
|
||||||
|
self.assertIn("body", result["content_gate"]["vague_fields"])
|
||||||
|
self.assertTrue(any("BLOCKED + DIAGNOSE" in r for r in result["reasons"]))
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
mock_get_all.assert_not_called()
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_creates_when_content_is_concrete(
|
||||||
|
self, _auth, _get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
mock_api.return_value = {"number": 7, "html_url": "https://x/issues/7"}
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="Block vague issue creation",
|
||||||
|
body="Add a preflight gate rejecting placeholder references.",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertEqual(result["number"], 7)
|
||||||
|
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_override_creates_despite_vague_content(
|
||||||
|
self, _auth, _get_all, mock_api, mock_role_check
|
||||||
|
):
|
||||||
|
mock_role_check.return_value = (True, [])
|
||||||
|
mock_api.return_value = {"number": 8, "html_url": "https://x/issues/8"}
|
||||||
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
|
result = gitea_create_issue(
|
||||||
|
title="the drafted issue",
|
||||||
|
body="the drafted issue",
|
||||||
|
remote="prgs",
|
||||||
|
allow_incomplete_content=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["number"], 8)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -774,6 +774,9 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
import reviewer_pr_lease
|
import reviewer_pr_lease
|
||||||
|
|
||||||
|
# Clean ledger each merge test so residual/host durable #332 state
|
||||||
|
# cannot short-circuit eligibility gates (#590 / #594).
|
||||||
|
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)
|
||||||
self._lease_patch.start()
|
self._lease_patch.start()
|
||||||
@@ -1003,6 +1006,64 @@ class TestMergePR(unittest.TestCase):
|
|||||||
r["reasons"])
|
r["reasons"])
|
||||||
self._assert_no_merge_call(mock_api)
|
self._assert_no_merge_call(mock_api)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_env_clear_does_not_adopt_host_review_mutation_ledger(
|
||||||
|
self, _auth, mock_api
|
||||||
|
):
|
||||||
|
"""#590: patch.dict(clear=True) must not load host terminal mutations.
|
||||||
|
|
||||||
|
Simulates a dirty host session-state file for profile gitea-default
|
||||||
|
(the profile active when env is fully cleared). Isolation must keep
|
||||||
|
the #332 hard-stop ledger clean so the empty-profile gate fires.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import mcp_session_state
|
||||||
|
|
||||||
|
poison_dir = tempfile.mkdtemp(prefix="gitea-host-poison-")
|
||||||
|
mcp_session_state.save_state(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="gitea-default",
|
||||||
|
state_dir=poison_dir,
|
||||||
|
payload={
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"final_review_decision_ready": True,
|
||||||
|
"ready_pr_number": 8,
|
||||||
|
"ready_action": "request_changes",
|
||||||
|
"live_mutations": [
|
||||||
|
{
|
||||||
|
"pr_number": 8,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 99,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# If isolation only used the env var, clear=True would fall back to
|
||||||
|
# DEFAULT_STATE_DIR and adopt this poison ledger.
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||||
|
with patch.object(mcp_session_state, "DEFAULT_STATE_DIR", poison_dir):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
r = gitea_merge_pr(
|
||||||
|
pr_number=8,
|
||||||
|
confirmation=self._confirm(8),
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertFalse(r["performed"])
|
||||||
|
self.assertIn(
|
||||||
|
"profile has no configured allowed operations (fail closed)",
|
||||||
|
r["reasons"],
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any("terminal review mutation already consumed" in x for x in r["reasons"]),
|
||||||
|
msg=f"host ledger leaked into reasons: {r['reasons']}",
|
||||||
|
)
|
||||||
|
self._assert_no_merge_call(mock_api)
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||||
@@ -2672,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
|
||||||
|
|
||||||
@@ -4288,3 +4399,236 @@ class TestIssue546Deadlock(unittest.TestCase):
|
|||||||
self.assertIsNone(reviewer_pr_lease.get_session_lease())
|
self.assertIsNone(reviewer_pr_lease.get_session_lease())
|
||||||
# verify comment phase is released
|
# verify comment phase is released
|
||||||
self.assertIn("phase: released", posted_comments[0]["body"])
|
self.assertIn("phase: released", posted_comments[0]["body"])
|
||||||
|
|
||||||
|
def test_reviewer_pr_lease_gate_resolves_identity_with_host(self):
|
||||||
|
"""Lease mutation gate must resolve identity using host, not remote alias."""
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
resolved_hosts = []
|
||||||
|
|
||||||
|
def tracking_username(host):
|
||||||
|
resolved_hosts.append(host)
|
||||||
|
return None if host in {"prgs", "dadeschools"} else "reviewer-bot"
|
||||||
|
|
||||||
|
with _install_owned_reviewer_lease(
|
||||||
|
550,
|
||||||
|
session_id=_DEFAULT_LEASE_SESSION,
|
||||||
|
head_sha="abc123",
|
||||||
|
), patch(
|
||||||
|
"mcp_server._resolve",
|
||||||
|
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
|
||||||
|
), patch(
|
||||||
|
"mcp_server._authenticated_username",
|
||||||
|
side_effect=tracking_username,
|
||||||
|
):
|
||||||
|
reasons = mcp_server._reviewer_pr_lease_gate(
|
||||||
|
pr_number=550,
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org=None,
|
||||||
|
repo=None,
|
||||||
|
mutation="approve",
|
||||||
|
live_head_sha="abc123",
|
||||||
|
pinned_head_sha="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
self.assertIn("gitea.prgs.cc", resolved_hosts)
|
||||||
|
self.assertNotIn("prgs", resolved_hosts)
|
||||||
|
|
||||||
|
def test_acquire_reviewer_pr_lease_resolves_identity_with_host(self):
|
||||||
|
"""Acquire path must not pass a remote alias into identity lookup."""
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
resolved_hosts = []
|
||||||
|
posted = []
|
||||||
|
|
||||||
|
def tracking_username(host):
|
||||||
|
resolved_hosts.append(host)
|
||||||
|
return None if host in {"prgs", "dadeschools"} else "reviewer-bot"
|
||||||
|
|
||||||
|
def mock_api(method, url, auth, data=None):
|
||||||
|
if "/pulls/" in url:
|
||||||
|
return {
|
||||||
|
"user": {"login": "author-user"},
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "abc123"},
|
||||||
|
"mergeable": True,
|
||||||
|
}
|
||||||
|
if "/comments" in url:
|
||||||
|
if method == "POST":
|
||||||
|
posted.append(data["body"])
|
||||||
|
return {"id": 1003}
|
||||||
|
return []
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=mock_api), patch(
|
||||||
|
"mcp_server._authenticated_username",
|
||||||
|
side_effect=tracking_username,
|
||||||
|
), patch(
|
||||||
|
"mcp_server._resolve",
|
||||||
|
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
|
||||||
|
):
|
||||||
|
res = mcp_server.gitea_acquire_reviewer_pr_lease(
|
||||||
|
pr_number=550,
|
||||||
|
worktree="/workspace",
|
||||||
|
candidate_head="abc123",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertTrue(res.get("acquired"), res)
|
||||||
|
self.assertEqual(res.get("comment_id"), 1003)
|
||||||
|
self.assertTrue(posted)
|
||||||
|
self.assertIn("gitea.prgs.cc", resolved_hosts)
|
||||||
|
self.assertNotIn("prgs", resolved_hosts)
|
||||||
|
|
||||||
|
def test_adopt_merger_pr_lease_resolves_identity_with_host(self):
|
||||||
|
"""Adopt path must not pass a remote alias into identity lookup."""
|
||||||
|
import mcp_server
|
||||||
|
import reviewer_pr_lease
|
||||||
|
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
head = "a" * 40
|
||||||
|
resolved_hosts = []
|
||||||
|
posted = []
|
||||||
|
reviewer_comment = _reviewer_lease_comment(
|
||||||
|
550,
|
||||||
|
session_id="reviewer-session",
|
||||||
|
head_sha=head,
|
||||||
|
reviewer="reviewer-bot",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tracking_username(host):
|
||||||
|
resolved_hosts.append(host)
|
||||||
|
return None if host in {"prgs", "dadeschools"} else "merger-bot"
|
||||||
|
|
||||||
|
def mock_api(method, url, auth, data=None):
|
||||||
|
if "/pulls/" in url and "/comments" not in url:
|
||||||
|
return {
|
||||||
|
"number": 550,
|
||||||
|
"user": {"login": "author-user"},
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": head},
|
||||||
|
"mergeable": True,
|
||||||
|
"merged": False,
|
||||||
|
}
|
||||||
|
if "/comments" in url:
|
||||||
|
if method == "POST":
|
||||||
|
posted.append(data["body"])
|
||||||
|
return {"id": 1004}
|
||||||
|
return [reviewer_comment]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
merger_profile = {
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
with patch("mcp_server.get_profile", return_value=merger_profile), patch(
|
||||||
|
"mcp_server.api_request",
|
||||||
|
side_effect=mock_api,
|
||||||
|
), patch(
|
||||||
|
"mcp_server._authenticated_username",
|
||||||
|
side_effect=tracking_username,
|
||||||
|
), patch(
|
||||||
|
"mcp_server._resolve",
|
||||||
|
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
|
||||||
|
), patch(
|
||||||
|
"mcp_server.gitea_get_pr_review_feedback",
|
||||||
|
return_value={
|
||||||
|
"approval_at_current_head": True,
|
||||||
|
"latest_approved_head_sha": head,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
res = mcp_server.gitea_adopt_merger_pr_lease(
|
||||||
|
pr_number=550,
|
||||||
|
worktree="/workspace",
|
||||||
|
expected_head_sha=head,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertTrue(res.get("adopted"), res)
|
||||||
|
self.assertEqual(res.get("adoption_comment_id"), 1004)
|
||||||
|
self.assertTrue(posted)
|
||||||
|
self.assertIn("gitea.prgs.cc", resolved_hosts)
|
||||||
|
self.assertNotIn("prgs", resolved_hosts)
|
||||||
|
|
||||||
|
def test_release_reviewer_pr_lease_same_identity_cross_session(self):
|
||||||
|
"""Same reviewer identity may release a lease claimed by another session.
|
||||||
|
|
||||||
|
Regression: release used to call ``_authenticated_username(remote)``
|
||||||
|
with the remote alias (e.g. ``prgs``) instead of the resolved host,
|
||||||
|
so identity resolved empty and same-identity release fail-closed even
|
||||||
|
when the authenticated user owned the lease.
|
||||||
|
"""
|
||||||
|
import mcp_server
|
||||||
|
import reviewer_pr_lease
|
||||||
|
|
||||||
|
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
|
||||||
|
foreign_session = "46820-dd78cdc4aacc"
|
||||||
|
comments = [
|
||||||
|
_reviewer_lease_comment(
|
||||||
|
457,
|
||||||
|
session_id=foreign_session,
|
||||||
|
head_sha="ef287eaf5841d9e542a4e888ce0ae7d679dbd685",
|
||||||
|
reviewer="sysadmin",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
posted = []
|
||||||
|
resolved_hosts = []
|
||||||
|
|
||||||
|
def mock_api(method, url, auth, data=None):
|
||||||
|
if "/api/v1/user" in url:
|
||||||
|
return {"login": "sysadmin"}
|
||||||
|
if "/comments" in url:
|
||||||
|
if method == "POST":
|
||||||
|
new_c = {
|
||||||
|
"id": 8071,
|
||||||
|
"body": data["body"],
|
||||||
|
"user": {"login": "sysadmin"},
|
||||||
|
}
|
||||||
|
comments.append(new_c)
|
||||||
|
posted.append(new_c)
|
||||||
|
return {"id": 8071}
|
||||||
|
return comments
|
||||||
|
if "/pulls/" in url:
|
||||||
|
return {
|
||||||
|
"user": {"login": "jcwalker3"},
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "ef287eaf5841d9e542a4e888ce0ae7d679dbd685"},
|
||||||
|
"mergeable": False,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def tracking_username(host):
|
||||||
|
resolved_hosts.append(host)
|
||||||
|
# Empty identity when passed a remote alias (the pre-fix bug path).
|
||||||
|
if host in {"prgs", "dadeschools"}:
|
||||||
|
return None
|
||||||
|
return "sysadmin"
|
||||||
|
|
||||||
|
with patch("mcp_server.api_request", side_effect=mock_api), patch(
|
||||||
|
"mcp_server._authenticated_username", side_effect=tracking_username
|
||||||
|
), patch(
|
||||||
|
"mcp_server._resolve",
|
||||||
|
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
|
||||||
|
), patch("mcp_server._auth", return_value={"Authorization": "token x"}), patch(
|
||||||
|
"mcp_server._verify_role_mutation_workspace", return_value=None
|
||||||
|
):
|
||||||
|
res = mcp_server.gitea_release_reviewer_pr_lease(
|
||||||
|
pr_number=457, remote="prgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertTrue(res.get("released"), res)
|
||||||
|
self.assertEqual(res.get("comment_id"), 8071)
|
||||||
|
self.assertTrue(posted)
|
||||||
|
self.assertIn("phase: released", posted[0]["body"])
|
||||||
|
# Must resolve identity with host, never the remote alias alone.
|
||||||
|
self.assertIn("gitea.prgs.cc", resolved_hosts)
|
||||||
|
self.assertNotIn("prgs", resolved_hosts)
|
||||||
|
|||||||
@@ -67,5 +67,66 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
# But does NOT contain missing author profile error
|
# But does NOT contain missing author profile error
|
||||||
self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author))
|
self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author))
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch.dict("os.environ", {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"})
|
||||||
|
def test_git_head_change_triggers_staleness(self, mock_getpid, mock_exists, mock_getmtime, mock_run):
|
||||||
|
# Set boot SHA to FAKE1 and current to FAKE2
|
||||||
|
gitea_mcp_server._process_boot_head_sha = "FAKE1"
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
|
||||||
|
# Code time is in the past (fresh process chronologically)
|
||||||
|
code_time = datetime(2026, 7, 8, 11, 0, 0)
|
||||||
|
mock_getmtime.return_value = code_time.timestamp()
|
||||||
|
|
||||||
|
ps_output = (
|
||||||
|
" PID LSTART COMMAND\n"
|
||||||
|
"12345 Wed Jul 8 12:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run_ps = MagicMock()
|
||||||
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
|
mock_run_env = MagicMock()
|
||||||
|
mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author"
|
||||||
|
|
||||||
|
mock_run_git = MagicMock()
|
||||||
|
mock_run_git.stdout = "FAKE2" # different SHA
|
||||||
|
|
||||||
|
def side_effect(args, **kwargs):
|
||||||
|
if args[0] == "ps" and "eww" in args:
|
||||||
|
return mock_run_env
|
||||||
|
elif args[0] == "ps":
|
||||||
|
return mock_run_ps
|
||||||
|
elif args[0] == "git" and "rev-parse" in args:
|
||||||
|
return mock_run_git
|
||||||
|
raise ValueError(f"Unexpected: {args}")
|
||||||
|
|
||||||
|
mock_run.side_effect = side_effect
|
||||||
|
|
||||||
|
# Stale even though process started AFTER code mtime, because git HEAD changed
|
||||||
|
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||||
|
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||||
|
|
||||||
|
@patch("threading.Thread")
|
||||||
|
@patch("os.utime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
||||||
|
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
||||||
|
mock_exists.return_value = True
|
||||||
|
gitea_mcp_server._restart_triggered = False
|
||||||
|
|
||||||
|
# Ensure we are not skipped in test mode for testing purposes
|
||||||
|
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
|
||||||
|
gitea_mcp_server._trigger_mcp_auto_restart()
|
||||||
|
|
||||||
|
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
self.assertTrue(gitea_mcp_server._restart_triggered)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -42,6 +42,96 @@ def _lease_comment(
|
|||||||
HEAD = "f" * 40
|
HEAD = "f" * 40
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescribeSessionLeaseProof(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def test_empty_session_is_none_kind(self):
|
||||||
|
proof = mla.describe_session_lease_proof(None)
|
||||||
|
self.assertIsNone(proof["lease_proof_source"])
|
||||||
|
self.assertEqual(proof["lease_proof_kind"], "none")
|
||||||
|
self.assertFalse(proof["lease_proof_sanctioned"])
|
||||||
|
|
||||||
|
def test_manual_seed_without_provenance(self):
|
||||||
|
leases.record_session_lease({
|
||||||
|
"pr_number": 536,
|
||||||
|
"session_id": "manual",
|
||||||
|
"comment_id": 1,
|
||||||
|
})
|
||||||
|
proof = mla.describe_session_lease_proof(leases.get_session_lease())
|
||||||
|
self.assertIsNone(proof["lease_proof_source"])
|
||||||
|
self.assertEqual(proof["lease_proof_kind"], "unsanctioned_manual_seed")
|
||||||
|
self.assertFalse(proof["lease_proof_sanctioned"])
|
||||||
|
|
||||||
|
def test_sanctioned_adopt_fields(self):
|
||||||
|
leases.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": 536,
|
||||||
|
"session_id": "merger-sess",
|
||||||
|
"comment_id": 700,
|
||||||
|
},
|
||||||
|
lease_provenance=mla.build_lease_provenance(
|
||||||
|
source=mla.SOURCE_ADOPT,
|
||||||
|
comment_id=700,
|
||||||
|
adopted_from_session_id="reviewer-sess",
|
||||||
|
adoption_reason=mla.DEFAULT_ADOPTION_REASON,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
proof = mla.describe_session_lease_proof(leases.get_session_lease())
|
||||||
|
self.assertEqual(proof["lease_proof_source"], mla.SOURCE_ADOPT)
|
||||||
|
self.assertEqual(proof["lease_proof_kind"], "sanctioned_adoption")
|
||||||
|
self.assertTrue(proof["lease_proof_sanctioned"])
|
||||||
|
self.assertEqual(
|
||||||
|
proof["lease_recovery_reason"], mla.DEFAULT_ADOPTION_REASON
|
||||||
|
)
|
||||||
|
self.assertEqual(proof["lease_proof_comment_id"], 700)
|
||||||
|
self.assertEqual(proof["lease_adopted_from_session_id"], "reviewer-sess")
|
||||||
|
|
||||||
|
def test_sanctioned_acquire_fields(self):
|
||||||
|
leases.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": 536,
|
||||||
|
"session_id": "rev",
|
||||||
|
"comment_id": 50,
|
||||||
|
},
|
||||||
|
lease_provenance=mla.build_lease_provenance(
|
||||||
|
source=mla.SOURCE_ACQUIRE,
|
||||||
|
comment_id=50,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
proof = mla.describe_session_lease_proof(leases.get_session_lease())
|
||||||
|
self.assertEqual(proof["lease_proof_source"], mla.SOURCE_ACQUIRE)
|
||||||
|
self.assertEqual(proof["lease_proof_kind"], "sanctioned_acquire")
|
||||||
|
self.assertTrue(proof["lease_proof_sanctioned"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualLeaseProofHandoffAudit(unittest.TestCase):
|
||||||
|
def test_clean_merge_report_passes(self):
|
||||||
|
text = (
|
||||||
|
"Merged via gitea_merge_pr after gitea_adopt_merger_pr_lease. "
|
||||||
|
"lease_proof_source: gitea_adopt_merger_pr_lease"
|
||||||
|
)
|
||||||
|
result = mla.assess_manual_lease_proof_handoff(text)
|
||||||
|
self.assertTrue(result["proven"])
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_manual_seeded_claim_without_mcp_evidence_blocks(self):
|
||||||
|
text = "Merged using manually seeded lease proof from prior session."
|
||||||
|
result = mla.assess_manual_lease_proof_handoff(text)
|
||||||
|
self.assertFalse(result["proven"])
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertTrue(any("#535" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_historical_manual_mention_allowed_with_adoption_evidence(self):
|
||||||
|
text = (
|
||||||
|
"Historical incident used manually seeded lease; this merge used "
|
||||||
|
"gitea_adopt_merger_pr_lease with adoption_comment_id: 8288."
|
||||||
|
)
|
||||||
|
result = mla.assess_manual_lease_proof_handoff(text)
|
||||||
|
self.assertTrue(result["proven"])
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
|
||||||
class TestSanctionedProvenance(unittest.TestCase):
|
class TestSanctionedProvenance(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
leases.clear_session_lease()
|
leases.clear_session_lease()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for native MCP preference gate and shell circuit breaker (#270)."""
|
"""Tests for native MCP preference gate and shell circuit breaker (#270)."""
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
@@ -26,6 +27,90 @@ class TestShellHealthCircuitBreaker(unittest.TestCase):
|
|||||||
self.assertEqual(status["consecutive_spawn_failures"], 0)
|
self.assertEqual(status["consecutive_spawn_failures"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDiagnostics(unittest.TestCase):
|
||||||
|
def test_diagnose_missing_cwd(self):
|
||||||
|
res = nmp.diagnose_terminal_failure("/nonexistent_directory_for_test", ["git", "status"])
|
||||||
|
self.assertFalse(res["cwd_exists"])
|
||||||
|
self.assertEqual(res["error_type"], "missing cwd")
|
||||||
|
self.assertIn("does not exist", res["error_msg"])
|
||||||
|
|
||||||
|
def test_diagnose_cwd_is_not_dir(self):
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
with tempfile.NamedTemporaryFile() as tmp:
|
||||||
|
res = nmp.diagnose_terminal_failure(tmp.name, ["git", "status"])
|
||||||
|
self.assertFalse(res["cwd_is_dir"])
|
||||||
|
self.assertEqual(res["error_type"], "cwd is not a directory")
|
||||||
|
|
||||||
|
def test_diagnose_missing_executable(self):
|
||||||
|
res = nmp.diagnose_terminal_failure(None, ["nonexistent_exec_for_test"])
|
||||||
|
self.assertFalse(res["executable_exists"])
|
||||||
|
self.assertEqual(res["error_type"], "missing executable")
|
||||||
|
|
||||||
|
def test_diagnose_missing_runtime_wrapper(self):
|
||||||
|
res = nmp.diagnose_terminal_failure("/tmp", ["venv/bin/pytest"])
|
||||||
|
self.assertFalse(res["executable_exists"])
|
||||||
|
self.assertEqual(res["error_type"], "missing runtime wrapper")
|
||||||
|
|
||||||
|
def test_diagnose_relative_executable_from_cwd(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
script = Path(tmp) / "tool"
|
||||||
|
script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
os.chmod(script, 0o755)
|
||||||
|
|
||||||
|
res = nmp.diagnose_terminal_failure(tmp, ["./tool"])
|
||||||
|
self.assertTrue(res["executable_exists"])
|
||||||
|
self.assertEqual(res["resolved_executable"], str(script))
|
||||||
|
|
||||||
|
def test_probe_terminal_spawn_success(self):
|
||||||
|
res = nmp.probe_terminal_spawn()
|
||||||
|
self.assertTrue(res["healthy"])
|
||||||
|
self.assertIn("command", res)
|
||||||
|
self.assertIn("diagnostics", res)
|
||||||
|
|
||||||
|
def test_probe_terminal_spawn_missing_cwd(self):
|
||||||
|
res = nmp.probe_terminal_spawn("/nonexistent_directory_for_test")
|
||||||
|
self.assertFalse(res["healthy"])
|
||||||
|
self.assertEqual(res["error_type"], "missing cwd")
|
||||||
|
|
||||||
|
def test_probe_terminal_spawn_honors_custom_command(self):
|
||||||
|
res = nmp.probe_terminal_spawn(
|
||||||
|
command=[sys.executable, "-c", "raise SystemExit(3)"]
|
||||||
|
)
|
||||||
|
self.assertTrue(res["healthy"])
|
||||||
|
self.assertEqual(res["command"][0], sys.executable)
|
||||||
|
self.assertEqual(res["exit_code"], 3)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("git"), "git is required for git -C probe")
|
||||||
|
def test_probe_terminal_spawn_supports_explicit_git_c_worktree(self):
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", tmp],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
res = nmp.probe_terminal_spawn(
|
||||||
|
cwd="/",
|
||||||
|
command=["git", "-C", tmp, "status", "--short"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res["healthy"])
|
||||||
|
self.assertEqual(res["command"][1], "-C")
|
||||||
|
|
||||||
|
def test_probe_terminal_spawn_blocks_missing_custom_command(self):
|
||||||
|
res = nmp.probe_terminal_spawn(command=["nonexistent_exec_for_test"])
|
||||||
|
self.assertFalse(res["healthy"])
|
||||||
|
self.assertEqual(res["error_type"], "missing executable")
|
||||||
|
self.assertEqual(res["command"], ["nonexistent_exec_for_test"])
|
||||||
|
|
||||||
|
|
||||||
class TestNativeMcpPreferenceGate(unittest.TestCase):
|
class TestNativeMcpPreferenceGate(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
nmp.clear_shell_health_for_tests()
|
nmp.clear_shell_health_for_tests()
|
||||||
@@ -118,4 +203,4 @@ class TestNativeMcpPreferenceGate(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ class TestControlPlaneGuide(GuideTestBase):
|
|||||||
blob = " ".join(g["guidance"]).lower()
|
blob = " ".join(g["guidance"]).lower()
|
||||||
self.assertIn("forbidden", blob)
|
self.assertIn("forbidden", blob)
|
||||||
self.assertIn("review", blob)
|
self.assertIn("review", blob)
|
||||||
|
self.assertIn("resolved_repository", g)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "author-bot"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_guide_explicit_org_repo_parameters(self, _auth, _api):
|
||||||
|
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||||
|
g = mcp_get_control_plane_guide(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
g["resolved_repository"],
|
||||||
|
{"org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
|
||||||
|
)
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "reviewer-bot"})
|
@patch("mcp_server.api_request", return_value={"login": "reviewer-bot"})
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Issue #525: reconciler supersession close path."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import role_session_router # noqa: E402
|
||||||
|
import task_capability_map # noqa: E402
|
||||||
|
from review_proofs import assess_reconciler_supersession_close_gate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
HEAD = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||||
|
MERGE = "1111111111111111111111111111111111111111"
|
||||||
|
TARGET = "2222222222222222222222222222222222222222"
|
||||||
|
|
||||||
|
|
||||||
|
def _target_pr(mergeable=False):
|
||||||
|
return {
|
||||||
|
"number": 490,
|
||||||
|
"state": "open",
|
||||||
|
"mergeable": mergeable,
|
||||||
|
"head": {"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _superseding_pr():
|
||||||
|
return {
|
||||||
|
"number": 502,
|
||||||
|
"state": "closed",
|
||||||
|
"merged": True,
|
||||||
|
"merge_commit_sha": MERGE,
|
||||||
|
"head": {"sha": HEAD},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcilerSupersessionCapabilityMap(unittest.TestCase):
|
||||||
|
def test_supersession_tasks_route_to_reconciler(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission(
|
||||||
|
"reconcile_close_superseded_pr"),
|
||||||
|
"gitea.pr.close",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("reconcile_close_superseded_pr"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission(
|
||||||
|
"reconcile_close_satisfied_issue"),
|
||||||
|
"gitea.issue.close",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("reconcile_create_followup_issue"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_author_session_cannot_route_supersession_close(self):
|
||||||
|
result = role_session_router.route_task_session(
|
||||||
|
"reconcile_close_superseded_pr",
|
||||||
|
active_profile="prgs-author",
|
||||||
|
active_role_kind="author",
|
||||||
|
allowed_in_current_session=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["route_result"], "wrong_role_stop")
|
||||||
|
self.assertFalse(result["downstream_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcilerSupersessionCloseGate(unittest.TestCase):
|
||||||
|
def _gate(self, **overrides):
|
||||||
|
kwargs = {
|
||||||
|
"target_pr_number": 490,
|
||||||
|
"target_pr_state": "open",
|
||||||
|
"target_pr_mergeable": False,
|
||||||
|
"target_independently_required": False,
|
||||||
|
"superseding_pr_number": 502,
|
||||||
|
"superseding_pr_state": "closed",
|
||||||
|
"superseding_pr_merged": True,
|
||||||
|
"superseding_merge_commit_sha": MERGE,
|
||||||
|
"superseding_head_sha": HEAD,
|
||||||
|
"target_branch": "master",
|
||||||
|
"target_branch_sha": TARGET,
|
||||||
|
"superseding_head_is_ancestor_of_target": True,
|
||||||
|
"canonical_comment_valid": True,
|
||||||
|
"canonical_comment_mentions_superseding_pr": True,
|
||||||
|
"canonical_comment_mentions_merge_commit": True,
|
||||||
|
"close_pr_capability": True,
|
||||||
|
"close_issue_capability": True,
|
||||||
|
"target_issue_state": "open",
|
||||||
|
"issue_satisfied_by_superseding": True,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return assess_reconciler_supersession_close_gate(**kwargs)
|
||||||
|
|
||||||
|
def test_supersession_close_allowed_with_full_proof(self):
|
||||||
|
result = self._gate()
|
||||||
|
self.assertEqual(result["outcome"], "SUPERSESSION_CLOSE_ALLOWED")
|
||||||
|
self.assertTrue(result["pr_close_allowed"])
|
||||||
|
self.assertTrue(result["issue_close_allowed"])
|
||||||
|
self.assertFalse(result["review_allowed"])
|
||||||
|
self.assertFalse(result["merge_allowed"])
|
||||||
|
|
||||||
|
def test_mergeable_target_still_blocks(self):
|
||||||
|
result = self._gate(target_pr_mergeable=True)
|
||||||
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||||
|
self.assertFalse(result["pr_close_allowed"])
|
||||||
|
self.assertTrue(any("mergeable" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_requires_canonical_comment_with_merge_commit(self):
|
||||||
|
result = self._gate(canonical_comment_mentions_merge_commit=False)
|
||||||
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||||
|
self.assertFalse(result["pr_close_allowed"])
|
||||||
|
self.assertTrue(any("merge commit" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_superseding_head_must_be_on_target_branch(self):
|
||||||
|
result = self._gate(superseding_head_is_ancestor_of_target=False)
|
||||||
|
self.assertEqual(result["outcome"], "SUPERSEDING_PR_NOT_ON_TARGET")
|
||||||
|
self.assertFalse(result["pr_close_allowed"])
|
||||||
|
|
||||||
|
def test_close_capability_required(self):
|
||||||
|
result = self._gate(close_pr_capability=False)
|
||||||
|
self.assertEqual(result["outcome"], "RECOVERY_HANDOFF_REQUIRED")
|
||||||
|
self.assertFalse(result["pr_close_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcilerSupersessionMcpTool(unittest.TestCase):
|
||||||
|
def _comment(self):
|
||||||
|
return f"""## Canonical PR State
|
||||||
|
STATE: superseded
|
||||||
|
NEXT_ACTION: Close superseded PR #490 and issue #470; PR #502 is canonical.
|
||||||
|
NEXT_ACTOR: prgs-reconciler
|
||||||
|
SUMMARY: PR #490 is superseded by merged PR #502 at merge commit {MERGE}.
|
||||||
|
CANONICAL_ITEM: PR #502
|
||||||
|
SUPERSEDED_ITEM: PR #490
|
||||||
|
CLOSE_OR_KEEP_OPEN: close superseded PR #490 and satisfied issue #470
|
||||||
|
"""
|
||||||
|
|
||||||
|
@patch("mcp_server.verify_preflight_purity")
|
||||||
|
@patch("mcp_server._canonical_comment_gate")
|
||||||
|
@patch("already_landed_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||||
|
@patch("already_landed_reconcile.fetch_target_branch")
|
||||||
|
@patch("mcp_server._profile_operation_gate", return_value=[])
|
||||||
|
@patch("mcp_server._auth", return_value="token test")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_tool_posts_comment_and_closes_superseded_pr_issue(
|
||||||
|
self,
|
||||||
|
api,
|
||||||
|
_auth,
|
||||||
|
_profile_gate,
|
||||||
|
fetch_target,
|
||||||
|
_ancestor,
|
||||||
|
canonical_gate,
|
||||||
|
verify_preflight,
|
||||||
|
):
|
||||||
|
fetch_target.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"target_branch": "master",
|
||||||
|
"target_ref": "prgs/master",
|
||||||
|
"target_branch_sha": TARGET,
|
||||||
|
}
|
||||||
|
canonical_gate.return_value = {
|
||||||
|
"blocked": False,
|
||||||
|
"canonical_comment_validation": {"valid": True},
|
||||||
|
}
|
||||||
|
api.side_effect = [
|
||||||
|
_target_pr(),
|
||||||
|
_superseding_pr(),
|
||||||
|
{"number": 470, "state": "open"},
|
||||||
|
{"id": 123},
|
||||||
|
{"state": "closed"},
|
||||||
|
{"state": "closed"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = mcp_server.gitea_reconcile_superseded_by_merged_pr(
|
||||||
|
target_pr_number=490,
|
||||||
|
superseding_pr_number=502,
|
||||||
|
target_issue_number=470,
|
||||||
|
target_independently_required=False,
|
||||||
|
issue_satisfied_by_superseding=True,
|
||||||
|
close_pr=True,
|
||||||
|
close_issue=True,
|
||||||
|
post_comment=True,
|
||||||
|
comment_body=self._comment(),
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["performed"])
|
||||||
|
self.assertTrue(result["pr_comment_posted"])
|
||||||
|
self.assertTrue(result["pr_closed"])
|
||||||
|
self.assertTrue(result["issue_closed"])
|
||||||
|
verify_preflight.assert_called_once_with(
|
||||||
|
"prgs", task="reconcile_close_superseded_pr")
|
||||||
|
|
||||||
|
@patch("mcp_server.verify_preflight_purity")
|
||||||
|
@patch("mcp_server._canonical_comment_gate")
|
||||||
|
@patch("already_landed_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||||
|
@patch("already_landed_reconcile.fetch_target_branch")
|
||||||
|
@patch("mcp_server._profile_operation_gate", return_value=[])
|
||||||
|
@patch("mcp_server._auth", return_value="token test")
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_tool_does_not_mutate_when_target_still_mergeable(
|
||||||
|
self,
|
||||||
|
api,
|
||||||
|
_auth,
|
||||||
|
_profile_gate,
|
||||||
|
fetch_target,
|
||||||
|
_ancestor,
|
||||||
|
canonical_gate,
|
||||||
|
verify_preflight,
|
||||||
|
):
|
||||||
|
fetch_target.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"target_branch": "master",
|
||||||
|
"target_ref": "prgs/master",
|
||||||
|
"target_branch_sha": TARGET,
|
||||||
|
}
|
||||||
|
canonical_gate.return_value = {
|
||||||
|
"blocked": False,
|
||||||
|
"canonical_comment_validation": {"valid": True},
|
||||||
|
}
|
||||||
|
api.side_effect = [_target_pr(mergeable=True), _superseding_pr()]
|
||||||
|
|
||||||
|
result = mcp_server.gitea_reconcile_superseded_by_merged_pr(
|
||||||
|
target_pr_number=490,
|
||||||
|
superseding_pr_number=502,
|
||||||
|
target_independently_required=False,
|
||||||
|
close_pr=True,
|
||||||
|
post_comment=True,
|
||||||
|
comment_body=self._comment(),
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertFalse(result["pr_closed"])
|
||||||
|
self.assertFalse(result["pr_comment_posted"])
|
||||||
|
verify_preflight.assert_not_called()
|
||||||
|
self.assertEqual(api.call_count, 2)
|
||||||
@@ -111,6 +111,23 @@ class TestAssessRemoteRepoMatch(unittest.TestCase):
|
|||||||
self.assertIn("Gitea-Tools", message)
|
self.assertIn("Gitea-Tools", message)
|
||||||
self.assertIn("org=", message)
|
self.assertIn("org=", message)
|
||||||
self.assertIn("repo=", message)
|
self.assertIn("repo=", message)
|
||||||
|
# Recovery text must name a tool that actually accepts org/repo (#530 follow-up).
|
||||||
|
self.assertIn("mcp_get_control_plane_guide", message)
|
||||||
|
self.assertIn("schema does not list", message)
|
||||||
|
|
||||||
|
def test_parse_org_repo_from_https_url(self):
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(LOCAL_GITEA_TOOLS_URL)
|
||||||
|
self.assertEqual(parsed, ("Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
|
||||||
|
def test_parse_org_repo_from_ssh_url(self):
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
|
||||||
|
"[email protected]:Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed, ("Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
|
||||||
|
def test_parse_org_repo_empty(self):
|
||||||
|
self.assertIsNone(remote_repo_guard.parse_org_repo_from_remote_url(None))
|
||||||
|
self.assertIsNone(remote_repo_guard.parse_org_repo_from_remote_url(""))
|
||||||
|
|
||||||
|
|
||||||
class TestResolveServerWiring(unittest.TestCase):
|
class TestResolveServerWiring(unittest.TestCase):
|
||||||
@@ -173,6 +190,82 @@ class TestResolveServerWiring(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
self.server.gitea_view_issue(issue_number=1, remote="prgs")
|
self.server.gitea_view_issue(issue_number=1, remote="prgs")
|
||||||
|
|
||||||
|
def test_control_plane_guide_accepts_explicit_gitea_tools(self):
|
||||||
|
"""Guide schema accepts org/repo and resolves Gitea-Tools, not Timesheet."""
|
||||||
|
self._set_prgs_default_repo("Timesheet")
|
||||||
|
with mock.patch.object(
|
||||||
|
self.server, "api_request", return_value={"login": "guide-bot"}
|
||||||
|
), mock.patch.object(
|
||||||
|
self.server, "get_auth_header", return_value="Basic dGVzdA=="
|
||||||
|
), mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
guide = self.server.mcp_get_control_plane_guide(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(guide["read_only"])
|
||||||
|
self.assertEqual(
|
||||||
|
guide["resolved_repository"],
|
||||||
|
{"org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
|
||||||
|
)
|
||||||
|
self.assertNotEqual(guide["resolved_repository"]["repo"], "Timesheet")
|
||||||
|
|
||||||
|
def test_control_plane_guide_bare_remote_aligns_to_local_gitea_tools(self):
|
||||||
|
"""Bare remote=prgs in a Gitea-Tools worktree must not stay on Timesheet."""
|
||||||
|
self._set_prgs_default_repo("Timesheet")
|
||||||
|
with mock.patch.object(
|
||||||
|
self.server, "api_request", return_value={"login": "guide-bot"}
|
||||||
|
), mock.patch.object(
|
||||||
|
self.server, "get_auth_header", return_value="Basic dGVzdA=="
|
||||||
|
), mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
guide = self.server.mcp_get_control_plane_guide(remote="prgs")
|
||||||
|
self.assertEqual(guide["resolved_repository"]["repo"], "Gitea-Tools")
|
||||||
|
self.assertEqual(
|
||||||
|
guide["resolved_repository"]["org"], "Scaled-Tech-Consulting"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_control_plane_guide_wrong_default_fails_closed_without_local(self):
|
||||||
|
"""Without local remote context, mismatched defaults still fail closed."""
|
||||||
|
self._set_prgs_default_repo("Timesheet")
|
||||||
|
with mock.patch.object(
|
||||||
|
self.server, "_local_git_remote_url", return_value=None
|
||||||
|
):
|
||||||
|
# No local URL → preference cannot recover; bare resolve uses Timesheet
|
||||||
|
# and guard is skipped only because local URL is missing (best-effort).
|
||||||
|
host, org, repo = self.server._resolve_control_plane_guide_target(
|
||||||
|
"prgs", None, None, None
|
||||||
|
)
|
||||||
|
self.assertEqual(repo, "Timesheet")
|
||||||
|
|
||||||
|
def test_control_plane_guide_schema_recovery_matches_parameters(self):
|
||||||
|
"""Remediation must only recommend parameters the guide actually accepts."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(self.server.mcp_get_control_plane_guide)
|
||||||
|
self.assertIn("org", sig.parameters)
|
||||||
|
self.assertIn("repo", sig.parameters)
|
||||||
|
self.assertIn("remote", sig.parameters)
|
||||||
|
remediation = remote_repo_guard.REMEDIATION
|
||||||
|
self.assertIn("mcp_get_control_plane_guide", remediation)
|
||||||
|
self.assertIn("org=", remediation)
|
||||||
|
self.assertIn("repo=", remediation)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
"GITEA_TOKEN_MERGER": "merger-pass",
|
"GITEA_TOKEN_MERGER": "merger-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_diagnose_terminal_success_has_no_error(self):
|
||||||
|
res = mcp_server.gitea_diagnose_terminal(
|
||||||
|
command=[sys.executable, "-c", "raise SystemExit(0)"]
|
||||||
|
)
|
||||||
|
self.assertTrue(res["healthy"])
|
||||||
|
self.assertIsNone(res["error_type"])
|
||||||
|
self.assertEqual(res["error_msg"], "")
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_resolve_author_capable_task(self, _auth, _api):
|
def test_resolve_author_capable_task(self, _auth, _api):
|
||||||
@@ -110,6 +118,39 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertFalse(res["different_mcp_namespace_required"])
|
self.assertFalse(res["different_mcp_namespace_required"])
|
||||||
self.assertIn("ready for operations", res["exact_safe_next_action"])
|
self.assertIn("ready for operations", res["exact_safe_next_action"])
|
||||||
|
|
||||||
|
@patch("mcp_server.native_mcp_preference.probe_terminal_spawn")
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
def test_resolve_mutation_task_blocks_when_terminal_launcher_unhealthy(
|
||||||
|
self,
|
||||||
|
_auth,
|
||||||
|
_api,
|
||||||
|
mock_probe,
|
||||||
|
):
|
||||||
|
mock_probe.return_value = {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "missing cwd",
|
||||||
|
"error_msg": "Current working directory does not exist: /missing",
|
||||||
|
"cwd": "/missing",
|
||||||
|
"command": ["git", "--version"],
|
||||||
|
"diagnostics": {"cwd_exists": False},
|
||||||
|
}
|
||||||
|
env = self._env("author-profile")
|
||||||
|
# Probe is skipped under pytest unless forced (mirrors production path).
|
||||||
|
env["GITEA_FORCE_TERMINAL_PROBE"] = "1"
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
||||||
|
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
self.assertTrue(res["infra_stop"])
|
||||||
|
self.assertTrue(res["terminal_launcher_unhealthy"])
|
||||||
|
self.assertTrue(res["blocked"])
|
||||||
|
self.assertEqual(res["blocked_reason"], "BLOCKED + DIAGNOSE")
|
||||||
|
self.assertEqual(res["terminal_launcher_diagnostics"]["error_type"], "missing cwd")
|
||||||
|
self.assertIn("terminal-launcher-failure", res["exact_safe_next_action"])
|
||||||
|
self.assertIn("BLOCKED + DIAGNOSE", res["exact_safe_next_action"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_resolve_reviewer_capable_task_blocked(self, _auth, _api):
|
def test_resolve_reviewer_capable_task_blocked(self, _auth, _api):
|
||||||
@@ -246,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])
|
||||||
@@ -98,6 +98,45 @@ class TestReviewerLeaseFreshness(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerLeaseNewestWins(unittest.TestCase):
|
||||||
|
"""#577: terminal release must supersede older claimed markers."""
|
||||||
|
|
||||||
|
def test_released_after_claimed_has_no_active_lease(self):
|
||||||
|
claim = _lease_comment(516, "session-a", phase="claimed")
|
||||||
|
claim["id"] = 1
|
||||||
|
release = _lease_comment(516, "session-a", phase="released")
|
||||||
|
release["id"] = 2
|
||||||
|
active = leases.find_active_reviewer_lease(
|
||||||
|
[claim, release], pr_number=516
|
||||||
|
)
|
||||||
|
self.assertIsNone(active)
|
||||||
|
|
||||||
|
def test_new_claim_after_release_is_active(self):
|
||||||
|
claim = _lease_comment(516, "session-a", phase="claimed")
|
||||||
|
claim["id"] = 1
|
||||||
|
release = _lease_comment(516, "session-a", phase="released")
|
||||||
|
release["id"] = 2
|
||||||
|
reclaim = _lease_comment(516, "session-b", phase="claimed")
|
||||||
|
reclaim["id"] = 3
|
||||||
|
active = leases.find_active_reviewer_lease(
|
||||||
|
[claim, release, reclaim], pr_number=516
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(active)
|
||||||
|
self.assertEqual(active.get("session_id"), "session-b")
|
||||||
|
self.assertEqual(active.get("phase"), "claimed")
|
||||||
|
self.assertEqual(active.get("comment_id"), 3)
|
||||||
|
|
||||||
|
def test_done_after_claimed_has_no_active_lease(self):
|
||||||
|
claim = _lease_comment(516, "session-a", phase="claimed")
|
||||||
|
claim["id"] = 10
|
||||||
|
done = _lease_comment(516, "session-a", phase="done")
|
||||||
|
done["id"] = 11
|
||||||
|
active = leases.find_active_reviewer_lease(
|
||||||
|
[claim, done], pr_number=516
|
||||||
|
)
|
||||||
|
self.assertIsNone(active)
|
||||||
|
|
||||||
|
|
||||||
class TestReviewerLeaseMutationGate(unittest.TestCase):
|
class TestReviewerLeaseMutationGate(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
leases.clear_session_lease()
|
leases.clear_session_lease()
|
||||||
@@ -198,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):
|
||||||
@@ -334,4 +374,4 @@ class TestCheckMidMerge(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Regression: durable session-state isolation survives env clears (#590)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_session_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_state_dir_survives_env_clear():
|
||||||
|
"""conftest pins default_state_dir so clear=True cannot hit host cache."""
|
||||||
|
isolated = os.environ.get("GITEA_MCP_SESSION_STATE_DIR")
|
||||||
|
assert isolated, "conftest must set GITEA_MCP_SESSION_STATE_DIR"
|
||||||
|
host_cache = Path.home() / ".cache" / "gitea-tools" / "session-state"
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
assert os.environ.get("GITEA_MCP_SESSION_STATE_DIR") is None
|
||||||
|
resolved = mcp_session_state.default_state_dir()
|
||||||
|
assert resolved == isolated
|
||||||
|
assert Path(resolved).resolve() != host_cache.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_lock_save_load_stays_in_isolated_dir():
|
||||||
|
"""Review-mutation ledger files land only under the per-test state dir."""
|
||||||
|
isolated = os.environ.get("GITEA_MCP_SESSION_STATE_DIR")
|
||||||
|
assert isolated
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
saved = mcp_session_state.save_state(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="gitea-default",
|
||||||
|
payload={
|
||||||
|
"live_mutations": [
|
||||||
|
{"pr_number": 1, "action": "request_changes"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert saved is not None
|
||||||
|
path = mcp_session_state.state_file_path(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="gitea-default",
|
||||||
|
)
|
||||||
|
assert path.startswith(isolated + os.sep) or path.startswith(
|
||||||
|
isolated + "/"
|
||||||
|
)
|
||||||
|
loaded = mcp_session_state.load_state(
|
||||||
|
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="gitea-default",
|
||||||
|
)
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.get("live_mutations")
|
||||||
|
host_path = (
|
||||||
|
Path.home()
|
||||||
|
/ ".cache"
|
||||||
|
/ "gitea-tools"
|
||||||
|
/ "session-state"
|
||||||
|
/ "review_decision_lock-gitea-default.json"
|
||||||
|
)
|
||||||
|
# Host file may exist from operator use; this write must not refresh it.
|
||||||
|
# Prove our isolated file is distinct and present.
|
||||||
|
assert Path(path).is_file()
|
||||||
|
assert Path(path).resolve() != host_path.resolve()
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""Stale #332 review-decision lock cleanup (#594).
|
||||||
|
|
||||||
|
Proves:
|
||||||
|
a. active terminal locks still block unrelated terminal reviews
|
||||||
|
b. merged/closed PR locks can be cleared canonically
|
||||||
|
c. cleanup cannot bypass review gates on open PRs
|
||||||
|
d. after moot cleanup, mark_ready for a different PR can proceed
|
||||||
|
(PR #592-style after PR #586-style stale approve)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
from mcp_server import (
|
||||||
|
gitea_cleanup_stale_review_decision_lock,
|
||||||
|
gitea_mark_final_review_decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
HEAD = "a" * 40
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
|
REVIEWER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.read,gitea.pr.review,gitea.pr.approve,"
|
||||||
|
"gitea.pr.request_changes,gitea.pr.comment"
|
||||||
|
),
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
REVIEWER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_profile_patch():
|
||||||
|
return patch.object(mcp_server, "get_profile", return_value=REVIEWER_PROFILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, correction=False):
|
||||||
|
return {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": None,
|
||||||
|
"ready_action": None,
|
||||||
|
"ready_expected_head_sha": None,
|
||||||
|
"ready_remote": None,
|
||||||
|
"ready_org": None,
|
||||||
|
"ready_repo": None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": correction,
|
||||||
|
"correction_reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
APPROVED_586 = {
|
||||||
|
"pr_number": 586,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": 395,
|
||||||
|
"review_state": "approve",
|
||||||
|
}
|
||||||
|
APPROVED_OPEN = {
|
||||||
|
"pr_number": 700,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": 1,
|
||||||
|
"review_state": "approve",
|
||||||
|
}
|
||||||
|
RC_OPEN = {
|
||||||
|
"pr_number": 701,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 2,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(mutations=None, correction=False):
|
||||||
|
import review_workflow_load
|
||||||
|
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server._save_review_decision_lock(_lock(mutations, correction))
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_pr(pr_number=586, sha="6913ac9" + "0" * 33):
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "closed",
|
||||||
|
"merged": True,
|
||||||
|
"merged_at": "2026-07-09T18:20:03Z",
|
||||||
|
"merge_commit_sha": sha,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(pr_number=700):
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"merged_at": None,
|
||||||
|
"merge_commit_sha": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Pure assessment
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestAssessStaleLock(unittest.TestCase):
|
||||||
|
def test_no_lock(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(None)
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertFalse(a["is_moot"])
|
||||||
|
|
||||||
|
def test_no_terminal_mutations(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(_lock([]))
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_open_pr_not_cleanable(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
_lock([APPROVED_OPEN]), pr_live=_open_pr(700)
|
||||||
|
)
|
||||||
|
self.assertFalse(a["is_moot"])
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("open PR" in r for r in a["reasons"]))
|
||||||
|
|
||||||
|
def test_merged_pr_is_moot_and_cleanable(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
_lock([APPROVED_586]), pr_live=_merged_pr(586)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["is_moot"])
|
||||||
|
self.assertTrue(a["cleanup_allowed"])
|
||||||
|
self.assertEqual(a["last_terminal_pr"], 586)
|
||||||
|
|
||||||
|
def test_closed_unmerged_is_moot(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
_lock([RC_OPEN]),
|
||||||
|
pr_live={"number": 701, "state": "closed", "merged": False},
|
||||||
|
)
|
||||||
|
self.assertTrue(a["is_moot"])
|
||||||
|
self.assertTrue(a["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_profile_mismatch_blocks(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
_lock([APPROVED_586]),
|
||||||
|
pr_live=_merged_pr(586),
|
||||||
|
active_profile_identity="other-profile",
|
||||||
|
)
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertFalse(a["profile_match"])
|
||||||
|
|
||||||
|
def test_lookup_error_fail_closed(self):
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
_lock([APPROVED_586]),
|
||||||
|
pr_lookup_error="timeout",
|
||||||
|
)
|
||||||
|
self.assertFalse(a["cleanup_allowed"])
|
||||||
|
self.assertTrue(any("timeout" in r for r in a["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Hard-stop still blocks active locks
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestActiveHardStopPreserved(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_open_approve_still_blocks_other_pr_mark_ready(self):
|
||||||
|
_seed([APPROVED_OPEN])
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
self.assertIn("#332", reasons[0])
|
||||||
|
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
|
||||||
|
|
||||||
|
def test_request_changes_still_blocks_everything(self):
|
||||||
|
_seed([RC_OPEN])
|
||||||
|
for op in ("merge", "mark_ready", "review"):
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
||||||
|
msg=op,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MCP cleanup tool
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class TestCleanupTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
|
||||||
|
self._env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._env.stop()
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_read_only_reports_moot_without_clearing(self):
|
||||||
|
_seed([APPROVED_586])
|
||||||
|
with patch.object(mcp_server, "api_request", return_value=_merged_pr()), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), \
|
||||||
|
_reviewer_profile_patch(), \
|
||||||
|
patch.object(mcp_server, "_profile_operation_gate", return_value=[]):
|
||||||
|
r = gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=False, remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(r["success"])
|
||||||
|
self.assertTrue(r["is_moot"])
|
||||||
|
self.assertTrue(r["cleanup_allowed"])
|
||||||
|
self.assertFalse(r["cleanup_performed"])
|
||||||
|
self.assertIsNotNone(mcp_server._load_review_decision_lock())
|
||||||
|
|
||||||
|
def test_apply_clears_moot_lock(self):
|
||||||
|
_seed([APPROVED_586])
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
def _api(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/pulls/586" in url:
|
||||||
|
return _merged_pr()
|
||||||
|
if method == "POST" and "/comments" in url:
|
||||||
|
posts.append(payload)
|
||||||
|
return {"id": 9999}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "api_request", side_effect=_api), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), \
|
||||||
|
_reviewer_profile_patch(), \
|
||||||
|
patch.object(mcp_server, "_profile_operation_gate", return_value=[]), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_audited",
|
||||||
|
side_effect=lambda *a, **k: __import__(
|
||||||
|
"contextlib"
|
||||||
|
).nullcontext(),
|
||||||
|
):
|
||||||
|
r = gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=True,
|
||||||
|
expected_terminal_pr=586,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(r["cleanup_performed"], r)
|
||||||
|
self.assertTrue(r["audit"]["applied"])
|
||||||
|
self.assertIsNone(mcp_server._load_review_decision_lock())
|
||||||
|
self.assertEqual(r.get("audit_comment_id"), 9999)
|
||||||
|
self.assertTrue(posts)
|
||||||
|
|
||||||
|
def test_apply_refuses_open_pr(self):
|
||||||
|
_seed([APPROVED_OPEN])
|
||||||
|
with patch.object(mcp_server, "api_request", return_value=_open_pr(700)), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), \
|
||||||
|
_reviewer_profile_patch(), \
|
||||||
|
patch.object(mcp_server, "_profile_operation_gate", return_value=[]):
|
||||||
|
r = gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=True, remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(r["cleanup_performed"])
|
||||||
|
self.assertFalse(r["cleanup_allowed"])
|
||||||
|
self.assertIsNotNone(mcp_server._load_review_decision_lock())
|
||||||
|
|
||||||
|
def test_expected_terminal_pr_pin(self):
|
||||||
|
_seed([APPROVED_586])
|
||||||
|
with patch.object(mcp_server, "api_request", return_value=_merged_pr()), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), \
|
||||||
|
_reviewer_profile_patch(), \
|
||||||
|
patch.object(mcp_server, "_profile_operation_gate", return_value=[]):
|
||||||
|
r = gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=True,
|
||||||
|
expected_terminal_pr=999,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(r["cleanup_performed"])
|
||||||
|
self.assertTrue(any("expected_terminal_pr" in x for x in r["reasons"]))
|
||||||
|
|
||||||
|
def test_after_moot_cleanup_other_pr_mark_ready_unblocked(self):
|
||||||
|
"""PR #592-style: after clearing merged #586 lock, new mark_ready works."""
|
||||||
|
_seed([APPROVED_586])
|
||||||
|
with patch.object(mcp_server, "api_request", return_value=_merged_pr()), \
|
||||||
|
patch.object(mcp_server, "_auth", return_value=FAKE_AUTH), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), \
|
||||||
|
_reviewer_profile_patch(), \
|
||||||
|
patch.object(mcp_server, "_profile_operation_gate", return_value=[]), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_audited",
|
||||||
|
side_effect=lambda *a, **k: __import__(
|
||||||
|
"contextlib"
|
||||||
|
).nullcontext(),
|
||||||
|
):
|
||||||
|
cleaned = gitea_cleanup_stale_review_decision_lock(
|
||||||
|
apply=True,
|
||||||
|
expected_terminal_pr=586,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(cleaned["cleanup_performed"], cleaned)
|
||||||
|
|
||||||
|
# Hard-stop gone; re-init clean lock like a fresh review_pr resolve.
|
||||||
|
mcp_server.init_review_decision_lock(remote="prgs", task="review_pr")
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
with patch.object(mcp_server, "_list_pr_lease_comments", return_value=[]), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_pr_work_lease_reviewer_block", return_value=no_lease
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={
|
||||||
|
"eligible": True,
|
||||||
|
"reasons": [],
|
||||||
|
"authenticated_user": "sysadmin",
|
||||||
|
"pr_author": "jcwalker3",
|
||||||
|
"self_author": False,
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="sysadmin"
|
||||||
|
):
|
||||||
|
marked = gitea_mark_final_review_decision(
|
||||||
|
pr_number=592,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(marked.get("marked_ready"), marked)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for web UI CI path-filter gate (#436)."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from webui.ci_paths import should_run_webui_ci, touches_webui_surface
|
||||||
|
from webui.lease_loader import load_lease_snapshot
|
||||||
|
from webui.queue_loader import load_queue_snapshot
|
||||||
|
from webui.runtime_health import load_runtime_snapshot
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiPathMatching(unittest.TestCase):
|
||||||
|
def test_positive_paths(self):
|
||||||
|
for path in (
|
||||||
|
"webui/app.py",
|
||||||
|
"tests/test_webui_skeleton.py",
|
||||||
|
"docs/webui-local-dev.md",
|
||||||
|
"scripts/test-webui",
|
||||||
|
):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertTrue(touches_webui_surface(path))
|
||||||
|
|
||||||
|
def test_negative_paths(self):
|
||||||
|
for path in ("mcp_server.py", "tests/test_mcp_server.py", "README.md"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertFalse(touches_webui_surface(path))
|
||||||
|
|
||||||
|
def test_should_run_aggregate(self):
|
||||||
|
self.assertTrue(should_run_webui_ci(["README.md", "webui/layout.py"]))
|
||||||
|
self.assertFalse(should_run_webui_ci(["mcp_server.py", "gitea_auth.py"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiRunnerScript(unittest.TestCase):
|
||||||
|
def test_test_webui_smoke(self):
|
||||||
|
script = _REPO_ROOT / "scripts" / "test-webui"
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(script), "-q"],
|
||||||
|
cwd=_REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"WEBUI_TEST_PATTERN": "test_webui_skeleton.py",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOfflineWebuiCiMode(unittest.TestCase):
|
||||||
|
def test_offline_mode_avoids_live_queue_auth(self):
|
||||||
|
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.queue_loader.get_auth_header",
|
||||||
|
side_effect=AssertionError("live auth should not run"),
|
||||||
|
):
|
||||||
|
snapshot = load_queue_snapshot()
|
||||||
|
self.assertIsNone(snapshot.fetch_error)
|
||||||
|
self.assertEqual(snapshot.prs, ())
|
||||||
|
self.assertEqual(snapshot.issues, ())
|
||||||
|
|
||||||
|
def test_offline_mode_avoids_live_lease_auth(self):
|
||||||
|
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.lease_loader.get_auth_header",
|
||||||
|
side_effect=AssertionError("live auth should not run"),
|
||||||
|
):
|
||||||
|
snapshot = load_lease_snapshot()
|
||||||
|
self.assertIsNone(snapshot.fetch_error)
|
||||||
|
self.assertEqual(snapshot.reviewer_leases, ())
|
||||||
|
|
||||||
|
def test_offline_mode_avoids_live_runtime_identity(self):
|
||||||
|
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": "1"}):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.runtime_health.get_auth_header",
|
||||||
|
side_effect=AssertionError("live auth should not run"),
|
||||||
|
):
|
||||||
|
snapshot = load_runtime_snapshot()
|
||||||
|
self.assertEqual(snapshot.identity_error, "offline web UI test mode")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Tests for web UI deployment and auth boundary (#435)."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.deployment_boundary import (
|
||||||
|
assess_bind_host,
|
||||||
|
runtime_assumptions,
|
||||||
|
scan_text_for_client_secrets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBindAssessment(unittest.TestCase):
|
||||||
|
def test_loopback_is_safe(self):
|
||||||
|
for host in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
with self.subTest(host=host):
|
||||||
|
result = assess_bind_host(host)
|
||||||
|
self.assertEqual(result.disposition, "safe")
|
||||||
|
|
||||||
|
def test_all_interfaces_refused_by_default(self):
|
||||||
|
for host in ("0.0.0.0", "::", "*"):
|
||||||
|
with self.subTest(host=host):
|
||||||
|
result = assess_bind_host(host)
|
||||||
|
self.assertEqual(result.disposition, "refuse")
|
||||||
|
|
||||||
|
def test_all_interfaces_allowed_with_override(self):
|
||||||
|
prior = os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
||||||
|
try:
|
||||||
|
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = "1"
|
||||||
|
result = assess_bind_host("0.0.0.0")
|
||||||
|
self.assertEqual(result.disposition, "warn")
|
||||||
|
finally:
|
||||||
|
os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
||||||
|
if prior is not None:
|
||||||
|
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = prior
|
||||||
|
|
||||||
|
def test_non_loopback_warns_without_override(self):
|
||||||
|
prior = os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
|
||||||
|
try:
|
||||||
|
os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
|
||||||
|
result = assess_bind_host("192.168.1.10")
|
||||||
|
self.assertEqual(result.disposition, "warn")
|
||||||
|
finally:
|
||||||
|
if prior is not None:
|
||||||
|
os.environ["WEBUI_ALLOW_REMOTE_BIND"] = prior
|
||||||
|
|
||||||
|
def test_runtime_assumptions_exclude_secrets(self):
|
||||||
|
assumptions = runtime_assumptions()
|
||||||
|
blob = " ".join(str(v) for v in assumptions.values())
|
||||||
|
self.assertNotIn("GITEA_TOKEN", blob)
|
||||||
|
self.assertIn("internal-operator-console", assumptions["deployment_mode"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeploymentRoutes(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app(bind_host="127.0.0.1"))
|
||||||
|
|
||||||
|
def test_health_includes_deployment_metadata(self):
|
||||||
|
response = self.client.get("/health")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("deployment", data)
|
||||||
|
deployment = data["deployment"]
|
||||||
|
self.assertEqual(deployment["mode"], "internal-operator-console")
|
||||||
|
self.assertEqual(deployment["mvp_auth"], "none")
|
||||||
|
self.assertEqual(deployment["bind"]["disposition"], "safe")
|
||||||
|
self.assertIn("runtime_assumptions", deployment)
|
||||||
|
|
||||||
|
def test_rendered_pages_contain_no_embedded_secrets(self):
|
||||||
|
for path in ("/", "/projects", "/prompts", "/queue"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
text = self.client.get(path).text
|
||||||
|
self.assertEqual(scan_text_for_client_secrets(text), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartupRefusal(unittest.TestCase):
|
||||||
|
def test_refuses_all_interface_bind(self):
|
||||||
|
env = {**os.environ, "WEBUI_HOST": "0.0.0.0"}
|
||||||
|
env.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
||||||
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "webui"],
|
||||||
|
cwd=repo_root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientSecretScanner(unittest.TestCase):
|
||||||
|
def test_detects_token_like_content(self):
|
||||||
|
findings = scan_text_for_client_secrets("var x = GITEA_TOKEN_PRGS")
|
||||||
|
self.assertTrue(findings)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Tests for web UI gated action framework (#434)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from task_capability_map import TASK_CAPABILITY_MAP
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.gated_actions import (
|
||||||
|
attempt_action,
|
||||||
|
build_action_registry,
|
||||||
|
load_action_registry,
|
||||||
|
preview_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatedActionRegistry(unittest.TestCase):
|
||||||
|
def test_all_actions_disabled_by_default(self):
|
||||||
|
registry = build_action_registry()
|
||||||
|
self.assertGreater(len(registry.actions), 0)
|
||||||
|
for action in registry.actions:
|
||||||
|
with self.subTest(action=action.action_id):
|
||||||
|
self.assertFalse(action.enabled)
|
||||||
|
|
||||||
|
def test_actions_align_with_task_capability_map(self):
|
||||||
|
registry = build_action_registry()
|
||||||
|
for action in registry.actions:
|
||||||
|
with self.subTest(task=action.task_key):
|
||||||
|
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
|
||||||
|
self.assertEqual(
|
||||||
|
action.required_permission,
|
||||||
|
TASK_CAPABILITY_MAP[action.task_key]["permission"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
action.required_role,
|
||||||
|
TASK_CAPABILITY_MAP[action.task_key]["role"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_includes_mutation_ledger(self):
|
||||||
|
preview = preview_action("merge_pr", pr_number=42)
|
||||||
|
self.assertNotIn("error", preview)
|
||||||
|
ledger = preview["mutation_ledger"]
|
||||||
|
self.assertEqual(len(ledger), 1)
|
||||||
|
self.assertEqual(ledger[0]["mcp_tool"], "gitea_merge_pr")
|
||||||
|
self.assertEqual(ledger[0]["permission"], "gitea.pr.merge")
|
||||||
|
self.assertIn("42", ledger[0]["target"])
|
||||||
|
|
||||||
|
def test_attempt_fails_closed_without_mcp_calls(self):
|
||||||
|
registry = build_action_registry()
|
||||||
|
with patch("webui.gated_actions._MVP_ACTIONS_ENABLED", False):
|
||||||
|
for action in registry.actions:
|
||||||
|
with self.subTest(action=action.action_id):
|
||||||
|
result = attempt_action(action.action_id, issue_number=1)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error"], "action_disabled")
|
||||||
|
self.assertIn("preview", result)
|
||||||
|
|
||||||
|
def test_attempt_module_has_no_mcp_imports(self):
|
||||||
|
"""Ungated execution path must not import MCP server tools."""
|
||||||
|
import webui.gated_actions as ga
|
||||||
|
|
||||||
|
source_path = Path(ga.__file__).resolve()
|
||||||
|
source = source_path.read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("mcp_server", source)
|
||||||
|
result = attempt_action("merge_pr", pr_number=99)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
|
||||||
|
def test_unknown_action_fails_closed(self):
|
||||||
|
result = attempt_action("nonexistent_action")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error"], "unknown_action")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatedActionRoutes(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_actions_page_renders_disabled_buttons(self):
|
||||||
|
response = self.client.get("/actions")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("Gated actions", response.text)
|
||||||
|
self.assertIn("disabled", response.text.lower())
|
||||||
|
self.assertIn("mutation ledger", response.text.lower())
|
||||||
|
self.assertIn("aria-disabled", response.text)
|
||||||
|
self.assertIn(" disabled", response.text)
|
||||||
|
|
||||||
|
def test_api_actions_registry(self):
|
||||||
|
response = self.client.get("/api/actions")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertFalse(data["mvp_actions_enabled"])
|
||||||
|
action_ids = {item["action_id"] for item in data["actions"]}
|
||||||
|
self.assertIn("merge_pr", action_ids)
|
||||||
|
self.assertIn("claim_issue", action_ids)
|
||||||
|
for item in data["actions"]:
|
||||||
|
self.assertFalse(item["enabled"])
|
||||||
|
|
||||||
|
def test_api_action_preview_json(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/actions/review_pr/preview?pr_number=12"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["task_key"], "review_pr")
|
||||||
|
self.assertEqual(data["required_role"], "reviewer")
|
||||||
|
self.assertEqual(len(data["mutation_ledger"]), 1)
|
||||||
|
|
||||||
|
def test_post_attempt_fails_closed(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/actions/merge_pr/attempt",
|
||||||
|
json={"pr_number": 1},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
data = response.json()
|
||||||
|
self.assertFalse(data["success"])
|
||||||
|
self.assertEqual(data["error"], "action_disabled")
|
||||||
|
|
||||||
|
def test_nav_includes_actions_link(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
self.assertIn('href="/actions"', text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Consolidated MVP coverage for the internal web UI (#436)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from webui.app import create_app
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# HTML pages and JSON exports registered on master MVP.
|
||||||
|
MVP_GET_ROUTES: tuple[tuple[str, str | tuple[str, ...]], ...] = (
|
||||||
|
("/", "Operator console"),
|
||||||
|
("/health", "mcp-control-plane-webui"),
|
||||||
|
("/queue", "Live queue"),
|
||||||
|
("/api/queue", ("prs", "issues")),
|
||||||
|
("/projects", "Gitea-Tools"),
|
||||||
|
("/api/projects", "projects"),
|
||||||
|
("/prompts", "Prompt library"),
|
||||||
|
("/api/prompts", "prompts"),
|
||||||
|
("/runtime", "Runtime"),
|
||||||
|
("/audit", "Audit"),
|
||||||
|
("/worktrees", "Worktrees"),
|
||||||
|
("/leases", "Leases"),
|
||||||
|
)
|
||||||
|
|
||||||
|
MUTATION_METHODS = ("POST", "PUT", "PATCH", "DELETE")
|
||||||
|
|
||||||
|
|
||||||
|
def _import_optional(module_name: str):
|
||||||
|
try:
|
||||||
|
return importlib.import_module(module_name)
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiServerStartup(unittest.TestCase):
|
||||||
|
def test_create_app_imports_cleanly(self):
|
||||||
|
app = create_app()
|
||||||
|
self.assertIsNotNone(app)
|
||||||
|
|
||||||
|
def test_main_module_imports(self):
|
||||||
|
import webui.__main__ as main_mod
|
||||||
|
|
||||||
|
self.assertTrue(callable(main_mod.main))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiRouteRendering(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_all_mvp_get_routes_render(self):
|
||||||
|
for path, needle in MVP_GET_ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEqual(response.status_code, 200, path)
|
||||||
|
if path == "/health":
|
||||||
|
assert isinstance(needle, str)
|
||||||
|
self.assertEqual(response.json()["service"], needle)
|
||||||
|
elif path.startswith("/api/"):
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(needle, tuple):
|
||||||
|
for key in needle:
|
||||||
|
self.assertIn(key, data)
|
||||||
|
else:
|
||||||
|
self.assertIn(needle, data)
|
||||||
|
else:
|
||||||
|
assert isinstance(needle, str)
|
||||||
|
self.assertIn(needle, response.text)
|
||||||
|
|
||||||
|
def test_registered_routes_include_mvp_paths(self):
|
||||||
|
app = create_app()
|
||||||
|
get_paths = {
|
||||||
|
route.path
|
||||||
|
for route in app.routes
|
||||||
|
if isinstance(route, Route) and "GET" in route.methods
|
||||||
|
}
|
||||||
|
for path, _ in MVP_GET_ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertIn(path, get_paths)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiReadOnlyGuards(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_mutation_methods_rejected_on_core_paths(self):
|
||||||
|
paths = ("/health", "/queue", "/projects", "/prompts", "/api/queue")
|
||||||
|
for path in paths:
|
||||||
|
for method in MUTATION_METHODS:
|
||||||
|
with self.subTest(path=path, method=method):
|
||||||
|
response = self.client.request(method, path)
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiOptionalModules(unittest.TestCase):
|
||||||
|
"""Exercise child-issue modules when present on the branch under test."""
|
||||||
|
|
||||||
|
def test_audit_validator_basics_when_present(self):
|
||||||
|
mod = _import_optional("webui.audit_validator")
|
||||||
|
if mod is None:
|
||||||
|
self.skipTest("webui.audit_validator not merged on this branch")
|
||||||
|
report = "## Controller Handoff\n- Task: review PR #1\n"
|
||||||
|
self.assertEqual(mod.infer_task_kind(report), "review_pr")
|
||||||
|
result = mod.audit_report(report)
|
||||||
|
self.assertIn("grade", result)
|
||||||
|
|
||||||
|
def test_gated_actions_fail_closed_when_present(self):
|
||||||
|
mod = _import_optional("webui.gated_actions")
|
||||||
|
if mod is None:
|
||||||
|
self.skipTest("webui.gated_actions not merged on this branch")
|
||||||
|
registry = mod.build_action_registry()
|
||||||
|
for action in registry.actions:
|
||||||
|
with self.subTest(action=action.action_id):
|
||||||
|
self.assertFalse(action.enabled)
|
||||||
|
result = mod.attempt_action("merge_pr", pr_number=1)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
|
||||||
|
def test_deployment_boundary_when_present(self):
|
||||||
|
mod = _import_optional("webui.deployment_boundary")
|
||||||
|
if mod is None:
|
||||||
|
self.skipTest("webui.deployment_boundary not merged on this branch")
|
||||||
|
assessment = mod.assess_bind_host("0.0.0.0")
|
||||||
|
self.assertEqual(assessment.disposition, "refuse")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiTestInventory(unittest.TestCase):
|
||||||
|
def test_webui_test_modules_exist(self):
|
||||||
|
modules = sorted(_REPO_ROOT.glob("tests/test_webui_*.py"))
|
||||||
|
names = [path.name for path in modules]
|
||||||
|
self.assertIn("test_webui_skeleton.py", names)
|
||||||
|
self.assertIn("test_webui_project_registry.py", names)
|
||||||
|
self.assertIn("test_webui_prompt_library.py", names)
|
||||||
|
self.assertIn("test_webui_queue_dashboard.py", names)
|
||||||
|
self.assertGreaterEqual(len(names), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebuiCiScripts(unittest.TestCase):
|
||||||
|
def test_runner_scripts_exist(self):
|
||||||
|
for name in ("test-webui", "ci-webui-check"):
|
||||||
|
script = _REPO_ROOT / "scripts" / name
|
||||||
|
self.assertTrue(script.is_file(), name)
|
||||||
|
self.assertTrue(os.access(script, os.X_OK), name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -206,8 +206,9 @@ class TestQueueRoutes(unittest.TestCase):
|
|||||||
self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
|
self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
|
||||||
|
|
||||||
def test_queue_fail_closed_without_credentials(self):
|
def test_queue_fail_closed_without_credentials(self):
|
||||||
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
|
with mock.patch.dict("os.environ", {"WEBUI_TEST_OFFLINE": "0"}):
|
||||||
snapshot = load_queue_snapshot()
|
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
|
||||||
|
snapshot = load_queue_snapshot()
|
||||||
self.assertIsNotNone(snapshot.fetch_error)
|
self.assertIsNotNone(snapshot.fetch_error)
|
||||||
self.assertEqual(len(snapshot.prs), 0)
|
self.assertEqual(len(snapshot.prs), 0)
|
||||||
|
|
||||||
@@ -285,4 +286,4 @@ class TestQueueFailClosedUx(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class TestWebuiSkeleton(unittest.TestCase):
|
|||||||
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
||||||
self.assertEqual(data["mode"], "read-only-mvp")
|
self.assertEqual(data["mode"], "read-only-mvp")
|
||||||
self.assertIn("timestamp", data)
|
self.assertIn("timestamp", data)
|
||||||
|
self.assertIn("deployment", data)
|
||||||
|
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
|
||||||
|
|
||||||
def test_home_renders(self):
|
def test_home_renders(self):
|
||||||
response = self.client.get("/")
|
response = self.client.get("/")
|
||||||
@@ -61,6 +63,11 @@ class TestWebuiSkeleton(unittest.TestCase):
|
|||||||
self.assertIn("Collision warnings", response.text)
|
self.assertIn("Collision warnings", response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_actions_is_implemented(self):
|
||||||
|
response = self.client.get("/actions")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("Gated actions", response.text)
|
||||||
|
|
||||||
def test_post_is_rejected(self):
|
def test_post_is_rejected(self):
|
||||||
response = self.client.post("/health")
|
response = self.client.post("/health")
|
||||||
self.assertEqual(response.status_code, 405)
|
self.assertEqual(response.status_code, 405)
|
||||||
@@ -72,8 +79,8 @@ class TestWebuiSkeleton(unittest.TestCase):
|
|||||||
self.assertIn("Live queue", response.text)
|
self.assertIn("Live queue", response.text)
|
||||||
|
|
||||||
def test_nav_links_on_all_pages(self):
|
def test_nav_links_on_all_pages(self):
|
||||||
paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases")
|
paths = ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
|
||||||
hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases")
|
hrefs = ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/worktrees", "/leases", "/actions")
|
||||||
for path in paths:
|
for path in paths:
|
||||||
with self.subTest(path=path):
|
with self.subTest(path=path):
|
||||||
text = self.client.get(path).text
|
text = self.client.get(path).text
|
||||||
|
|||||||
+19
-2
@@ -2,18 +2,35 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from webui.app import create_app
|
from webui.deployment_boundary import assess_bind_host
|
||||||
|
|
||||||
|
logger = logging.getLogger("webui")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bind_host(host: str) -> None:
|
||||||
|
assessment = assess_bind_host(host)
|
||||||
|
if assessment.disposition == "refuse":
|
||||||
|
logger.error("webui bind refused: %s", assessment.message)
|
||||||
|
raise SystemExit(1)
|
||||||
|
if assessment.disposition == "warn":
|
||||||
|
logger.warning("webui bind warning: %s", assessment.message)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
|
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
|
||||||
port = int(os.environ.get("WEBUI_PORT", "8765"))
|
port = int(os.environ.get("WEBUI_PORT", "8765"))
|
||||||
|
# Validate bind host before importing the full app stack so refuse paths
|
||||||
|
# fail closed quickly (tests and operators must not hang on create_app).
|
||||||
|
_validate_bind_host(host)
|
||||||
|
from webui.app import create_app
|
||||||
|
|
||||||
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+61
-3
@@ -9,6 +9,7 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from webui.deployment_boundary import deployment_snapshot
|
||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
from webui.project_registry import find_project, load_registry, registry_to_dict
|
||||||
from webui.project_views import render_project_detail, render_projects_list
|
from webui.project_views import render_project_detail, render_projects_list
|
||||||
@@ -16,6 +17,8 @@ from webui.prompt_library import find_prompt, library_to_dict
|
|||||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||||
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
||||||
|
|
||||||
|
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||||
|
from webui.gated_action_views import render_actions_page
|
||||||
from webui.audit_validator import audit_report, audit_to_dict
|
from webui.audit_validator import audit_report, audit_to_dict
|
||||||
from webui.audit_views import render_audit_page
|
from webui.audit_views import render_audit_page
|
||||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||||
@@ -52,17 +55,20 @@ async def home(_request: Request) -> HTMLResponse:
|
|||||||
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
||||||
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
||||||
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
||||||
|
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
||||||
"</ul>"
|
"</ul>"
|
||||||
)
|
)
|
||||||
return HTMLResponse(render_page(title="Home", body_html=body))
|
return HTMLResponse(render_page(title="Home", body_html=body))
|
||||||
|
|
||||||
|
|
||||||
async def health(_request: Request) -> JSONResponse:
|
async def health(_request: Request) -> JSONResponse:
|
||||||
|
bind_host = _request.app.state.webui_bind_host
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "mcp-control-plane-webui",
|
"service": "mcp-control-plane-webui",
|
||||||
"mode": "read-only-mvp",
|
"mode": "read-only-mvp",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"deployment": deployment_snapshot(bind_host=bind_host),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +206,41 @@ async def api_leases(_request: Request) -> JSONResponse:
|
|||||||
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
|
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
|
||||||
|
|
||||||
|
|
||||||
|
async def actions(_request: Request) -> HTMLResponse:
|
||||||
|
registry = load_action_registry()
|
||||||
|
return HTMLResponse(render_actions_page(registry))
|
||||||
|
|
||||||
|
|
||||||
|
async def api_actions(_request: Request) -> JSONResponse:
|
||||||
|
return JSONResponse(load_action_registry().to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
async def api_action_preview(request: Request) -> JSONResponse:
|
||||||
|
action_id = request.path_params["action_id"]
|
||||||
|
params = dict(request.query_params)
|
||||||
|
for key in ("issue_number", "pr_number"):
|
||||||
|
if key in params:
|
||||||
|
params[key] = int(params[key])
|
||||||
|
result = preview_action(action_id, **params)
|
||||||
|
if "error" in result:
|
||||||
|
return JSONResponse(result, status_code=404)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_action_attempt(request: Request) -> JSONResponse:
|
||||||
|
"""POST is rejected by read-only guard; kept for explicit fail-closed tests."""
|
||||||
|
action_id = request.path_params["action_id"]
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
body = {}
|
||||||
|
result = attempt_action(action_id, **body)
|
||||||
|
status = 403 if not result.get("success") else 200
|
||||||
|
return JSONResponse(result, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||||
@@ -212,9 +253,12 @@ async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
|||||||
return JSONResponse({"error": "not_found"}, status_code=404)
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Starlette:
|
def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||||
"""Build the read-only MVP Starlette app."""
|
"""Build the read-only MVP Starlette app."""
|
||||||
return Starlette(
|
import os
|
||||||
|
|
||||||
|
resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1")
|
||||||
|
app = Starlette(
|
||||||
debug=False,
|
debug=False,
|
||||||
routes=[
|
routes=[
|
||||||
Route("/", home, methods=["GET"]),
|
Route("/", home, methods=["GET"]),
|
||||||
@@ -234,7 +278,21 @@ def create_app() -> Starlette:
|
|||||||
Route("/worktrees", worktrees, methods=["GET"]),
|
Route("/worktrees", worktrees, methods=["GET"]),
|
||||||
Route("/api/worktrees", api_worktrees, methods=["GET"]),
|
Route("/api/worktrees", api_worktrees, methods=["GET"]),
|
||||||
Route("/leases", leases, methods=["GET"]),
|
Route("/leases", leases, methods=["GET"]),
|
||||||
|
Route("/actions", actions, methods=["GET"]),
|
||||||
|
Route("/api/actions", api_actions, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/actions/{action_id}/preview",
|
||||||
|
api_action_preview,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/actions/{action_id}/attempt",
|
||||||
|
api_action_attempt,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
Route("/api/leases", api_leases, methods=["GET"]),
|
Route("/api/leases", api_leases, methods=["GET"]),
|
||||||
],
|
],
|
||||||
exception_handlers={405: method_not_allowed},
|
exception_handlers={405: method_not_allowed},
|
||||||
)
|
)
|
||||||
|
app.state.webui_bind_host = resolved_bind
|
||||||
|
return app
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Path filters for web UI CI gating (#436)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
_WEBUI_TOUCH_RE = re.compile(
|
||||||
|
r"^(?:webui/|tests/test_webui_|docs/webui|scripts/(?:run-webui|test-webui|ci-webui-check)$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def touches_webui_surface(path: str) -> bool:
|
||||||
|
"""Return True when *path* is a web UI surface file."""
|
||||||
|
return bool(_WEBUI_TOUCH_RE.match(path.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def should_run_webui_ci(changed_paths: Iterable[str]) -> bool:
|
||||||
|
"""Return True when any changed path should trigger the web UI test suite."""
|
||||||
|
return any(touches_webui_surface(path) for path in changed_paths)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Internal-only deployment boundary for the operator web UI (#435)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
Disposition = Literal["safe", "warn", "refuse"]
|
||||||
|
|
||||||
|
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||||
|
_ALL_INTERFACE_HOSTS = frozenset({"0.0.0.0", "::", "*"})
|
||||||
|
|
||||||
|
_ALLOW_PUBLIC_BIND_ENV = "WEBUI_ALLOW_PUBLIC_BIND"
|
||||||
|
_ALLOW_REMOTE_BIND_ENV = "WEBUI_ALLOW_REMOTE_BIND"
|
||||||
|
|
||||||
|
_FORBIDDEN_CLIENT_PATTERNS = (
|
||||||
|
re.compile(r"GITEA_(?:TOKEN|PASS|PASSWORD)", re.I),
|
||||||
|
re.compile(r"Bearer\s+[A-Za-z0-9._\-]{20,}"),
|
||||||
|
re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.I),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BindAssessment:
|
||||||
|
host: str
|
||||||
|
disposition: Disposition
|
||||||
|
message: str
|
||||||
|
override_env: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy_env(name: str) -> bool:
|
||||||
|
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_bind_host(host: str) -> BindAssessment:
|
||||||
|
"""Classify a bind host as safe, warn, or refuse (fail closed on 0.0.0.0/::)."""
|
||||||
|
normalized = (host or "").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="refuse",
|
||||||
|
message="WEBUI_HOST must not be empty.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized in _LOOPBACK_HOSTS:
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="safe",
|
||||||
|
message="Loopback bind — suitable for local operator console.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized in _ALL_INTERFACE_HOSTS:
|
||||||
|
if _truthy_env(_ALLOW_PUBLIC_BIND_ENV):
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="warn",
|
||||||
|
message=(
|
||||||
|
"Binding all interfaces. Protect with Cloudflare Access, "
|
||||||
|
"WARP, VPN, or equivalent before exposing beyond localhost."
|
||||||
|
),
|
||||||
|
override_env=_ALLOW_PUBLIC_BIND_ENV,
|
||||||
|
)
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="refuse",
|
||||||
|
message=(
|
||||||
|
f"Refusing all-interface bind ({host!r}). Set "
|
||||||
|
f"{_ALLOW_PUBLIC_BIND_ENV}=1 only when fronted by trusted "
|
||||||
|
"network access controls."
|
||||||
|
),
|
||||||
|
override_env=_ALLOW_PUBLIC_BIND_ENV,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(normalized)
|
||||||
|
if addr.is_loopback:
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="safe",
|
||||||
|
message="Loopback bind — suitable for local operator console.",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _truthy_env(_ALLOW_REMOTE_BIND_ENV):
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="warn",
|
||||||
|
message=(
|
||||||
|
"Non-loopback bind acknowledged. Restrict to a trusted network "
|
||||||
|
"and add Cloudflare Access, WARP, or VPN if reachable beyond it."
|
||||||
|
),
|
||||||
|
override_env=_ALLOW_REMOTE_BIND_ENV,
|
||||||
|
)
|
||||||
|
|
||||||
|
return BindAssessment(
|
||||||
|
host=host,
|
||||||
|
disposition="warn",
|
||||||
|
message=(
|
||||||
|
f"Non-loopback bind ({host!r}). MVP expects 127.0.0.1; set "
|
||||||
|
f"{_ALLOW_REMOTE_BIND_ENV}=1 to acknowledge a trusted-network bind."
|
||||||
|
),
|
||||||
|
override_env=_ALLOW_REMOTE_BIND_ENV,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_assumptions() -> dict[str, str]:
|
||||||
|
"""Documented runtime paths and hosts — never includes secrets."""
|
||||||
|
repo_root = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||||
|
registry = (os.environ.get("WEBUI_PROJECT_REGISTRY") or "").strip()
|
||||||
|
profile_config = (os.environ.get("GITEA_MCP_CONFIG") or "").strip()
|
||||||
|
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
||||||
|
return {
|
||||||
|
"deployment_mode": "internal-operator-console",
|
||||||
|
"webui_host_env": "WEBUI_HOST",
|
||||||
|
"webui_port_env": "WEBUI_PORT",
|
||||||
|
"webui_repo_root_env": "WEBUI_REPO_ROOT",
|
||||||
|
"webui_repo_root": repo_root or "(defaults to repository root)",
|
||||||
|
"webui_project_registry_env": "WEBUI_PROJECT_REGISTRY",
|
||||||
|
"webui_project_registry": registry or "(packaged webui/data/projects.registry.json)",
|
||||||
|
"gitea_mcp_config_env": "GITEA_MCP_CONFIG",
|
||||||
|
"gitea_mcp_config": profile_config or "(optional; server-side only)",
|
||||||
|
"gitea_mcp_profile_env": "GITEA_MCP_PROFILE",
|
||||||
|
"gitea_mcp_profile": profile_name or "(optional; server-side only)",
|
||||||
|
"gitea_credentials": "Resolved server-side via gitea_auth; never embedded in HTML/JS",
|
||||||
|
"public_bind_override_env": _ALLOW_PUBLIC_BIND_ENV,
|
||||||
|
"remote_bind_override_env": _ALLOW_REMOTE_BIND_ENV,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_text_for_client_secrets(text: str) -> list[str]:
|
||||||
|
"""Return human-readable findings if *text* looks like it embeds secrets."""
|
||||||
|
findings: list[str] = []
|
||||||
|
for pattern in _FORBIDDEN_CLIENT_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
findings.append(f"matched forbidden client pattern: {pattern.pattern}")
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_snapshot(*, bind_host: str | None = None) -> dict[str, object]:
|
||||||
|
host = bind_host if bind_host is not None else os.environ.get("WEBUI_HOST", "127.0.0.1")
|
||||||
|
bind = assess_bind_host(host)
|
||||||
|
return {
|
||||||
|
"mode": "internal-operator-console",
|
||||||
|
"mvp_auth": "none",
|
||||||
|
"bind": asdict(bind),
|
||||||
|
"runtime_assumptions": runtime_assumptions(),
|
||||||
|
"client_secret_policy": (
|
||||||
|
"No Gitea tokens, passwords, or profile secrets in HTML, JS, "
|
||||||
|
"or browser storage."
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""HTML views for gated action previews (#434)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
|
||||||
|
from webui.gated_actions import ActionRegistry, GatedAction, preview_action
|
||||||
|
from webui.layout import render_page
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(text: str) -> str:
|
||||||
|
return html.escape(text, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ledger_block(action: GatedAction) -> str:
|
||||||
|
sample_params: dict[str, object]
|
||||||
|
if action.action_id == "create_issue":
|
||||||
|
sample_params = {"title": "Example issue"}
|
||||||
|
elif action.action_id == "create_pr":
|
||||||
|
sample_params = {"head": "feat/issue-99-example", "base": "master"}
|
||||||
|
elif action.action_id == "delete_branch":
|
||||||
|
sample_params = {"branch_name": "feat/issue-99-example"}
|
||||||
|
elif action.action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
|
||||||
|
sample_params = {"pr_number": 99}
|
||||||
|
else:
|
||||||
|
sample_params = {"issue_number": 99}
|
||||||
|
|
||||||
|
preview = preview_action(action.action_id, **sample_params)
|
||||||
|
ledger_json = json.dumps(preview.get("mutation_ledger", []), indent=2)
|
||||||
|
return (
|
||||||
|
"<details class='action-preview'>"
|
||||||
|
"<summary>Preview mutation ledger</summary>"
|
||||||
|
f"<pre class='prompt-text'>{_escape(ledger_json)}</pre>"
|
||||||
|
f"<p class='muted meta'>Sample params: "
|
||||||
|
f"<code>{_escape(json.dumps(sample_params))}</code></p>"
|
||||||
|
f"<p><a href=\"/api/actions/{_escape(action.action_id)}/preview?"
|
||||||
|
f"{_preview_query(sample_params)}\">JSON preview</a></p>"
|
||||||
|
"</details>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_query(params: dict[str, object]) -> str:
|
||||||
|
parts = []
|
||||||
|
for key, value in params.items():
|
||||||
|
parts.append(f"{key}={_escape(str(value))}")
|
||||||
|
return "&".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_card(action: GatedAction) -> str:
|
||||||
|
disabled_attr = " disabled" if not action.enabled else ""
|
||||||
|
return (
|
||||||
|
"<article class='prompt-card action-card'>"
|
||||||
|
f"<h3>{_escape(action.label)}</h3>"
|
||||||
|
f"<p class='muted'>{_escape(action.description)}</p>"
|
||||||
|
"<p class='meta'>"
|
||||||
|
f"Task <code>{_escape(action.task_key)}</code> · "
|
||||||
|
f"MCP <code>{_escape(action.mcp_tool)}</code><br>"
|
||||||
|
f"Requires <code>{_escape(action.required_permission)}</code> "
|
||||||
|
f"({_escape(action.required_role)} profile)</p>"
|
||||||
|
f"<button type='button' class='copy-btn action-btn'{disabled_attr} "
|
||||||
|
f"aria-disabled='true' title='Disabled in read-only MVP'>"
|
||||||
|
f"{_escape(action.label)}</button>"
|
||||||
|
f"{_ledger_block(action)}"
|
||||||
|
"</article>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_PAGE_STYLES = """
|
||||||
|
<style>
|
||||||
|
.action-card .action-btn[disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
.action-preview { margin-top: 0.75rem; }
|
||||||
|
.action-preview summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_actions_page(registry: ActionRegistry) -> str:
|
||||||
|
cards = "".join(_action_card(action) for action in registry.actions)
|
||||||
|
body = (
|
||||||
|
"<h2>Gated actions</h2>"
|
||||||
|
"<p>Future write actions are registered here but remain "
|
||||||
|
"<strong>disabled</strong> in the read-only MVP. Each action "
|
||||||
|
"declares the MCP capability and profile role from "
|
||||||
|
"<code>task_capability_map</code>; previews show the mutation "
|
||||||
|
"ledger before any execution path ships.</p>"
|
||||||
|
f"<p class='meta'>{len(registry.actions)} registered actions · "
|
||||||
|
"execution: fail-closed</p>"
|
||||||
|
f"{cards}"
|
||||||
|
"<p><a href=\"/api/actions\">JSON registry</a></p>"
|
||||||
|
f"{ACTION_PAGE_STYLES}"
|
||||||
|
)
|
||||||
|
return render_page(title="Actions", body_html=body)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Disabled-by-default gated action registry for future UI writes (#434).
|
||||||
|
|
||||||
|
Every action declares the MCP capability and profile role from
|
||||||
|
``task_capability_map``. Previews always render a mutation ledger; execution
|
||||||
|
is fail-closed in the read-only MVP (no MCP or shell fallbacks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from task_capability_map import required_permission, required_role
|
||||||
|
|
||||||
|
# MVP: no UI action may execute mutations until capability gates ship.
|
||||||
|
_MVP_ACTIONS_ENABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MutationLedgerEntry:
|
||||||
|
"""One planned mutation step shown before any write executes."""
|
||||||
|
|
||||||
|
sequence: int
|
||||||
|
mcp_tool: str
|
||||||
|
permission: str
|
||||||
|
target: str
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatedAction:
|
||||||
|
"""Registry entry tying a UI action to resolver task metadata."""
|
||||||
|
|
||||||
|
action_id: str
|
||||||
|
label: str
|
||||||
|
task_key: str
|
||||||
|
mcp_tool: str
|
||||||
|
description: str
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def required_permission(self) -> str:
|
||||||
|
return required_permission(self.task_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def required_role(self) -> str:
|
||||||
|
return required_role(self.task_key)
|
||||||
|
|
||||||
|
def mutation_ledger(self, **params: Any) -> tuple[MutationLedgerEntry, ...]:
|
||||||
|
"""Build the mutation ledger preview for *params* (read-only)."""
|
||||||
|
target = _format_target(self.action_id, params)
|
||||||
|
return (
|
||||||
|
MutationLedgerEntry(
|
||||||
|
sequence=1,
|
||||||
|
mcp_tool=self.mcp_tool,
|
||||||
|
permission=self.required_permission,
|
||||||
|
target=target,
|
||||||
|
summary=self.description,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def preview(self, **params: Any) -> dict[str, Any]:
|
||||||
|
ledger = self.mutation_ledger(**params)
|
||||||
|
return {
|
||||||
|
"action_id": self.action_id,
|
||||||
|
"label": self.label,
|
||||||
|
"task_key": self.task_key,
|
||||||
|
"mcp_tool": self.mcp_tool,
|
||||||
|
"required_permission": self.required_permission,
|
||||||
|
"required_role": self.required_role,
|
||||||
|
"enabled": self.enabled and _MVP_ACTIONS_ENABLED,
|
||||||
|
"mvp_mode": "read-only",
|
||||||
|
"mutation_ledger": [asdict(entry) for entry in ledger],
|
||||||
|
"params": dict(params),
|
||||||
|
}
|
||||||
|
|
||||||
|
def attempt(self, **params: Any) -> dict[str, Any]:
|
||||||
|
"""Fail closed — never invokes MCP tools in MVP."""
|
||||||
|
preview = self.preview(**params)
|
||||||
|
if not preview["enabled"]:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "action_disabled",
|
||||||
|
"detail": (
|
||||||
|
"UI actions are disabled in the read-only MVP. "
|
||||||
|
"Use MCP tools with capability gates."
|
||||||
|
),
|
||||||
|
"preview": preview,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "action_not_implemented",
|
||||||
|
"detail": "Gated execution path is not wired in MVP.",
|
||||||
|
"preview": preview,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_target(action_id: str, params: dict[str, Any]) -> str:
|
||||||
|
if action_id == "claim_issue":
|
||||||
|
return f"issue #{params.get('issue_number', '?')}"
|
||||||
|
if action_id in {"comment_issue", "close_issue"}:
|
||||||
|
return f"issue #{params.get('issue_number', '?')}"
|
||||||
|
if action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
|
||||||
|
return f"PR #{params.get('pr_number', '?')}"
|
||||||
|
if action_id == "delete_branch":
|
||||||
|
return f"branch {params.get('branch_name', '?')!r}"
|
||||||
|
if action_id == "create_pr":
|
||||||
|
return (
|
||||||
|
f"PR {params.get('head', '?')} → {params.get('base', 'master')}"
|
||||||
|
)
|
||||||
|
if action_id == "create_issue":
|
||||||
|
return f"issue {params.get('title', '?')!r}"
|
||||||
|
return "unspecified"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionRegistry:
|
||||||
|
"""Ordered registry of gated UI actions."""
|
||||||
|
|
||||||
|
actions: tuple[GatedAction, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
def get(self, action_id: str) -> GatedAction | None:
|
||||||
|
for action in self.actions:
|
||||||
|
if action.action_id == action_id:
|
||||||
|
return action
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mvp_actions_enabled": _MVP_ACTIONS_ENABLED,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action_id": action.action_id,
|
||||||
|
"label": action.label,
|
||||||
|
"task_key": action.task_key,
|
||||||
|
"mcp_tool": action.mcp_tool,
|
||||||
|
"required_permission": action.required_permission,
|
||||||
|
"required_role": action.required_role,
|
||||||
|
"enabled": action.enabled,
|
||||||
|
"description": action.description,
|
||||||
|
}
|
||||||
|
for action in self.actions
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_action_registry() -> ActionRegistry:
|
||||||
|
"""Return the canonical MVP action set (all disabled)."""
|
||||||
|
specs = (
|
||||||
|
("claim_issue", "Claim issue", "claim_issue", "gitea_mark_issue",
|
||||||
|
"Apply status:in-progress via issue comment gate."),
|
||||||
|
("comment_issue", "Comment on issue", "comment_issue",
|
||||||
|
"gitea_create_issue_comment", "Post an issue comment."),
|
||||||
|
("create_issue", "Create issue", "create_issue", "gitea_create_issue",
|
||||||
|
"Open a new tracking issue."),
|
||||||
|
("create_pr", "Create pull request", "create_pr", "gitea_create_pr",
|
||||||
|
"Open a PR from a locked feature branch."),
|
||||||
|
("review_pr", "Review PR", "review_pr", "gitea_review_pr",
|
||||||
|
"Submit approve/request-changes/comment review."),
|
||||||
|
("merge_pr", "Merge PR", "merge_pr", "gitea_merge_pr",
|
||||||
|
"Merge an approved pull request."),
|
||||||
|
("delete_branch", "Delete branch", "delete_branch",
|
||||||
|
"gitea_delete_branch", "Remove a remote feature branch."),
|
||||||
|
("comment_pr", "Comment on PR", "comment_pr",
|
||||||
|
"gitea_create_issue_comment", "Post a PR review thread comment."),
|
||||||
|
("close_pr", "Close PR", "close_pr", "gitea_edit_pr",
|
||||||
|
"Close a pull request without merge."),
|
||||||
|
)
|
||||||
|
actions = tuple(
|
||||||
|
GatedAction(
|
||||||
|
action_id=action_id,
|
||||||
|
label=label,
|
||||||
|
task_key=task_key,
|
||||||
|
mcp_tool=mcp_tool,
|
||||||
|
description=description,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
for action_id, label, task_key, mcp_tool, description in specs
|
||||||
|
)
|
||||||
|
return ActionRegistry(actions=actions)
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY = build_action_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def load_action_registry() -> ActionRegistry:
|
||||||
|
return _REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def preview_action(action_id: str, **params: Any) -> dict[str, Any]:
|
||||||
|
action = _REGISTRY.get(action_id)
|
||||||
|
if action is None:
|
||||||
|
return {"error": "unknown_action", "action_id": action_id}
|
||||||
|
return action.preview(**params)
|
||||||
|
|
||||||
|
|
||||||
|
def attempt_action(action_id: str, **params: Any) -> dict[str, Any]:
|
||||||
|
action = _REGISTRY.get(action_id)
|
||||||
|
if action is None:
|
||||||
|
return {"success": False, "error": "unknown_action", "action_id": action_id}
|
||||||
|
return action.attempt(**params)
|
||||||
@@ -11,6 +11,7 @@ NAV_ITEMS = (
|
|||||||
("/audit", "Audit"),
|
("/audit", "Audit"),
|
||||||
("/worktrees", "Worktrees"),
|
("/worktrees", "Worktrees"),
|
||||||
("/leases", "Leases"),
|
("/leases", "Leases"),
|
||||||
|
("/actions", "Actions"),
|
||||||
)
|
)
|
||||||
|
|
||||||
MVP_NOTICE = (
|
MVP_NOTICE = (
|
||||||
|
|||||||
+24
-5
@@ -228,10 +228,29 @@ def load_lease_snapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
host = _host_from_url(project.remote_host)
|
host = _host_from_url(project.remote_host)
|
||||||
pr_fetch = fetch_prs or _fetch_prs
|
offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
|
||||||
issue_fetch = fetch_issues or _fetch_issues
|
"1",
|
||||||
comment_fetch = fetch_comments or _fetch_comments
|
"true",
|
||||||
using_live = fetch_prs is None or fetch_issues is None
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_pr_fetch(*_args, **_kwargs):
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
def _empty_issue_fetch(*_args, **_kwargs):
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
def _empty_comment_fetch(*_args, **_kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
pr_fetch = fetch_prs or (_empty_pr_fetch if offline_test else _fetch_prs)
|
||||||
|
issue_fetch = fetch_issues or (
|
||||||
|
_empty_issue_fetch if offline_test else _fetch_issues
|
||||||
|
)
|
||||||
|
comment_fetch = fetch_comments or (
|
||||||
|
_empty_comment_fetch if offline_test else _fetch_comments
|
||||||
|
)
|
||||||
|
using_live = not offline_test and (fetch_prs is None or fetch_issues is None)
|
||||||
auth = get_auth_header(host) if using_live else "test-auth"
|
auth = get_auth_header(host) if using_live else "test-auth"
|
||||||
|
|
||||||
if using_live and not auth:
|
if using_live and not auth:
|
||||||
@@ -328,4 +347,4 @@ def snapshot_to_dict(snapshot: LeaseSnapshot) -> dict[str, Any]:
|
|||||||
],
|
],
|
||||||
"collision_history": list(snapshot.collision_history),
|
"collision_history": list(snapshot.collision_history),
|
||||||
"fetch_error": snapshot.fetch_error,
|
"fetch_error": snapshot.fetch_error,
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-4
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -268,9 +269,23 @@ def load_queue_snapshot(
|
|||||||
)
|
)
|
||||||
|
|
||||||
host = _host_from_url(project.remote_host)
|
host = _host_from_url(project.remote_host)
|
||||||
pr_fetch = fetch_prs or _fetch_prs
|
offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
|
||||||
issue_fetch = fetch_issues or _fetch_issues
|
"1",
|
||||||
using_live_fetch = fetch_prs is None or fetch_issues is None
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_fetch(*_args, **_kwargs):
|
||||||
|
return [], _pagination_from_pages(
|
||||||
|
per_page=50,
|
||||||
|
pages_fetched=1,
|
||||||
|
returned_count=0,
|
||||||
|
is_final_page=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pr_fetch = fetch_prs or (_empty_fetch if offline_test else _fetch_prs)
|
||||||
|
issue_fetch = fetch_issues or (_empty_fetch if offline_test else _fetch_issues)
|
||||||
|
using_live_fetch = not offline_test and (fetch_prs is None or fetch_issues is None)
|
||||||
auth = get_auth_header(host) if using_live_fetch else "test-auth"
|
auth = get_auth_header(host) if using_live_fetch else "test-auth"
|
||||||
if using_live_fetch and not auth:
|
if using_live_fetch and not auth:
|
||||||
return QueueSnapshot(
|
return QueueSnapshot(
|
||||||
@@ -382,4 +397,4 @@ def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
|
|||||||
"prs": _page(snapshot.pr_pagination),
|
"prs": _page(snapshot.pr_pagination),
|
||||||
"issues": _page(snapshot.issue_pagination),
|
"issues": _page(snapshot.issue_pagination),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -217,6 +217,11 @@ def load_runtime_snapshot(
|
|||||||
repo = _repo_root()
|
repo = _repo_root()
|
||||||
host = _host_from_url(project.remote_host)
|
host = _host_from_url(project.remote_host)
|
||||||
remote = _remote_name_for_host(host)
|
remote = _remote_name_for_host(host)
|
||||||
|
offline_test = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
allowed = profile.get("allowed_operations") or []
|
allowed = profile.get("allowed_operations") or []
|
||||||
forbidden = profile.get("forbidden_operations") or []
|
forbidden = profile.get("forbidden_operations") or []
|
||||||
@@ -247,10 +252,20 @@ def load_runtime_snapshot(
|
|||||||
fetch_error=str(exc),
|
fetch_error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
identity_fn = resolve_username or _resolve_username
|
identity_fn = resolve_username
|
||||||
|
if identity_fn is None:
|
||||||
|
if offline_test:
|
||||||
|
identity_fn = lambda _host: (None, "offline web UI test mode")
|
||||||
|
else:
|
||||||
|
identity_fn = _resolve_username
|
||||||
username, identity_error = identity_fn(host)
|
username, identity_error = identity_fn(host)
|
||||||
|
|
||||||
git_fn = git_sync or _git_sync_status
|
git_fn = git_sync
|
||||||
|
if git_fn is None:
|
||||||
|
if offline_test:
|
||||||
|
git_fn = lambda _repo, _remote, _branch: (None, None, None)
|
||||||
|
else:
|
||||||
|
git_fn = _git_sync_status
|
||||||
remote_ref = "prgs" if remote == "prgs" else "origin"
|
remote_ref = "prgs" if remote == "prgs" else "origin"
|
||||||
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)
|
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)
|
||||||
|
|
||||||
@@ -317,4 +332,4 @@ def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]:
|
|||||||
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
|
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
|
||||||
"restart_guidance": snapshot.restart_guidance,
|
"restart_guidance": snapshot.restart_guidance,
|
||||||
"fetch_error": snapshot.fetch_error,
|
"fetch_error": snapshot.fetch_error,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user