Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc9366c394 | ||
|
|
67e4a2b5e9 | ||
|
|
ba7915452e | ||
|
|
293808b42d | ||
|
|
970e68bddb |
@@ -0,0 +1,198 @@
|
|||||||
|
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||||
|
|
||||||
|
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||||
|
- **Related:**
|
||||||
|
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||||
|
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||||
|
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||||
|
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||||
|
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||||
|
|
||||||
|
- Mid-session identity/preflight resets
|
||||||
|
- Stale-runtime vs master parity failures
|
||||||
|
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||||
|
- Accidental production mutations from experimental code
|
||||||
|
|
||||||
|
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
### 2.1 Stable control runtime
|
||||||
|
|
||||||
|
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||||
|
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||||
|
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||||
|
|
||||||
|
### 2.2 Dev / test runtime
|
||||||
|
|
||||||
|
MCP **server code** development and testing:
|
||||||
|
|
||||||
|
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||||
|
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||||
|
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||||
|
|
||||||
|
### 2.3 Forbidden actions (normal sessions)
|
||||||
|
|
||||||
|
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||||
|
|
||||||
|
| Forbidden | Why |
|
||||||
|
|-----------|-----|
|
||||||
|
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||||
|
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||||
|
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||||
|
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||||
|
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||||
|
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||||
|
|
||||||
|
**LLM-allowed vs operator-owned (authoritative split):**
|
||||||
|
|
||||||
|
| Actor | May do | Must not do |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||||
|
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||||
|
|
||||||
|
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||||
|
|
||||||
|
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||||
|
|
||||||
|
### 2.4 Promotion (operator / release-manager only)
|
||||||
|
|
||||||
|
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||||
|
|
||||||
|
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||||
|
| **promoted runtime SHA** | Commit after promotion |
|
||||||
|
| **source branch/PR** | Where the change was reviewed |
|
||||||
|
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||||
|
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||||
|
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||||
|
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||||
|
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||||
|
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||||
|
|
||||||
|
Suggested durable marker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||||
|
|
||||||
|
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||||
|
Previous-SHA: <full sha>
|
||||||
|
Promoted-SHA: <full sha>
|
||||||
|
Source-PR: <number>
|
||||||
|
Source-Branch: <name>
|
||||||
|
Reload-Method: <text>
|
||||||
|
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||||
|
Identity-Proof: profile=<name> user=<name>
|
||||||
|
Workspace-Proof: root=<path>
|
||||||
|
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||||
|
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||||
|
Operator: <username>
|
||||||
|
Timestamp: <ISO-8601>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Unhealthy stable runtime → stop work
|
||||||
|
|
||||||
|
If the stable MCP runtime is **unhealthy**, including any of:
|
||||||
|
|
||||||
|
- client-namespace probes fail
|
||||||
|
- wrong identity / wrong profile
|
||||||
|
- wrong workspace root
|
||||||
|
- missing mutation capability for the intended role
|
||||||
|
- persistent EOF after **client reconnect**
|
||||||
|
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||||
|
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||||
|
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||||
|
4. Resume only after:
|
||||||
|
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||||
|
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||||
|
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||||
|
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||||
|
|
||||||
|
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||||
|
|
||||||
|
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||||
|
|
||||||
|
**Sanctioned response (authoritative):**
|
||||||
|
|
||||||
|
| Step | Actor | Action |
|
||||||
|
|------|-------|--------|
|
||||||
|
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||||
|
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||||
|
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||||
|
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||||
|
|
||||||
|
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||||
|
|
||||||
|
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||||
|
|
||||||
|
## 3. Relationship to other controls
|
||||||
|
|
||||||
|
| Doc / mechanism | Interaction |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||||
|
| EOF recovery | Reconnect only; no process kill |
|
||||||
|
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||||
|
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||||
|
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||||
|
|
||||||
|
## 4. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Predictable control plane for concurrent LLMs
|
||||||
|
- Clear operator-only promotion gate with rollback
|
||||||
|
- Aligns session behavior with health/EOF docs already landed
|
||||||
|
|
||||||
|
### Costs
|
||||||
|
|
||||||
|
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||||
|
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Does not ban operator-supervised restarts during incidents
|
||||||
|
- Does not replace CI or code review for MCP changes
|
||||||
|
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||||
|
|
||||||
|
## 5. Implementation follow-ups (optional tooling)
|
||||||
|
|
||||||
|
These may land in later issues; the **policy binds sessions now**:
|
||||||
|
|
||||||
|
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||||
|
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||||
|
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||||
|
|
||||||
|
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||||
|
|
||||||
|
## 6. Acceptance for this ADR
|
||||||
|
|
||||||
|
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||||
|
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||||
|
3. Issue #615 references this path.
|
||||||
|
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||||
|
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||||
|
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||||
|
|
||||||
|
## 7. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||||
|
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
|||||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||||
review or author workflows.
|
review or author workflows.
|
||||||
|
|
||||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
|||||||
|
|
||||||
1. Read the error — it names the **resolved workspace path**, **role
|
1. Read the error — it names the **resolved workspace path**, **role
|
||||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||||
workspace (or set the role-specific env var before launch).
|
the active `branches/` worktree differs from the MCP process root; **client
|
||||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
reconnect** after transport EOF only (no process kill).
|
||||||
branches/ worktree differs from the MCP process root.
|
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||||
|
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||||
|
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||||
|
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||||
own namespace — that destroys another agent's WIP.
|
own namespace — that destroys another agent's WIP.
|
||||||
|
|
||||||
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||||
- Quote the resolved workspace path and binding source from the error.
|
- Quote the resolved workspace path and binding source from the error.
|
||||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
request an **operator** reload of the correct namespace MCP / env binding.
|
||||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||||
|
that the LLM must not self-restart the MCP process.
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
|
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
|||||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||||
|
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Supported Gitea instances
|
## Supported Gitea instances
|
||||||
|
|
||||||
@@ -30,3 +31,4 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
|||||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||||
|
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||||
|
|
||||||
|
## Stable MCP control runtime (#615)
|
||||||
|
|
||||||
|
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||||
|
|
||||||
|
| Situation | Who acts | What to do |
|
||||||
|
|-----------|----------|------------|
|
||||||
|
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||||
|
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||||
|
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||||
|
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||||
|
|
||||||
## Gitea Wiki sync
|
## Gitea Wiki sync
|
||||||
|
|
||||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||||
|
|||||||
+93
-31
@@ -517,6 +517,19 @@ def _clear_preflight_capability_state() -> None:
|
|||||||
_preflight_reviewer_violation_files = []
|
_preflight_reviewer_violation_files = []
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_resolved_capability_stamp() -> None:
|
||||||
|
"""#723 AC3: drop only the resolved role/task stamp (denied resolve).
|
||||||
|
|
||||||
|
``record_preflight_check("capability")`` without a role intentionally
|
||||||
|
preserves a prior stamp, so a DENIED resolve must clear it explicitly —
|
||||||
|
otherwise the stale role poisons :func:`_effective_workspace_role` and a
|
||||||
|
later mutation preflight raises instead of failing closed with reasons.
|
||||||
|
"""
|
||||||
|
global _preflight_resolved_role, _preflight_resolved_task
|
||||||
|
_preflight_resolved_role = None
|
||||||
|
_preflight_resolved_task = None
|
||||||
|
|
||||||
|
|
||||||
def record_preflight_check(
|
def record_preflight_check(
|
||||||
type_name: str,
|
type_name: str,
|
||||||
resolved_role: str | None = None,
|
resolved_role: str | None = None,
|
||||||
@@ -4096,9 +4109,6 @@ def _evaluate_pr_review_submission(
|
|||||||
worktree_path: str | None = None,
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared gate chain for live submit and dry-run review tools."""
|
"""Shared gate chain for live submit and dry-run review tools."""
|
||||||
_verify_role_mutation_workspace(
|
|
||||||
remote, worktree_path=worktree_path, task="review_pr"
|
|
||||||
)
|
|
||||||
action = (action or "").strip().lower()
|
action = (action or "").strip().lower()
|
||||||
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
|
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
|
||||||
result = {
|
result = {
|
||||||
@@ -4116,6 +4126,20 @@ def _evaluate_pr_review_submission(
|
|||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
reasons = result["reasons"]
|
reasons = result["reasons"]
|
||||||
|
# #723 AC4: workspace/role-binding failures fail closed with structured
|
||||||
|
# reasons — never escape as a generic internal_error (PR #721: a stamped
|
||||||
|
# 'merger' role raised RuntimeError here and the client saw an
|
||||||
|
# unactionable internal_error after mark_final had already succeeded).
|
||||||
|
try:
|
||||||
|
_verify_role_mutation_workspace(
|
||||||
|
remote, worktree_path=worktree_path, task="review_pr"
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
result["blocker_kind"] = "workspace_role_binding"
|
||||||
|
reasons.append(
|
||||||
|
"workspace/role binding failed (fail closed, #723): " f"{exc}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
if workflow_blockers:
|
if workflow_blockers:
|
||||||
reasons.extend(workflow_blockers)
|
reasons.extend(workflow_blockers)
|
||||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||||
@@ -11891,8 +11915,16 @@ _RUNTIME_CAPABILITY_TASKS = (
|
|||||||
def _matching_configured_profiles(
|
def _matching_configured_profiles(
|
||||||
config: dict | None,
|
config: dict | None,
|
||||||
required_permission: str,
|
required_permission: str,
|
||||||
|
required_role_kind: str | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Profile names that allow *required_permission* (redacted metadata only)."""
|
"""Profile names that allow *required_permission* (redacted metadata only).
|
||||||
|
|
||||||
|
#723 AC5: when *required_role_kind* is supplied (role-exclusive tasks),
|
||||||
|
a profile with a declared role must also match it — mirroring the
|
||||||
|
resolver's matching rule so the two lists cannot drift. Profiles without
|
||||||
|
a declared role still match on permission alone, exactly like the
|
||||||
|
resolver.
|
||||||
|
"""
|
||||||
if not config or "profiles" not in config:
|
if not config or "profiles" not in config:
|
||||||
return []
|
return []
|
||||||
matches: list[str] = []
|
matches: list[str] = []
|
||||||
@@ -11916,7 +11948,13 @@ def _matching_configured_profiles(
|
|||||||
ok, _ = gitea_config.check_operation(
|
ok, _ = gitea_config.check_operation(
|
||||||
required_permission, p_allowed_n, p_forbidden_n
|
required_permission, p_allowed_n, p_forbidden_n
|
||||||
)
|
)
|
||||||
if ok:
|
p_role = (p_data.get("role") or "").strip()
|
||||||
|
role_ok = (
|
||||||
|
required_role_kind is None
|
||||||
|
or not p_role
|
||||||
|
or p_role == required_role_kind
|
||||||
|
)
|
||||||
|
if ok and role_ok:
|
||||||
matches.append(p_name)
|
matches.append(p_name)
|
||||||
return sorted(matches)
|
return sorted(matches)
|
||||||
|
|
||||||
@@ -11925,8 +11963,16 @@ def _build_runtime_task_capabilities(
|
|||||||
allowed: list[str],
|
allowed: list[str],
|
||||||
forbidden: list[str],
|
forbidden: list[str],
|
||||||
config: dict | None,
|
config: dict | None,
|
||||||
|
active_role_kind: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Per-task capability summary for role-aware runtime context (#139)."""
|
"""Per-task capability summary for role-aware runtime context (#139).
|
||||||
|
|
||||||
|
#723 AC5: applies the same role-exclusive filter as
|
||||||
|
``gitea_resolve_task_capability`` when *active_role_kind* is supplied, so
|
||||||
|
runtime context can never report a role-exclusive task as allowed while
|
||||||
|
the resolver fail-closes it in the same session (incident #722). Without
|
||||||
|
an *active_role_kind* the view is permission-only and each entry says so.
|
||||||
|
"""
|
||||||
task_entries = []
|
task_entries = []
|
||||||
flags: dict[str, bool] = {}
|
flags: dict[str, bool] = {}
|
||||||
flag_keys = {
|
flag_keys = {
|
||||||
@@ -11939,18 +11985,35 @@ def _build_runtime_task_capabilities(
|
|||||||
"close_issue": "can_close_issues",
|
"close_issue": "can_close_issues",
|
||||||
"reconcile_already_landed_pr": "can_reconcile_already_landed_prs",
|
"reconcile_already_landed_pr": "can_reconcile_already_landed_prs",
|
||||||
}
|
}
|
||||||
|
role_filter_applied = active_role_kind is not None
|
||||||
for task in _RUNTIME_CAPABILITY_TASKS:
|
for task in _RUNTIME_CAPABILITY_TASKS:
|
||||||
permission = task_capability_map.required_permission(task)
|
permission = task_capability_map.required_permission(task)
|
||||||
allowed_here, _ = gitea_config.check_operation(
|
required_role_kind = task_capability_map.required_role(task)
|
||||||
|
role_exclusive = task in task_capability_map.ROLE_EXCLUSIVE_TASKS
|
||||||
|
permission_allowed, _ = gitea_config.check_operation(
|
||||||
permission, allowed, forbidden
|
permission, allowed, forbidden
|
||||||
)
|
)
|
||||||
|
role_ok = (
|
||||||
|
not role_exclusive
|
||||||
|
or not role_filter_applied
|
||||||
|
or active_role_kind == required_role_kind
|
||||||
|
)
|
||||||
|
allowed_here = permission_allowed and role_ok
|
||||||
entry = {
|
entry = {
|
||||||
"task": task,
|
"task": task,
|
||||||
"required_permission": permission,
|
"required_permission": permission,
|
||||||
"required_role_kind": task_capability_map.required_role(task),
|
"required_role_kind": required_role_kind,
|
||||||
|
"role_exclusive": role_exclusive,
|
||||||
|
"capability_view": (
|
||||||
|
"role_filtered" if role_filter_applied else "permission_only"
|
||||||
|
),
|
||||||
"allowed_in_current_session": allowed_here,
|
"allowed_in_current_session": allowed_here,
|
||||||
"matching_configured_profiles": _matching_configured_profiles(
|
"matching_configured_profiles": _matching_configured_profiles(
|
||||||
config, permission
|
config,
|
||||||
|
permission,
|
||||||
|
required_role_kind=(
|
||||||
|
required_role_kind if role_exclusive else None
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
task_entries.append(entry)
|
task_entries.append(entry)
|
||||||
@@ -12403,7 +12466,10 @@ def gitea_get_runtime_context(
|
|||||||
)
|
)
|
||||||
|
|
||||||
session_capabilities = _build_runtime_task_capabilities(
|
session_capabilities = _build_runtime_task_capabilities(
|
||||||
allowed, forbidden, config
|
allowed,
|
||||||
|
forbidden,
|
||||||
|
config,
|
||||||
|
active_role_kind=_profile_role_kind(profile),
|
||||||
)
|
)
|
||||||
|
|
||||||
preflight = assess_preflight_status(worktree_path)
|
preflight = assess_preflight_status(worktree_path)
|
||||||
@@ -13821,26 +13887,9 @@ def gitea_resolve_task_capability(
|
|||||||
|
|
||||||
required_permission = task_capability_map.required_permission(task)
|
required_permission = task_capability_map.required_permission(task)
|
||||||
required_role = task_capability_map.required_role(task)
|
required_role = task_capability_map.required_role(task)
|
||||||
role_exclusive_tasks = {
|
# #723 AC5: single shared definition; the runtime-context capability
|
||||||
"review_pr",
|
# report applies the same set so the two views cannot drift.
|
||||||
"approve_pr",
|
role_exclusive_tasks = task_capability_map.ROLE_EXCLUSIVE_TASKS
|
||||||
"request_changes_pr",
|
|
||||||
"blind_pr_queue_review",
|
|
||||||
"pr_queue_cleanup",
|
|
||||||
"pr-queue-cleanup",
|
|
||||||
"merge_pr",
|
|
||||||
"create_branch",
|
|
||||||
"push_branch",
|
|
||||||
"create_pr",
|
|
||||||
"commit_files",
|
|
||||||
"gitea_commit_files",
|
|
||||||
"address_pr_change_requests",
|
|
||||||
"delete_branch",
|
|
||||||
"cleanup_merged_pr_branch",
|
|
||||||
"reconciliation_cleanup",
|
|
||||||
"work_issue",
|
|
||||||
"work-issue",
|
|
||||||
}
|
|
||||||
|
|
||||||
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
||||||
if required_role == "reviewer":
|
if required_role == "reviewer":
|
||||||
@@ -13921,7 +13970,12 @@ def gitea_resolve_task_capability(
|
|||||||
"exact_safe_next_action": next_safe_action,
|
"exact_safe_next_action": next_safe_action,
|
||||||
}
|
}
|
||||||
|
|
||||||
record_preflight_check("capability", required_role, resolved_task=task)
|
# #723 AC3: record the capability purity baseline now, but DEFER the
|
||||||
|
# resolved-role/task stamp until after the allow/deny decision below. A
|
||||||
|
# denied resolve previously stamped ``_preflight_resolved_role`` anyway
|
||||||
|
# (PR #721: a denied review_pr resolve stamped 'merger', and the later
|
||||||
|
# submit escaped as a generic internal_error instead of failing closed).
|
||||||
|
record_preflight_check("capability")
|
||||||
|
|
||||||
# Try automatic dispatch switching
|
# Try automatic dispatch switching
|
||||||
_ensure_matching_profile(required_permission, required_role, remote, host)
|
_ensure_matching_profile(required_permission, required_role, remote, host)
|
||||||
@@ -13956,6 +14010,14 @@ def gitea_resolve_task_capability(
|
|||||||
permission_allowed_in_current_session and role_matches_current_session
|
permission_allowed_in_current_session and role_matches_current_session
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# #723 AC3: stamp the resolved role/task only for an ALLOWED resolve; a
|
||||||
|
# denied resolve clears any stale stamp so later mutation preflights key
|
||||||
|
# off the real profile role and fail closed with structured reasons.
|
||||||
|
if allowed_in_current_session:
|
||||||
|
record_preflight_check("capability", required_role, resolved_task=task)
|
||||||
|
else:
|
||||||
|
_clear_resolved_capability_stamp()
|
||||||
|
|
||||||
switching = gitea_config.is_runtime_switching_enabled()
|
switching = gitea_config.is_runtime_switching_enabled()
|
||||||
available_in_session = allowed_in_current_session
|
available_in_session = allowed_in_current_session
|
||||||
configured = False
|
configured = False
|
||||||
|
|||||||
+27
-1
@@ -84,7 +84,7 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"adopt_merger_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||||
@@ -330,6 +330,32 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #723 AC1/AC5: tasks where permission alone is NOT enough — the profile's
|
||||||
|
# role kind must also match. Shared by ``gitea_resolve_task_capability`` and
|
||||||
|
# the runtime-context capability report so the two can never disagree about
|
||||||
|
# which tasks are role-exclusive (incident #722: runtime context said
|
||||||
|
# review_pr was allowed while the resolver fail-closed the same session).
|
||||||
|
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset({
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"blind_pr_queue_review",
|
||||||
|
"pr_queue_cleanup",
|
||||||
|
"pr-queue-cleanup",
|
||||||
|
"merge_pr",
|
||||||
|
"create_branch",
|
||||||
|
"push_branch",
|
||||||
|
"create_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"address_pr_change_requests",
|
||||||
|
"delete_branch",
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
|
"reconciliation_cleanup",
|
||||||
|
"work_issue",
|
||||||
|
"work-issue",
|
||||||
|
})
|
||||||
|
|
||||||
# Issue-mutating MCP tools and their resolver task keys.
|
# Issue-mutating MCP tools and their resolver task keys.
|
||||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||||
"gitea_create_issue": "create_issue",
|
"gitea_create_issue": "create_issue",
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""#723 AC3/AC4/AC5: denied resolves must not poison the session role stamp,
|
||||||
|
review-submission workspace failures must fail closed with structured
|
||||||
|
reasons, and runtime-context capabilities must apply the resolver's
|
||||||
|
role-exclusive filter.
|
||||||
|
|
||||||
|
Reproduces the PR #721 sequence: a reviewer session resolved a task whose
|
||||||
|
capability-map role had drifted to ``merger``; the denied resolve stamped
|
||||||
|
``_preflight_resolved_role = "merger"`` anyway, ``mark_final`` succeeded, and
|
||||||
|
``gitea_submit_pr_review`` raised RuntimeError out of the tool as a generic
|
||||||
|
``internal_error``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
|
||||||
|
REVIEWER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
|
||||||
|
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
|
||||||
|
},
|
||||||
|
"prgs-merger": {
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_preflight():
|
||||||
|
mcp_server._clear_preflight_capability_state()
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_whoami_violation = False
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolveHarness(unittest.TestCase):
|
||||||
|
"""Shared patched-resolver harness."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def _resolve(self, task, profile=REVIEWER_PROFILE, required_role=None):
|
||||||
|
"""Run gitea_resolve_task_capability with a fixed profile/config."""
|
||||||
|
patches = [
|
||||||
|
patch.object(mcp_server, "get_profile", return_value=profile),
|
||||||
|
patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=CONFIG
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="tester"
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "_ensure_matching_profile", return_value=None
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if required_role is not None:
|
||||||
|
patches.append(
|
||||||
|
patch.object(
|
||||||
|
mcp_server.task_capability_map,
|
||||||
|
"required_role",
|
||||||
|
side_effect=lambda t: (
|
||||||
|
required_role
|
||||||
|
if t == task
|
||||||
|
else task_capability_map.TASK_CAPABILITY_MAP[t]["role"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for p in patches:
|
||||||
|
p.__enter__()
|
||||||
|
try:
|
||||||
|
return mcp_server.gitea_resolve_task_capability(
|
||||||
|
task=task, remote="prgs"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for p in reversed(patches):
|
||||||
|
p.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC3DeniedResolveDoesNotStampRole(_ResolveHarness):
|
||||||
|
def test_allowed_resolve_stamps_role(self):
|
||||||
|
result = self._resolve("review_pr")
|
||||||
|
self.assertTrue(result["allowed_in_current_session"], result)
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||||
|
|
||||||
|
def test_denied_resolve_does_not_stamp_required_role(self):
|
||||||
|
"""The PR #721 poison: review_pr requiring merger under a reviewer."""
|
||||||
|
result = self._resolve("review_pr", required_role="merger")
|
||||||
|
self.assertFalse(result["allowed_in_current_session"], result)
|
||||||
|
self.assertIsNone(
|
||||||
|
mcp_server._preflight_resolved_role,
|
||||||
|
"denied resolve must not stamp the required role (#723 AC3)",
|
||||||
|
)
|
||||||
|
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||||
|
|
||||||
|
def test_denied_resolve_clears_prior_stale_stamp(self):
|
||||||
|
allowed = self._resolve("review_pr")
|
||||||
|
self.assertTrue(allowed["allowed_in_current_session"])
|
||||||
|
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||||
|
|
||||||
|
denied = self._resolve("merge_pr") # reviewer profile cannot merge
|
||||||
|
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||||
|
self.assertIsNone(
|
||||||
|
mcp_server._preflight_resolved_role,
|
||||||
|
"a denied resolve must clear the previous stamp, not keep it",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_denied_resolve_keeps_effective_role_on_actual_profile(self):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
):
|
||||||
|
self._resolve("review_pr", required_role="merger")
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._effective_workspace_role(), "reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC4SubmissionFailsClosedStructured(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_reset_preflight()
|
||||||
|
|
||||||
|
def test_workspace_binding_failure_returns_reasons_not_raise(self):
|
||||||
|
boom = RuntimeError(
|
||||||
|
"namespace workspace binding blocked: role 'merger' cannot "
|
||||||
|
"mutate from reviewer session workspace"
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_verify_role_mutation_workspace",
|
||||||
|
side_effect=boom,
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
):
|
||||||
|
result = mcp_server._evaluate_pr_review_submission(
|
||||||
|
pr_number=721,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha="80f59b334e6671b08006725292c08a8e8b6c823f",
|
||||||
|
remote="prgs",
|
||||||
|
live=True,
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||||
|
self.assertTrue(
|
||||||
|
any("workspace/role binding failed" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("cannot mutate from reviewer session" in r for r in result["reasons"]),
|
||||||
|
"the underlying binding error text must be preserved",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_runtime_failure_also_structured(self):
|
||||||
|
boom = RuntimeError(
|
||||||
|
"stale-runtime: The active Gitea MCP server process is stale"
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_verify_role_mutation_workspace",
|
||||||
|
side_effect=boom,
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
):
|
||||||
|
result = mcp_server._evaluate_pr_review_submission(
|
||||||
|
pr_number=721,
|
||||||
|
action="approve",
|
||||||
|
remote="prgs",
|
||||||
|
live=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||||
|
self.assertTrue(
|
||||||
|
any("stale-runtime" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dry_run_also_fails_closed_structured(self):
|
||||||
|
boom = RuntimeError("binding blocked")
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_verify_role_mutation_workspace",
|
||||||
|
side_effect=boom,
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
):
|
||||||
|
result = mcp_server._evaluate_pr_review_submission(
|
||||||
|
pr_number=721,
|
||||||
|
action="approve",
|
||||||
|
remote="prgs",
|
||||||
|
live=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["would_perform"])
|
||||||
|
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC5RuntimeContextRoleFilter(unittest.TestCase):
|
||||||
|
def test_role_filtered_view_matches_resolver_denial(self):
|
||||||
|
"""Reviewer session: merge_pr stays denied under the role filter even
|
||||||
|
when the permission is (pathologically) present."""
|
||||||
|
allowed_ops = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
]
|
||||||
|
caps = mcp_server._build_runtime_task_capabilities(
|
||||||
|
allowed_ops, [], CONFIG, active_role_kind="reviewer"
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertTrue(merge_entry["role_exclusive"])
|
||||||
|
self.assertEqual(merge_entry["capability_view"], "role_filtered")
|
||||||
|
self.assertFalse(
|
||||||
|
merge_entry["allowed_in_current_session"],
|
||||||
|
"role-exclusive merge_pr must stay denied for a reviewer role "
|
||||||
|
"even when the permission is present (#723 AC5)",
|
||||||
|
)
|
||||||
|
self.assertFalse(caps["can_merge_prs"])
|
||||||
|
|
||||||
|
def test_role_filter_restricts_matching_profiles(self):
|
||||||
|
caps = mcp_server._build_runtime_task_capabilities(
|
||||||
|
["gitea.read"], [], CONFIG, active_role_kind="author"
|
||||||
|
)
|
||||||
|
review_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "review_pr"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
review_entry["matching_configured_profiles"],
|
||||||
|
["prgs-reviewer"],
|
||||||
|
"role-exclusive review_pr must not list the merger profile",
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
merge_entry["matching_configured_profiles"], ["prgs-merger"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_permission_only_view_is_labeled(self):
|
||||||
|
caps = mcp_server._build_runtime_task_capabilities(
|
||||||
|
["gitea.pr.merge", "gitea.read"], [], CONFIG
|
||||||
|
)
|
||||||
|
merge_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||||
|
)
|
||||||
|
self.assertEqual(merge_entry["capability_view"], "permission_only")
|
||||||
|
# Legacy permission-only semantics preserved when no role supplied.
|
||||||
|
self.assertTrue(merge_entry["allowed_in_current_session"])
|
||||||
|
|
||||||
|
def test_incident_722_shape_runtime_context_agrees_with_resolver(self):
|
||||||
|
"""Post-970e68b shape: review_pr requires merger; a reviewer session
|
||||||
|
must see review_pr denied in runtime context, matching the resolver."""
|
||||||
|
with patch.object(
|
||||||
|
mcp_server.task_capability_map,
|
||||||
|
"required_role",
|
||||||
|
side_effect=lambda t: (
|
||||||
|
"merger"
|
||||||
|
if t == "review_pr"
|
||||||
|
else task_capability_map.TASK_CAPABILITY_MAP[t]["role"]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
caps = mcp_server._build_runtime_task_capabilities(
|
||||||
|
REVIEWER_PROFILE["allowed_operations"],
|
||||||
|
REVIEWER_PROFILE["forbidden_operations"],
|
||||||
|
CONFIG,
|
||||||
|
active_role_kind="reviewer",
|
||||||
|
)
|
||||||
|
review_entry = next(
|
||||||
|
t for t in caps["task_capabilities"] if t["task"] == "review_pr"
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
review_entry["allowed_in_current_session"],
|
||||||
|
"runtime context must not report review_pr allowed when the "
|
||||||
|
"resolver would fail-close it (incident #722)",
|
||||||
|
)
|
||||||
|
self.assertFalse(caps["can_review_prs"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||||
|
|
||||||
|
Enforces review 443 remediation:
|
||||||
|
|
||||||
|
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||||
|
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||||
|
restart is operator-owned; ADR is the authoritative split.
|
||||||
|
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||||
|
(stop → report → operator reload → re-verify parity) and is not a full
|
||||||
|
promotion ledger requirement.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADR = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "architecture"
|
||||||
|
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
)
|
||||||
|
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
|
||||||
|
CROSS_LINK_DOCS = (
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||||
|
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_exists_with_policy_core():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||||
|
assert "#615" in text
|
||||||
|
assert "stable control runtime" in text.lower()
|
||||||
|
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_operator_docs_cross_link_adr():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
assert ADR_BASENAME in text, (
|
||||||
|
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||||
|
f"(issue #615 acceptance criterion 2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||||
|
text = _read(ADR)
|
||||||
|
# Acceptance section must require guide/runbook cross-links.
|
||||||
|
assert "Operator guide / runbooks cross-link" in text or (
|
||||||
|
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||||
|
and "Acceptance" in text
|
||||||
|
)
|
||||||
|
# Cross-link must not remain only as optional tooling item #4.
|
||||||
|
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||||
|
"## 6. Acceptance", 1
|
||||||
|
)[0]
|
||||||
|
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||||
|
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||||
|
)
|
||||||
|
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||||
|
"must cross-link" in text.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
# Forbidden historical self-service instruction.
|
||||||
|
forbidden = (
|
||||||
|
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||||
|
"profile environment variable before claiming or working on any tasks"
|
||||||
|
)
|
||||||
|
assert forbidden not in runbooks, (
|
||||||
|
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||||
|
)
|
||||||
|
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||||
|
assert ADR_BASENAME in runbooks
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||||
|
section = section.split("## Safety notes", 1)[0]
|
||||||
|
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||||
|
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||||
|
assert "Operator-owned" in section or "operator" in section.lower()
|
||||||
|
assert "worktree_path" in section
|
||||||
|
collapsed = " ".join(section.lower().split())
|
||||||
|
assert "client reconnect" in collapsed
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "must not" in lower and "restart" in lower
|
||||||
|
assert "operator" in lower and "reload" in lower
|
||||||
|
assert "supersedes" in lower
|
||||||
|
assert "llm" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "2.6" in text
|
||||||
|
assert "post-merge" in lower or "post merge" in lower
|
||||||
|
assert "parity" in lower and "stale" in lower
|
||||||
|
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||||
|
assert "stop" in lower
|
||||||
|
assert "report" in lower
|
||||||
|
assert "operator" in lower
|
||||||
|
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||||
|
"startup/current-head" in lower
|
||||||
|
)
|
||||||
|
# Not a full promotion ledger for routine reload.
|
||||||
|
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||||
|
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||||
|
)
|
||||||
|
# Stale parity listed among unhealthy triggers.
|
||||||
|
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||||
|
assert "parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
|
assert marker not in text, f"{path} contains {marker!r}"
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
||||||
|
|
||||||
|
Added with the operator-authorized break-glass repair for incident #722:
|
||||||
|
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
||||||
|
configured merger profile forbids the review permissions, so no configured
|
||||||
|
profile could resolve any formal review task (``matching_configured_profile``
|
||||||
|
was empty repository-wide). These tests fail loudly if that class of
|
||||||
|
regression recurs:
|
||||||
|
|
||||||
|
- every role-exclusive formal-review task must be satisfiable by at least one
|
||||||
|
canonical role profile (permission AND role together);
|
||||||
|
- the capability map must agree with ``role_session_router`` task sets;
|
||||||
|
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
||||||
|
970e68b, preserved by the repair);
|
||||||
|
- merger profiles must not be able to resolve review_pr/approve_pr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||||
|
from task_capability_map import (
|
||||||
|
ROLE_EXCLUSIVE_TASKS,
|
||||||
|
TASK_CAPABILITY_MAP,
|
||||||
|
required_permission,
|
||||||
|
required_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical role-profile permission shape. Mirrors the configured
|
||||||
|
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||||
|
# reviewers review/approve/request changes but never merge; mergers merge but
|
||||||
|
# never review/approve/request changes.
|
||||||
|
CANONICAL_ROLE_PROFILES = {
|
||||||
|
"author": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reviewer": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"merger": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reconciler": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
||||||
|
# handling for review work): permission alone is not enough — the profile's
|
||||||
|
# role kind must also match, so both dimensions are pinned here.
|
||||||
|
FORMAL_REVIEW_TASKS = (
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"blind_pr_queue_review",
|
||||||
|
"pr_queue_cleanup",
|
||||||
|
"pr-queue-cleanup",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_satisfies(role_name, task):
|
||||||
|
"""True when the canonical *role_name* profile can perform *task*."""
|
||||||
|
profile = CANONICAL_ROLE_PROFILES[role_name]
|
||||||
|
ok, _reason = gitea_config.check_operation(
|
||||||
|
required_permission(task), profile["allowed"], profile["forbidden"]
|
||||||
|
)
|
||||||
|
return ok and role_name == required_role(task)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
||||||
|
"""#722: some configured profile must be able to formally review."""
|
||||||
|
|
||||||
|
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
||||||
|
for task in FORMAL_REVIEW_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
satisfying = [
|
||||||
|
role
|
||||||
|
for role in CANONICAL_ROLE_PROFILES
|
||||||
|
if _profile_satisfies(role, task)
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
satisfying,
|
||||||
|
f"no canonical role profile satisfies both permission "
|
||||||
|
f"{required_permission(task)!r} and role "
|
||||||
|
f"{required_role(task)!r} for task {task!r} — formal "
|
||||||
|
f"review would be impossible for every configured "
|
||||||
|
f"profile (incident #722)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_formal_review_tasks_are_reviewer_role(self):
|
||||||
|
for task in FORMAL_REVIEW_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(required_role(task), "reviewer")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapRouterAgreement(unittest.TestCase):
|
||||||
|
"""#723 AC2: the map and the role session router must not drift."""
|
||||||
|
|
||||||
|
def test_reviewer_tasks_map_to_reviewer_role(self):
|
||||||
|
for task in sorted(REVIEWER_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(
|
||||||
|
required_role(task),
|
||||||
|
"reviewer",
|
||||||
|
f"router classifies {task!r} as a reviewer task but the "
|
||||||
|
f"capability map assigns role {required_role(task)!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merger_tasks_map_to_merger_role(self):
|
||||||
|
for task in sorted(MERGER_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(
|
||||||
|
required_role(task),
|
||||||
|
"merger",
|
||||||
|
f"router classifies {task!r} as a merger task but the "
|
||||||
|
f"capability map assigns role {required_role(task)!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergerBoundary(unittest.TestCase):
|
||||||
|
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
||||||
|
|
||||||
|
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
||||||
|
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
||||||
|
self.assertEqual(
|
||||||
|
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merge_pr_requires_merger_role(self):
|
||||||
|
self.assertEqual(required_role("merge_pr"), "merger")
|
||||||
|
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
||||||
|
|
||||||
|
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
||||||
|
merger = CANONICAL_ROLE_PROFILES["merger"]
|
||||||
|
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
ok, reason = gitea_config.check_operation(
|
||||||
|
required_permission(task),
|
||||||
|
merger["allowed"],
|
||||||
|
merger["forbidden"],
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
ok,
|
||||||
|
f"merger profile must not hold {task!r} permission "
|
||||||
|
f"(got reason {reason!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleExclusiveSetIntegrity(unittest.TestCase):
|
||||||
|
"""#723 AC1/AC5: the shared role-exclusive set stays coherent."""
|
||||||
|
|
||||||
|
def test_every_role_exclusive_task_exists_in_capability_map(self):
|
||||||
|
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(
|
||||||
|
task,
|
||||||
|
TASK_CAPABILITY_MAP,
|
||||||
|
f"role-exclusive task {task!r} missing from the "
|
||||||
|
f"capability map — required_role() would raise and the "
|
||||||
|
f"resolver would 500 instead of failing closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_formal_review_tasks_are_role_exclusive(self):
|
||||||
|
for task in FORMAL_REVIEW_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(task, ROLE_EXCLUSIVE_TASKS)
|
||||||
|
|
||||||
|
def test_merge_pr_is_role_exclusive_and_merger_satisfiable(self):
|
||||||
|
"""AC1 merger equivalent: merging must stay possible for a canonical
|
||||||
|
merger profile (permission AND role together)."""
|
||||||
|
self.assertIn("merge_pr", ROLE_EXCLUSIVE_TASKS)
|
||||||
|
self.assertTrue(
|
||||||
|
_profile_satisfies("merger", "merge_pr"),
|
||||||
|
"no canonical merger profile satisfies merge_pr — merging would "
|
||||||
|
"be impossible for every configured profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_cleanup_tasks_stay_reconciler_satisfiable(self):
|
||||||
|
for task in ("cleanup_merged_pr_branch", "reconciliation_cleanup"):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertIn(task, ROLE_EXCLUSIVE_TASKS)
|
||||||
|
self.assertTrue(
|
||||||
|
_profile_satisfies("reconciler", task),
|
||||||
|
f"no canonical reconciler profile satisfies {task!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user