Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1421fa8568 | ||
|
|
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:
|
||||||
|
|||||||
@@ -9959,6 +9959,140 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number: int,
|
||||||
|
worktree: str,
|
||||||
|
candidate_head: str | None = None,
|
||||||
|
target_branch: str = "master",
|
||||||
|
target_branch_sha: str | None = None,
|
||||||
|
issue_number: int | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Acquire a per-PR lease for a merger session when no reviewer lease exists (#718).
|
||||||
|
|
||||||
|
Merger sessions should normally adopt an active reviewer lease using
|
||||||
|
gitea_adopt_merger_pr_lease. However, if no active reviewer lease exists
|
||||||
|
or it has expired, a merger can acquire one directly using this tool.
|
||||||
|
"""
|
||||||
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
|
if read_block:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": read_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.read"),
|
||||||
|
}
|
||||||
|
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||||
|
if comment_block:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": comment_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||||
|
}
|
||||||
|
merge_block = _profile_operation_gate("gitea.pr.merge")
|
||||||
|
if merge_block:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": merge_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.pr.merge"),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_verify_role_mutation_workspace(remote, worktree=worktree, task="acquire_merger_pr_lease")
|
||||||
|
except RuntimeError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": [str(e)],
|
||||||
|
}
|
||||||
|
|
||||||
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
auth = _auth(h)
|
||||||
|
profile = get_profile()
|
||||||
|
identity = _authenticated_username(h) or profile.get("username") or ""
|
||||||
|
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||||||
|
repo_label = f"{o}/{r}"
|
||||||
|
|
||||||
|
comments = _fetch_pr_comments(
|
||||||
|
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||||
|
|
||||||
|
pr_live = api_request(
|
||||||
|
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||||||
|
pr_merged_or_closed = bool(
|
||||||
|
pr_live.get("merged") or pr_live.get("merged_at")
|
||||||
|
) or (str(pr_live.get("state") or "").strip().lower() == "closed")
|
||||||
|
|
||||||
|
assessment = reviewer_pr_lease.assess_acquire_lease(
|
||||||
|
comments,
|
||||||
|
pr_number=pr_number,
|
||||||
|
reviewer_identity=identity,
|
||||||
|
profile=profile.get("profile_name") or "unknown",
|
||||||
|
session_id=sid,
|
||||||
|
repo=repo_label,
|
||||||
|
issue_number=issue_number,
|
||||||
|
worktree=worktree,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch=target_branch,
|
||||||
|
target_branch_sha=target_branch_sha,
|
||||||
|
pr_merged_or_closed=pr_merged_or_closed,
|
||||||
|
)
|
||||||
|
if not assessment.get("acquire_allowed"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": assessment.get("reasons") or [],
|
||||||
|
"existing_lease": assessment.get("existing_lease"),
|
||||||
|
"post_merge_moot": assessment.get("post_merge_moot", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
body = assessment["lease_body"]
|
||||||
|
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||||
|
with _audited(
|
||||||
|
"comment_pr",
|
||||||
|
host=h,
|
||||||
|
remote=remote,
|
||||||
|
org=o,
|
||||||
|
repo=r,
|
||||||
|
pr_number=pr_number,
|
||||||
|
request_metadata={"source": "acquire_merger_pr_lease"},
|
||||||
|
):
|
||||||
|
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||||
|
|
||||||
|
session_lease = reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"session_id": sid,
|
||||||
|
"reviewer_identity": identity,
|
||||||
|
"profile": profile.get("profile_name"),
|
||||||
|
"worktree": worktree,
|
||||||
|
"phase": "claimed",
|
||||||
|
"candidate_head": candidate_head,
|
||||||
|
"target_branch": target_branch,
|
||||||
|
"target_branch_sha": target_branch_sha,
|
||||||
|
"repo": repo_label,
|
||||||
|
"comment_id": posted.get("id"),
|
||||||
|
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||||||
|
source=merger_lease_adoption.SOURCE_ACQUIRE_MERGER,
|
||||||
|
comment_id=posted.get("id"),
|
||||||
|
))
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"acquired": True,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"session_id": sid,
|
||||||
|
"comment_id": posted.get("id"),
|
||||||
|
"session_lease": session_lease,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_adopt_merger_pr_lease(
|
def gitea_adopt_merger_pr_lease(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
|||||||
|
|
||||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||||
|
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||||
|
|
||||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||||
SOURCE_ADOPT,
|
SOURCE_ADOPT,
|
||||||
SOURCE_ACQUIRE,
|
SOURCE_ACQUIRE,
|
||||||
|
SOURCE_ACQUIRE_MERGER,
|
||||||
SOURCE_HEARTBEAT,
|
SOURCE_HEARTBEAT,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,8 +211,10 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
|||||||
r"(?is)\b("
|
r"(?is)\b("
|
||||||
r"gitea_adopt_merger_pr_lease|"
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
r"gitea_acquire_reviewer_pr_lease|"
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_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_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
r"adoption_comment_id\s*[:=]|"
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ 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",
|
||||||
|
},
|
||||||
|
"acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"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).
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Merger lease acquisition tests (#718)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._preflight_whoami_called = True
|
||||||
|
mcp_server._preflight_capability_called = True
|
||||||
|
mcp_server._preflight_resolved_role = "merger"
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("dadeschools", "org", "repo"))
|
||||||
|
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_success(self, _verify, _comments, _profile, _resolve, _auth, mock_api):
|
||||||
|
"""Merger lease acquisition succeeds when no reviewer lease exists."""
|
||||||
|
# api_request is called for PR state, then for POSTing comment
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"state": "open"}, # PR is open
|
||||||
|
{"id": 456}, # Posted comment ID
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertTrue(res["acquired"])
|
||||||
|
self.assertEqual(res["comment_id"], 456)
|
||||||
|
self.assertEqual(res["session_id"], "merger-session")
|
||||||
|
|
||||||
|
# Verify lease body includes merger identity
|
||||||
|
args, kwargs = mock_api.call_args_list[-1]
|
||||||
|
self.assertEqual(args[0], "POST")
|
||||||
|
body = args[3]["body"]
|
||||||
|
self.assertIn("reviewer_identity: merger-user", body)
|
||||||
|
self.assertIn("profile: prgs-merger", body)
|
||||||
|
self.assertIn("phase: claimed", body)
|
||||||
|
|
||||||
|
# Post-mutation readback: lease is recorded in session
|
||||||
|
session_lease = leases._SESSION_LEASE
|
||||||
|
self.assertIsNotNone(session_lease)
|
||||||
|
self.assertEqual(session_lease["phase"], "claimed")
|
||||||
|
self.assertEqual(session_lease["session_id"], "merger-session")
|
||||||
|
|
||||||
|
# Verify lease provenance uses SOURCE_ACQUIRE_MERGER
|
||||||
|
provenance = session_lease.get("lease_provenance") or {}
|
||||||
|
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("dadeschools", "org", "repo"))
|
||||||
|
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_if_active_exists(self, _verify, _comments, _profile, _resolve, _auth, mock_api):
|
||||||
|
"""Acquisition fails closed if another session holds an active lease."""
|
||||||
|
active_body = leases.format_lease_body(
|
||||||
|
repo="org/repo",
|
||||||
|
pr_number=718,
|
||||||
|
issue_number=None,
|
||||||
|
reviewer_identity="other-user",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="other-session",
|
||||||
|
worktree="branches/review-1",
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head="a" * 40,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
_comments.return_value = [{"id": 1, "body": active_body, "user": {"login": "other-user"}}]
|
||||||
|
|
||||||
|
# PR is open
|
||||||
|
mock_api.return_value = {"state": "open"}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["acquired"])
|
||||||
|
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
||||||
|
|
||||||
|
# No POST made
|
||||||
|
post_calls = [c for c in mock_api.call_args_list if c[0][0] == "POST"]
|
||||||
|
self.assertEqual(len(post_calls), 0)
|
||||||
|
|
||||||
|
# No partial state recorded
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, _profile):
|
||||||
|
"""Fails closed if workspace verification throws RuntimeError."""
|
||||||
|
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["acquired"])
|
||||||
|
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
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,205 @@
|
|||||||
|
"""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 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})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user