Compare commits

..
109 changed files with 4651 additions and 27766 deletions
-20
View File
@@ -1,20 +0,0 @@
## Description
[Summary of changes and issue number closed.]
Closes #[Issue Number]
## Checklist
- [ ] I have verified my identity matches the required role.
- [ ] No secrets, tokens, keychain IDs, or raw service URLs are committed.
- [ ] All tests pass for touched code.
- [ ] `git diff --check` is clean.
## Documentation and Wiki
- [ ] Does this change require a wiki update (workflows, tools, profiles, runbooks)?
- [ ] If yes, has `docs/wiki/` been updated accordingly?
- [ ] If wiki pages changed, plan the Gitea Wiki sync after merge (`scripts/sync-gitea-wiki.sh`, see Runbooks).
- [ ] Readiness gate (#224): the Gitea Wiki is populated and current for this repo — verify the repo **Wiki tab**, not `docs/wiki/`. If stale or empty, record the required sync as a follow-up before approval.
- [ ] If this PR closes a wiki-related issue: closure requires live Gitea Wiki proof links (Wiki Home plus page listing or wiki git log). Markdown in `docs/wiki/`, sync-helper code, or policy docs alone are not sufficient to close a wiki issue.
-4
View File
@@ -10,7 +10,3 @@ gitea-mcp*.json
.vscode/
graphify-out/
branches/
# Throwaway agent commit-encoding helpers (#261) — never commit.
/_encode_*.py
/_emit_*.py
/_inline_*.py
-8
View File
@@ -172,14 +172,6 @@ Recognized environment fields (see [`.env.example`](.env.example) for placeholde
| `GITEA_MCP_CONFIG` | Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour. |
| `GITEA_MCP_PROFILE` | Name of the profile (from `GITEA_MCP_CONFIG`) to activate for this runtime. |
#### External MCP Control Plane servers
Jenkins and GlitchTip are separate MCP trust boundaries, not tools inside this
Gitea MCP runtime. Register them as `jenkins-mcp` and `glitchtip-mcp` in the
client that will use them, then reconnect or reload the client and verify the
expected tools are visible before claiming readiness. See
[`docs/mcp-client-registration.md`](docs/mcp-client-registration.md).
Notes:
- This provides **one token + one profile per process**. It does not implement
-26
View File
@@ -1,26 +0,0 @@
"""Detect throwaway agent helper scripts left in the repo root (#261)."""
from __future__ import annotations
import fnmatch
AGENT_TEMP_BASENAME_PATTERNS = (
"_encode_*.py",
"_emit_*.py",
"_inline_*.py",
)
def find_agent_temp_artifacts_from_porcelain(porcelain: str) -> list[str]:
"""Return untracked repo-root helper paths matching agent temp patterns."""
found: list[str] = []
for line in (porcelain or "").splitlines():
if not line.startswith("??"):
continue
path = line[3:].strip()
if not path or "/" in path or "\\" in path:
continue
basename = path.split("/")[-1]
if any(fnmatch.fnmatch(basename, pat) for pat in AGENT_TEMP_BASENAME_PATTERNS):
found.append(path)
return sorted(found)
-217
View File
@@ -1,217 +0,0 @@
"""Fail-closed branch-identity proofs for author workflows (#177).
Author-side counterpart of the reviewer proofs in ``review_proofs.py``
(#173). During the #173 implementation itself, a commit landed on local
``master`` because the shared checkout's branch moved mid-session (origin
incident of #177). These helpers turn that from an after-the-fact repair
into a fail-closed gate: an author workflow must prove its local git state
before staging, committing, or pushing.
The helpers are pure (no git calls): the workflow gathers the raw facts
(``git branch --show-current``, ``git rev-parse HEAD``, the push refspec,
the branch named in the issue claim) and passes them in, so the same logic
works from prompts, harness assertions, and tests. Shared-worktree branch
switches by other sessions are treated as expected events to detect, not
exceptional ones. Nothing here weakens the review/merge/permission gates.
"""
PROTECTED_BRANCHES = frozenset(
{"master", "main", "develop", "development", "dev"}
)
def _clean(name):
return (name or "").strip()
def verify_branch_for_commit(current_branch, intended_branch):
"""Required behavior 1: prove the branch before staging/committing.
Proven only when both names are present, the intended branch is not a
protected branch, and the current branch equals the intended one (which
also rules out being on any protected branch). Returns {'proven',
'block', 'reasons', 'current_branch', 'intended_branch'}.
"""
reasons = []
current = _clean(current_branch)
intended = _clean(intended_branch)
if not current:
reasons.append(
"current branch unknown (detached HEAD or state not read); "
"fail closed"
)
if not intended:
reasons.append("intended feature branch not stated; fail closed")
if intended and intended in PROTECTED_BRANCHES:
reasons.append(
f"intended branch '{intended}' is a protected branch; author "
"work must target a feature branch"
)
if current and current in PROTECTED_BRANCHES:
reasons.append(
f"current branch '{current}' is a protected branch; committing "
"here is blocked"
)
if current and intended and current != intended:
reasons.append(
f"current branch '{current}' is not the intended feature branch "
f"'{intended}'; stop before staging/committing"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"current_branch": current or None,
"intended_branch": intended or None,
}
def detect_branch_drift(branch_at_validation, head_at_validation,
current_branch, current_head):
"""Required behaviors 23: stop when branch or HEAD moved mid-session.
Compares the branch name and HEAD SHA captured at validation time with
the state observed immediately before commit/push. Any difference —
including an external branch switch in a shared worktree — is drift and
blocks until reconciled. Missing state fails closed.
"""
reasons = []
branch_then = _clean(branch_at_validation)
branch_now = _clean(current_branch)
head_then = _clean(head_at_validation).lower()
head_now = _clean(current_head).lower()
if not branch_then or not head_then:
reasons.append("validation-time branch/HEAD not recorded; fail closed")
if not branch_now or not head_now:
reasons.append("current branch/HEAD not read; fail closed")
if branch_then and branch_now and branch_then != branch_now:
reasons.append(
f"branch changed from '{branch_then}' to '{branch_now}' since "
"validation — possible external branch switch in a shared "
"worktree; stop and reconcile before committing"
)
if head_then and head_now and head_then != head_now:
reasons.append(
"HEAD moved since validation; re-validate on the current HEAD "
"before committing"
)
drifted = bool(reasons)
return {"drifted": drifted, "block": drifted, "reasons": reasons}
def verify_push_target(current_branch, remote_target_branch, intended_branch):
"""Acceptance: a push needs local, remote, and intended branches to match.
Proven only when all three names are present, equal, and not a
protected branch — a feature-branch workflow never pushes a protected
branch, and never pushes to a refspec other than its own branch.
"""
reasons = []
current = _clean(current_branch)
remote_target = _clean(remote_target_branch)
intended = _clean(intended_branch)
if not current:
reasons.append("current branch unknown; fail closed")
if not remote_target:
reasons.append("remote target branch not stated; fail closed")
if not intended:
reasons.append("intended feature branch not stated; fail closed")
for label, name in (("current", current), ("remote target", remote_target),
("intended", intended)):
if name and name in PROTECTED_BRANCHES:
reasons.append(
f"{label} branch '{name}' is a protected branch; author "
"pushes to protected branches are blocked"
)
if current and remote_target and current != remote_target:
reasons.append(
f"push target '{remote_target}' does not match the local branch "
f"'{current}'"
)
if current and intended and current != intended:
reasons.append(
f"local branch '{current}' does not match the intended feature "
f"branch '{intended}'"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
}
def assess_protected_branch_commit(commit_branch, pushed=False,
repair_reported=True):
"""Required behavior 4: handle an accidental protected-branch commit.
If a commit landed on a protected branch: it must never be pushed, a
repair is required, and the repair must be *reported* — silently
continuing after (or without) repair is a violation, as is having
pushed the accident.
"""
branch = _clean(commit_branch)
accident = branch in PROTECTED_BRANCHES
violations = []
if accident:
if pushed:
violations.append(
f"accidental commit on protected branch '{branch}' was "
"pushed; protected-branch pushes are forbidden"
)
if not repair_reported:
violations.append(
"protected-branch commit repair was not reported; the "
"workflow must surface the accident and the repair steps, "
"never silently continue"
)
return {
"accident": accident,
"must_not_push": accident,
"repair_required": accident,
"violations": violations,
}
def build_commit_push_report(commit_proof, drift, push_proof, accident=None):
"""Acceptance: final report carries branch proof before commit and push.
Combines the individual proofs; any failed proof, detected drift, or
accident violation makes the status 'blocked' — the workflow stops and
reports instead of continuing.
"""
accident = accident or {"accident": False, "violations": []}
violations = list(accident.get("violations", []))
blocked = (
not commit_proof.get("proven")
or drift.get("drifted")
or not push_proof.get("proven")
or bool(violations)
)
return {
"status": "blocked" if blocked else "ok",
"branch_proof_before_commit": bool(commit_proof.get("proven")),
"branch_proof_before_push": bool(push_proof.get("proven")),
"drift_detected": bool(drift.get("drifted")),
"protected_branch_accident": bool(accident.get("accident")),
"violations": violations,
"reasons": (
list(commit_proof.get("reasons", []))
+ list(drift.get("reasons", []))
+ list(push_proof.get("reasons", []))
),
}
-232
View File
@@ -1,232 +0,0 @@
"""Hard-stop terminal mode after reviewer capability denial (#197)."""
from __future__ import annotations
import re
TERMINAL_REPORT_HEADING = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed."
)
REVIEWER_CAPABILITY_TASKS = frozenset({
"review_pr",
"merge_pr",
"blind_pr_queue_review",
"request_changes_pr",
"approve_pr",
})
BLOCKED_QUEUE_TOOLS = frozenset({
"list_prs",
"check_pr_eligibility",
"view_pr",
"submit_pr_review",
"dry_run_pr_review",
"merge_pr",
"review_pr",
})
_session_terminal: dict | None = None
def enter_from_capability_result(capability: dict) -> dict | None:
"""Enter terminal mode when a reviewer/merge task is denied."""
global _session_terminal
task = (capability or {}).get("requested_task", "")
required_role = (capability or {}).get("required_role_kind")
if not capability.get("stop_required"):
return None
if required_role != "reviewer" and task not in REVIEWER_CAPABILITY_TASKS:
return None
record = {
"active": True,
"requested_task": task,
"required_role_kind": required_role,
"active_profile": capability.get("active_profile"),
"active_identity": capability.get("active_identity"),
"stop_required": True,
"exact_safe_next_action": capability.get("exact_safe_next_action"),
"terminal_message": TERMINAL_REPORT_HEADING,
}
_session_terminal = record
return dict(record)
def _is_reviewer_denial(capability: dict) -> bool:
task = (capability or {}).get("requested_task", "")
required_role = (capability or {}).get("required_role_kind")
return (
required_role == "reviewer"
or task in REVIEWER_CAPABILITY_TASKS
)
def sync_from_capability_result(capability: dict) -> dict | None:
"""Enter or clear terminal mode from a capability resolution (#238).
Reviewer denials activate terminal mode for the denied operation only.
A later allowed task route clears stale denial state so author read-only
tools (e.g. ``list_prs``) are not permanently blocked.
"""
if (capability or {}).get("stop_required") and _is_reviewer_denial(capability):
return enter_from_capability_result(capability)
clear()
return None
def enter_from_route_result(route: dict) -> dict | None:
"""Enter terminal mode from a role router wrong_role_stop (#206 compat)."""
if (route or {}).get("route_result") != "wrong_role_stop":
return None
if route.get("required_role") != "reviewer":
return None
return enter_from_capability_result({
"requested_task": route.get("task_type"),
"required_role_kind": "reviewer",
"stop_required": True,
"active_profile": route.get("active_profile"),
"active_identity": None,
"exact_safe_next_action": route.get("message"),
})
def is_active() -> bool:
return bool(_session_terminal and _session_terminal.get("active"))
def active_record() -> dict | None:
if not is_active():
return None
return dict(_session_terminal)
def clear():
global _session_terminal
_session_terminal = None
def check_reviewer_queue_tool(tool_name: str) -> tuple[bool, list[str]]:
"""Return (allowed, reasons). False when terminal mode blocks queue work."""
if not is_active():
return True, []
name = (tool_name or "").strip().lower().removeprefix("gitea_")
if name in BLOCKED_QUEUE_TOOLS:
denied_task = (_session_terminal or {}).get("requested_task") or "unknown"
return False, [
TERMINAL_REPORT_HEADING,
f"Reviewer queue tool '{tool_name}' is blocked by the current "
f"capability denial for task '{denied_task}' (fail closed).",
"Resolve or route an allowed author task to clear stale denial "
"state, or relaunch a reviewer MCP namespace for reviewer work.",
]
return True, []
def validate_eligibility_wording(text: str) -> tuple[bool, list[str]]:
"""Reject session-based eligibility reasoning (#197)."""
lower = (text or "").lower()
violations = []
if "not authored by this session" in lower:
violations.append(
"eligibility must use authenticated account identity, not "
"'this session' wording"
)
if re.search(r"not (?:self-)?authored by (?:the )?session", lower):
violations.append("session-based eligibility reasoning is invalid")
return (len(violations) == 0), violations
def assess_capability_stop_report(
report_text: str,
*,
trust_gate_status: str | None = None,
capability_denied: bool = True,
) -> dict:
"""Validate final report purity after reviewer capability denial."""
text = report_text or ""
lower = text.lower()
violations = []
if capability_denied and TERMINAL_REPORT_HEADING.lower() not in lower:
violations.append("missing required terminal report heading")
forbidden_patterns = [
("pr selection", re.compile(
r"selected pr|pr #\d+ (?:to review|selected)|eligible pr|"
r"next pr to review", re.I)),
("sibling repo inventory", re.compile(
r"sibling repo|other repo|mcp-control-plane|gitea-tools and", re.I)),
("author fallback", re.compile(
r"rebase conflicted|author-side fallback|have me rebase|"
r"implement the fix|push a branch|open a pr for", re.I)),
("invalid session eligibility", re.compile(
r"not authored by this session", re.I)),
]
for label, pattern in forbidden_patterns:
if pattern.search(text):
violations.append(f"forbidden after hard stop: {label}")
empty_queue_patterns = re.compile(
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|"
r"inventory empty",
re.I,
)
parsed_status = None
for line in text.splitlines():
if "pr_inventory_trust_gate.status:" in line.lower():
parsed_status = line.split(":", 1)[1].strip()
break
effective_status = trust_gate_status or parsed_status
if empty_queue_patterns.search(text):
if effective_status != "trusted_empty":
violations.append(
"empty-queue claim after capability stop without "
"pr_inventory_trust_gate.status == trusted_empty"
)
ok, elig_violations = validate_eligibility_wording(text)
violations.extend(elig_violations)
if violations:
return {
"pure": False,
"downgraded": True,
"violations": violations,
"reasons": violations,
}
return {
"pure": True,
"downgraded": False,
"violations": [],
"reasons": [],
}
def build_terminal_report(capability: dict) -> dict:
"""Minimal allowed report fields after hard stop."""
return {
"terminal_mode": True,
"heading": TERMINAL_REPORT_HEADING,
"authenticated_profile": capability.get("active_profile"),
"authenticated_identity": capability.get("active_identity"),
"denied_task": capability.get("requested_task"),
"required_role_kind": capability.get("required_role_kind"),
"stop_required": capability.get("stop_required"),
"required_action": capability.get("exact_safe_next_action"),
"mutations_performed": False,
"allowed_sections": [
"authenticated identity/profile",
"denied capability result",
"reason task cannot proceed",
"required reviewer profile/identity",
"mutation confirmation (none)",
],
"forbidden_sections": [
"PR selection",
"sibling-repo queue recommendations",
"author-side fallback suggestions",
"empty-queue claims without trusted_empty",
"session-based eligibility wording",
],
}
@@ -22,13 +22,13 @@ Strictly read-only, per ADR-0001:
tools — never one dual-credential server).
- **This server never holds Gitea write credentials.**
## 2. Boundary placement
## 2. Boundary placement (namespace pending)
These tools belong to the GlitchTip observability boundary of the MCP Control
Plane. The canonical MCP server name is `glitchtip-mcp`; client registration
and reload instructions live in
[`../mcp-client-registration.md`](../mcp-client-registration.md). Tool names
below use the `glitchtip_` prefix.
Plane`glitchtip-mcp` (ADR-0001's recommendation), `observability-mcp`, or
folded into `ops-mcp`. **ADR-0001 open owner decision #2 picks the name; this
design does not assume it.** Tool names below use the `glitchtip_` prefix for
readability and rename mechanically with the decision.
Fixed regardless of the name (per `tool-boundaries.md`,
`credential-isolation.md`):
@@ -160,13 +160,11 @@ Mocked-GlitchTip unit tests only, per `docs/developer-testing-guidelines.md`:
## 10. Implementation-readiness checklist
Ready to operate once:
Ready to implement once:
1. The MCP client registers the external server under the exact `glitchtip-mcp`
name and is reconnected/reloaded.
2. Tool discoverability proves the expected `glitchtip_*` read tools are
visible; if the server is enabled but exposes no usable tools, report
`SKIPPED` and stop.
1. ADR-0001 owner decision #2 (namespace/placement) is made — mechanical
rename of the `glitchtip_` prefix if needed.
2. ADR-0001 owner decision #1 (repo home) is made.
3. #76 profile schema exists (or a minimal `glitchtip-readonly` profile is
hand-rolled to the same rules).
4. A pinned GlitchTip version is chosen for API-subset testing (§3).
@@ -1,34 +1,21 @@
# GlitchTip-to-Gitea Issue Filing Workflow Contract
# GlitchTip-to-Gitea Issue Filing Workflow Design
- **Status:** Contract for the library-only implementation in
`Scaled-Tech-Consulting/mcp-control-plane` (#57)
- **Issue:** #153 (supersedes #74/#78 as the Gitea-Tools tracker)
- **Status:** Design (no implementation in this repo)
- **Issue:** #74 (parent umbrella: #75)
- **Related:** #78 (deduplication design, child of #74)
- **Date:** 2026-07-07
- **Date:** 2026-07-02
## 1. Boundary and Orchestration
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
* The filing capability lives in a **library-only orchestrator** in
`mcp-control-plane` and is not exposed through `glitchtip-mcp`.
* The orchestrator composes the real GlitchTip read path with Gitea
issue-write tools. It must not use mocked GlitchTip issue data in production
filing paths.
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
* If a future MCP surface exposes filing, it must be a separate write-boundary
server/profile with explicit Gitea issue-write permission and the same audit
gates. It must not be added to the read-only `glitchtip-mcp` surface.
## 2. Invocation and Safety
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation
relies on `gitea-mcp`, and therefore must pass Gitea profile checks and
fail-closed mutation audit before create/link/comment actions.
* **Deduplication before create:** The orchestrator must run the
GlitchTip-to-Gitea dedup/linking logic before any Gitea create action. Create,
link, and skip decisions must be represented in tests.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
## 3. Gitea Issue Format
@@ -64,23 +51,6 @@ To prevent PII or secret leakage into Gitea, the orchestrator and the underlying
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
## 5. Deduplication and Linking
## 5. Deduplication and Linking (Deferred)
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip
issue IDs, and race condition handling) is integrated into the library-only
filing orchestrator in `mcp-control-plane`. The orchestrator must reuse that
dedup/linking path instead of duplicating or bypassing it.
## 6. Gitea-Tools Acceptance Contract for #153
Gitea-Tools does not host the filing implementation. This repository owns the
operator-facing contract:
* `glitchtip-mcp` remains read-only and exposes only GlitchTip inspection tools.
* Filing is implemented in `mcp-control-plane`, not in this Gitea MCP runtime.
* Filing uses the real GlitchTip read path, not mocked issue data.
* Dedup runs before any Gitea create action.
* Create/link/skip decisions and audit failure are covered by orchestrator tests.
* Mutation audit fails closed before any Gitea create, link, or comment mutation.
* Any future MCP exposure requires a separate write-boundary server/profile and
must not add Gitea write credentials to `glitchtip-mcp`.
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
@@ -5,9 +5,7 @@
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
- **Date:** 2026-07-02
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The read server boundary remains read-only; gated triggers live on the separate `jenkins-write-mcp` / `jenkins_mcp.write_server` boundary (see #56 / #152).
Client registration and reload instructions live in
[`../mcp-client-registration.md`](../mcp-client-registration.md).
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The server boundary now contains gated trigger (see #56), but read tools remain as designed.
## 1. Purpose and scope
@@ -18,9 +16,7 @@ detail (build URL, number, timing, result) to report or investigate.
Phase 1 is **primarily read-only**, per ADR-0001
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
- Build triggers are outside this read-only surface and require the separate
`jenkins-write-mcp` boundary, a dedicated profile, exact confirmation, and
fail-closed mutation audit (landed in #4, boundary correction in #56 / #152).
- Build triggers are gated behind dedicated profile + exact confirmation (landed in #4, boundary correction in #56).
- **Excluded: deploy triggers.**
- **Excluded: parameterized job launches.**
- Excluded: job creation/deletion/config changes, queue manipulation, node
@@ -111,7 +107,7 @@ by #76):
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
as belt-and-braces even though no mutating tool exists.
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
- Since every tool on `jenkins-mcp` is read-only, no confirmation gates are needed — but
- Since every tool is read-only, no confirmation gates are needed — but
identity (`jenkins_whoami`) must still work so workflows can prove which
Jenkins account they act as.
@@ -145,16 +141,12 @@ repo's conventions (`docs/developer-testing-guidelines.md`):
## 10. Implementation-readiness checklist
Ready to operate through `jenkins-mcp` once:
Ready to implement in `jenkins-mcp` once:
1. The MCP client registers the external server under the exact `jenkins-mcp`
name and is reconnected/reloaded.
2. Tool discoverability proves the expected `jenkins_*` read tools are visible;
if the server is enabled but exposes no usable tools, report `SKIPPED` and
stop.
3. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
1. ADR-0001 owner decision #1 (where `jenkins-mcp` lives) is made.
2. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
hand-rolled to the same rules).
4. #77 mapping design is accepted (or tools ship path-addressed only, mapping
3. #77 mapping design is accepted (or tools ship path-addressed only, mapping
deferred).
Explicitly **not** unlocked by this document: build triggers, deploys,
-34
View File
@@ -203,29 +203,6 @@ remote/org/repo arguments. Create operations are audit-logged
redacted, and normal output contains no endpoint URLs
(`GITEA_MCP_REVEAL_ENDPOINTS=1` is the local admin opt-in for web links).
## PR edits versus PR closure (#216)
Editing a pull request and closing one are different capabilities:
- **PR edits** (`gitea_edit_pr` with `title`/`body`/`base`, or reopening with
`state="open"`) stay on the ordinary edit path and need no dedicated
capability.
- **PR closure** (`gitea_edit_pr` with `state="closed"`) requires the
distinct `gitea.pr.close` operation. The resolver task is `close_pr`
(`gitea_resolve_task_capability(task="close_pr")`, author-side). Without
`gitea.pr.close` the close attempt fails closed — no API call, structured
`permission_report` — so the broad edit path can never be used as an
untracked close fallback.
- Closures are audited as a distinct `close_pr` action with
`required_permission: gitea.pr.close` in the request metadata, so final
reports can prove exactly which mutation capability was exercised (#191).
`gitea.pr.close` has no legacy alias; spell it canonically. It is not part of
any default profile: the operator grants it deliberately (e.g. for an
explicit operator-directed closure of a contaminated PR). If `close_pr` ever
resolves as unknown, agents must fail closed rather than fall back to the
edit path.
## Identity and fail-closed rules
Before **any** mutating action, a workflow must know both:
@@ -298,17 +275,6 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
This guarantees the active profile operations align with the actual Gitea authenticated user credential.
## Gitea MCP Runtime Isolation and Worktree Safety
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
- **Startup Conflict Check:** The MCP server (`mcp_server.py`) acts as a conflict-free loader. On startup, it scans all Python files in the directory for unresolved git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`). If any are found, it prints an `infra_stop` message and exits immediately.
- **Workflow Guard:** Before starting reviewer work, task routing checks if the MCP runtime source is mid-merge (by checking for `.git/MERGE_HEAD` or conflict markers). If dirty, it returns `infra_stop` (never `wrong_role_stop` or empty queue) to prevent unsafe mutations.
- **Recovery Instructions:** To recover from an `infra_stop` state:
1. Resolve all merge conflicts in the local repository or abort the merge (`git merge --abort` / `git rebase --abort`).
2. Restart the Gitea MCP server process.
3. Retry the task.
## Relationship to roadmap issues
This document defines the **model only**. Related work is tracked separately
-175
View File
@@ -30,11 +30,6 @@ audit logging). See [Related documents](#related-documents).
> the standard rules; operator prompts still control task-specific scope.
> See issue #129 for the skill registry design.
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
runtime. Register them as `jenkins-mcp` and `glitchtip-mcp`, reconnect or
reload the client, and verify visible tools before claiming either integration
is usable. See [`mcp-client-registration.md`](mcp-client-registration.md).
For cross-project use, copy the portable workflow skill at
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
@@ -229,122 +224,6 @@ Legacy environment-only setups keep working unchanged until migrated.
Each runbook names the **profile role** it runs under, the steps, and a safe
prompt. Confirm the active profile first (`gitea_get_profile` / `gitea_whoami`).
## Work Selection Rule for LLMs
Before starting any issue or PR work, acquire or verify a work lease. Do not
begin coding, reviewing, fixing, branching, committing, pushing, commenting,
or creating a PR until you prove the target is not already being worked.
Required checks:
1. List open PRs.
2. Search for PRs linked to the target issue.
3. Search local and remote branches for the issue number.
4. Search registered worktrees for the issue branch.
5. Check dirty worktrees.
6. Check active leases or recent handoffs.
7. Check whether the issue was already completed by a merged PR.
If another active LLM/session owns the lease, stop. Allowed responses:
continue as the lease owner; review the existing PR if reviewer capability
allows; produce a handoff; request takeover after lease expiry; stop with
"work already claimed."
Never create a parallel branch or PR for the same issue unless the old branch
is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. Full portable wording:
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Global LLM Worktree Rule
The main project checkout is a stable control checkout. It must stay on the
configured stable branch: `master`, `main`, or `dev`.
All LLM task work must happen inside the project's `branches/` directory.
Before any mutation, prove:
1. current project root
2. current working directory
3. current branch
4. stable branch for the main checkout
5. session-owned worktree path under `branches/`
If `cwd` is not inside `branches/`, stop. Do not edit, create, delete, format,
test-write, commit, merge, rebase, checkout task branches, resolve conflicts,
or run cleanup.
There are no exceptions for small fixes, docs, tests, cleanup, PR review fixes,
conflict resolution, or emergencies.
The main checkout may only be used for read-only inspection, fetching,
stable-branch update after merged PRs, creating `branches/` worktrees, or
explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
That is an executor spawn failure, not a command failure — the command never
ran, and retrying the identical call cannot succeed.
Required behavior (fail closed, issue #258):
1. **Probe once.** On the first spawn failure, run one trivial probe
(`echo ok` or `pwd`). If the probe also returns `exit_code: -1`, mark
shell unavailable for the session.
2. **Hard-stop at two.** After two consecutive spawn failures, stop all
further shell tool use for the session; never retry the same failing
spawn. A hundred retries produce a hundred identical failures (session
`019f382e`: 100+ tool calls stalled on a trivial encode-and-commit task).
3. **Emit a recovery report.** The report must direct the operator to:
- restart the session,
- kill hung background terminals (a hung test runner holding the
executor is a known contributor),
- prefer MCP-native paths for remaining mutations (for example
`gitea_commit_files` under `gitea.repo.commit`) instead of shell.
4. **No improvised fallbacks.** Shell unavailability never authorizes
WebFetch/browser/manual-encoding workarounds (see #260). No shell means
stop-and-report.
Doc-contract tests: `tests/test_shell_spawn_hard_stop_docs.py`.
## Subagent Tool-Budget Guardrails
General-purpose subagents tasked with **deterministic MCP work** (for example a
single `gitea_commit_files` call) have expanded to 100122 tool calls,
WebFetch/Playwright fallbacks, and throwaway helper-script generation instead of
calling the native MCP tool once (observed during #152 closure, issue #259).
**Default budgets** (fail closed when exceeded):
| Task class | Max tool calls | Max wall time |
|------------|----------------|---------------|
| Single-step MCP mutation (`commit_files`, `create_pr`, `lock_issue`) | 15 | 5 minutes |
| Review / merge queue inspection | 40 | 15 minutes |
| Exploration / codebase search (non-mutating) | 60 | 20 minutes |
**Required behavior:**
1. **Main session first.** When the active author profile allows
`gitea.repo.commit` and `gitea_commit_files` is visible, the main session
must call it directly — do not delegate commit authority to a subagent
(see #260).
2. **Native MCP before fallback.** After a shell spawn failure (#258), attempt
the native MCP tool once before any alternate path. Shell unavailability
never authorizes WebFetch, Playwright, or manual base64 encoding.
3. **No retry spirals.** Never resume a failed subagent into a larger retry
loop or spawn a second subagent for the same deterministic step. Stop and
emit a recovery report instead.
4. **Forbidden detours** when `gitea_commit_files` is available: WebFetch,
Playwright/browser automation, manual LLM-generated base64, and ad-hoc
`_encode_*` / `_emit_*` helper scripts left in the repo.
Doc-contract tests: `tests/test_subagent_tool_budget_docs.py`.
## Branch worktree isolation
All LLM implementation and review work happens in an isolated branch worktree
@@ -376,30 +255,6 @@ may edit another issue's branch folder unless explicitly assigned to that issue.
No LLM may clean another issue's branch folder unless the PR is merged or closed
and cleanup is explicitly part of the task.
## Agent temp artifact cleanup (#261)
Failed or aborted MCP commit attempts sometimes leave throwaway helper scripts in
the **repository root**. These are not part of any issue scope and pollute
`git status`, which can break `gitea_lock_issue` and preflight checks.
**Patterns (repo root only, untracked):**
- `_encode_*.py` — base64 payload encoders
- `_emit_*.py` — commit payload emitters
- `_inline_*.py` — inline encoding helpers
**Required cleanup (after MCP commit completes or aborts):**
1. Delete any matching files at the repo root (`rm ./_encode_*.py` etc.).
2. Confirm `git status` is clean on the orchestration checkout before
`gitea_lock_issue`.
3. Prefer native `gitea_commit_files` / gated commit paths — do not leave shell
encoding fallbacks behind.
Root-level matches are listed in `.gitignore` so they never get committed.
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
hard blocks) when these artifacts are still present.
Implementation work and review work must use separate branch folders. For
example, an implementation branch might live under
`branches/fix-issue-123-example`, while a review branch for the resulting PR
@@ -454,36 +309,6 @@ git branch -d fix/issue-123-example
All three helpers accept `--dry-run` to print the exact commands/paths without
touching anything.
### MCP-native commit path (#260)
When the active author profile allows **`gitea.repo.commit`** and
**`gitea_commit_files`** is visible in the client, that is the **only** approved
path for committing files to the tracked repository. Do not improvise alternate
encoding or transport when MCP commit is available.
**Required before commit:**
1. Call `gitea_resolve_task_capability` for `commit_files` or
`gitea_commit_files` and confirm `allowed_in_current_session` is true.
2. Use `gitea_commit_files` with file payloads prepared in the author worktree.
3. Stage only issue-scoped paths; never commit throwaway `_encode_*` /
`_emit_*` / `_inline_*` helpers.
**Explicitly forbidden workarounds** when MCP commit is reachable:
- `WebFetch` / HTTP calls to external decode sites (for example httpbin base64
endpoints)
- Playwright or other browser automation to bypass MCP
- Manual LLM-generated base64 pasted into ad-hoc scripts
- Delegating commit authority to a subagent while the main session has
`gitea.repo.commit` on an author profile
**If shell encoding is unavailable** (spawn failure, hung terminal) **and** MCP
commit cannot run: **stop** with a recovery report. Mention restarting the
session, clearing hung background terminals, switching to MCP-native commit, and
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
loop and do **not** substitute WebFetch/Playwright/manual base64.
### Create an issue / child issues
- **Profile:** issue-manager or author (any profile allowed to create issues).
-108
View File
@@ -1,108 +0,0 @@
# MCP Client Registration for External Control Plane Servers
Issue #151 fixes the registration and naming contract for the Jenkins and
GlitchTip MCP servers that live outside this Gitea MCP runtime.
## Canonical Server Names
Use these exact MCP server names in clients:
| Server name | Boundary | Default capability |
|---|---|---|
| `jenkins-mcp` | Jenkins CI inspection (read) | Read-only build/job inspection |
| `jenkins-write-mcp` | Jenkins build trigger (write) | Gated `jenkins_trigger_build` only |
| `glitchtip-mcp` | GlitchTip observability inspection | Read-only issue/event inspection |
The write boundary (`jenkins-write-mcp`) is **not** registered by default (#152).
It exposes a single mutating tool and requires operator approval of a dedicated
trigger profile before any client config references it.
Historical names such as `jenkins-readonly` and `glitchtip-readonly` are
descriptive profile labels only. They are not the canonical MCP server names
unless an operator intentionally creates aliases and documents them.
## Registration Pattern
Register each external server as its own MCP entry. Do not add Jenkins or
GlitchTip credentials to the Gitea MCP server. Also, do not add Gitea write
credentials to the GlitchTip server.
```json
{
"mcpServers": {
"jenkins-mcp": {
"command": "/path/to/mcp-control-plane/venv/bin/python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_CONFIG": "/path/to/mcp-control-plane/profiles.json",
"JENKINS_MCP_PROFILE": "jenkins-readonly"
}
},
"glitchtip-mcp": {
"command": "/path/to/mcp-control-plane/venv/bin/python3",
"args": ["-m", "glitchtip_mcp"],
"env": {
"GLITCHTIP_MCP_CONFIG": "/path/to/mcp-control-plane/profiles.json",
"GLITCHTIP_MCP_PROFILE": "glitchtip-readonly"
}
}
}
}
```
Client-specific wrappers may differ, but the server names, trust boundaries,
and profile separation must remain the same. After adding or changing either
entry, reconnect or reload the MCP client before claiming the tools are usable.
## Discoverability Validation
Before using either server in a task, prove the expected tools are visible in
the client. It is not enough for the config entry to exist.
Expected Jenkins read tools (`jenkins-mcp` only):
- `jenkins_whoami`
- `jenkins_list_jobs`
- `jenkins_latest_build`
- `jenkins_build_status`
- `jenkins_get_build`
`jenkins_trigger_build` must **not** appear on `jenkins-mcp`. When an operator
explicitly enables the write boundary, the only expected tool on
`jenkins-write-mcp` is `jenkins_trigger_build`.
Expected GlitchTip tools:
- `glitchtip_whoami`
- `glitchtip_list_projects`
- `glitchtip_list_unresolved`
- `glitchtip_get_issue`
- `glitchtip_recent_events`
- `glitchtip_search`
If a client reports the server as enabled but exposes no usable tools, report
`SKIPPED: server enabled but no usable tools visible`, then stop. Do not fall
back to shell commands, raw service APIs, or unrelated MCP servers.
## Boundary Rules
- `jenkins-mcp` read profiles must not expose build trigger tools.
- Build triggers live on the separate `jenkins-write-mcp` server
(`jenkins_mcp.write_server`), not on `jenkins-mcp`.
- Jenkins build triggers require a dedicated trigger profile with
`jenkins.build.trigger` allowed, exact confirmation
(`TRIGGER BUILD <job-path>`), and fail-closed mutation audit.
- Do not register `jenkins-write-mcp` until an operator approves a trigger
profile; no shipped profile carries trigger capability by default.
- `glitchtip-mcp` remains read-only. It must not file or mutate Gitea issues.
- GlitchTip-to-Gitea filing is a separate library-only orchestrator in
`mcp-control-plane` that composes the real GlitchTip read path with Gitea
issue-write tools.
- The filing orchestrator must run GlitchTip/Gitea dedup before any Gitea
create action, and its create/link/skip decisions must be covered by tests.
- The filing orchestrator must fail closed on mutation-audit failure before
any Gitea create, link, or comment mutation.
- If filing is ever MCP-exposed, it must use a separate write-boundary
server/profile. It must not be exposed by `glitchtip-mcp`.
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
+2 -25
View File
@@ -21,28 +21,5 @@ Note on naming: Historical design docs used `jenkins-readonly` / `glitchtip-read
## 5. Mutation Gating
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
- **Jenkins build triggers** are gated on a separate write boundary
(`jenkins-write-mcp` / `jenkins_mcp.write_server`), not on the read-only
`jenkins-mcp` surface. Triggers require a dedicated profile with
`jenkins.build.trigger`, exact confirmation, and fail-closed mutation audit.
No default profile carries trigger capability (#152 / mcp-control-plane #56).
- **GlitchTip to Gitea issue filing** is a library-only orchestrator in
mcp-control-plane (not on `glitchtip-mcp`). See #153 / mcp-control-plane #57.
## 6. Agent Commit Path (no improvised fallbacks)
When an author execution profile allows **`gitea.repo.commit`** and the
**`gitea_commit_files`** tool is visible, agents must use that MCP path for
repository commits. Fail closed instead of improvising alternate transports.
Forbidden when MCP commit is available:
- WebFetch or other HTTP calls to external base64/decode services
- Playwright or browser automation used to work around MCP commit
- Manual LLM-generated base64 embedded in throwaway scripts as the primary
commit transport
If shell helpers are unavailable and MCP commit cannot run, stop with a recovery
report (restart session, clear hung terminals, use MCP-native commit). See
[`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path
(#260) and agent temp artifact cleanup (#261).
- **Jenkins build triggers** exist in jenkins-mcp (landed #4) but require dedicated profile/identity and exact confirmation; not on standard reader profiles. See #56 for boundary correction.
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow (not in glitchtip-mcp), currently partial (mocked, dedup not wired, audit missing). See #57.
-15
View File
@@ -1,15 +0,0 @@
# Project History
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
## 2026-07-06
- **Wiki bootstrap (#224)** — Added repo-tracked `docs/wiki/` (10 pages), `scripts/sync-gitea-wiki.sh`, PR template gate, and sync safety tests for Gitea-Tools publication.
## Prior milestones
- Gated review/merge path (#16), task capability resolver (#69), issue-write tool gates.
- Canonical JSON execution profiles (#19) and thin MCP launchers.
- Role session router (#206) and review decision lock (#211).
-35
View File
@@ -1,35 +0,0 @@
# Gitea-Tools Project Wiki
Welcome to the version-controlled wiki for the Gitea-Tools MCP server and CLI tooling.
## Navigation
- [Home](Home.md)
- [Operator Guide](Operator-Guide.md)
- [Repositories Map](Repositories.md)
- [Identity and Profiles](Identity-and-Profiles.md)
- [Workflow Model](Workflow.md)
- [Safety and Gates](Safety-and-Gates.md)
- [MCP Tools Reference](MCP-Tools.md)
- [Operator Runbooks](Runbooks.md)
- [Open Decisions](Open-Decisions.md)
- [Project History](History.md)
## Gitea Wiki mirror
These repo-tracked pages are the **source of truth**. The Gitea native Wiki
for this repository is a read-only convenience mirror generated from this
directory with `scripts/sync-gitea-wiki.sh` (dry-run by default; see
[Runbooks](Runbooks.md)). Never edit the Gitea Wiki directly — change the
pages here through a PR, then sync.
## Project overview
Gitea-Tools provides the Gitea MCP server, execution-profile configuration,
identity handling, and CLI helpers used across MCP Control Plane workflows.
It enforces author/reviewer separation, gated review/merge, task-capability
resolution, and fail-closed safety rails in code.
Canonical long-form docs also live under `docs/` in the repository (for example
`docs/gitea-execution-profiles.md` and `docs/llm-workflow-runbooks.md`). The
wiki summarizes operator-facing rules; the repo docs carry implementation detail.
-39
View File
@@ -1,39 +0,0 @@
# Identity and Profiles
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
The LLM is not the role — the **MCP execution profile** is the role. Profiles
bind an authenticated Gitea identity to an allowed operation set.
## Reference profiles (prgs)
### Author / implementer
- **Profile:** `prgs-author`
- **Typical identity:** `jcwalker3`
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
- **Forbidden:** PR approve, merge, request_changes.
### Reviewer / merger
- **Profile:** `prgs-reviewer`
- **Typical identity:** `sysadmin`
- **Allowed:** PR review/approve/merge/request_changes, issue comment, read.
- **Forbidden:** branch push, PR create, repo commit.
## Configuration
Profiles are defined in the canonical JSON config (`GITEA_MCP_CONFIG`, typically
`~/.config/gitea-tools/profiles.json`). Launchers are thin: they set
`GITEA_MCP_PROFILE` and point at the config file. Credentials resolve from
keychain or env references — never inline in client configs.
See `docs/gitea-execution-profiles.md` in the repository for the full model.
## Profile switching
Use separate MCP server namespaces (`gitea-tools` author vs `gitea-reviewer`)
or distinct launcher entries. Runtime in-place profile switching is disabled by
default (fail closed).
-34
View File
@@ -1,34 +0,0 @@
# MCP Tools Reference
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
## Identity and capability
- `gitea_whoami` — authenticated user and active profile metadata.
- `gitea_get_profile` / `gitea_get_runtime_context` — allowed and forbidden operations.
- `gitea_resolve_task_capability` — required pre-flight for gated mutations.
- `gitea_route_task_session` — role/session router before task execution.
## Author tools
- `gitea_create_issue`, `gitea_create_issue_comment`, `gitea_close_issue`
- `gitea_mark_issue`, `gitea_set_issue_labels`, `gitea_lock_issue`
- `gitea_create_pr`, `gitea_edit_pr`, `gitea_commit_files`
- `gitea_delete_branch`
## Reviewer tools
- `gitea_check_pr_eligibility` — read-only eligibility check.
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
- `gitea_mark_final_review_decision` — mark validation complete.
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
- `gitea_merge_pr` — gated merge (only merge path).
## Read tools
- `gitea_list_prs`, `gitea_view_pr`, `gitea_list_issues`, `gitea_view_issue`
- `gitea_get_file`, `gitea_list_labels`, `gitea_mirror_refs`
See the repository `README.md` for the full tool table and client setup.
-11
View File
@@ -1,11 +0,0 @@
# Open Decisions
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
Pending architectural and workflow decisions:
- **Operation-scoped role selection (#228)** — Move from launcher-profile-dependent routing to per-operation profile resolution.
- **Gitea-Tools wiki for dadeschools remote** — This bootstrap covers `prgs`; dadeschools instance wiki parity is undecided.
- **Server-side self-merge block** — Complement tool gates with Gitea branch protection where available.
-32
View File
@@ -1,32 +0,0 @@
# Operator Guide
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
Handbook for LLM operators and human developers using the Gitea-Tools MCP server.
## Key rules
1. **Verify identity first** — Run `gitea_whoami` and confirm the active profile before any mutation.
2. **Resolve task capability** — Call `gitea_resolve_task_capability` for the intended task before gated tools.
3. **One unit of work per session** — Implement one claimed issue *or* review/merge one PR; do not mix author and reviewer mutations in one session.
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.
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.
## Supported Gitea instances
| Remote | Host | Default org/repo |
|--------|------|------------------|
| `dadeschools` | `gitea.dadeschools.net` | `Contractor / Timesheet` |
| `prgs` | `gitea.prgs.cc` | `Scaled-Tech-Consulting / Timesheet` |
Always pass `remote` explicitly on tool calls. The server default is `dadeschools`; forgetting `remote` on a `prgs` task hits the wrong host.
## New session checklist
1. `gitea_whoami` — confirm authenticated user and profile.
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
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.
-38
View File
@@ -1,38 +0,0 @@
# Repositories Map
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
Active MCP Control Plane repositories (authoritative inventory for wiki publication gate #224 / #87):
| Repository | Purpose | Wiki required |
|------------|---------|---------------|
| `Scaled-Tech-Consulting/Gitea-Tools` | Gitea MCP server, execution profiles, workflow tooling | Yes — live Gitea Wiki on Wiki tab |
| `Scaled-Tech-Consulting/mcp-control-plane` | Orchestrators, audit trails, multi-service controller workflows | Yes — live Gitea Wiki on Wiki tab |
Repo-tracked `docs/wiki/` is the source of truth; the Gitea Wiki is a mirror.
Wiki-related issues cannot be closed until the live Wiki is verified — see
[Safety and Gates](Safety-and-Gates.md) and [Runbooks](Runbooks.md).
### Gitea-Tools
- **Repository:** `Scaled-Tech-Consulting/Gitea-Tools`
- **Purpose:** Gitea MCP server, profile configuration, CLI scripts, safety gates.
- **Base branch:** `master`
### mcp-control-plane
- **Repository:** `Scaled-Tech-Consulting/mcp-control-plane`
- **Purpose:** Controller workflows, Jenkins/GlitchTip integrations, audit orchestration.
- **Base branch:** `master`
## Wiki publication status (#224 readiness gate)
| Repository | `docs/wiki/` source | Gitea Wiki published | Proof |
|---|---|---|---|
| `Scaled-Tech-Consulting/Gitea-Tools` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/wiki/Home); 10 pages; wiki git log head `d1f0693` |
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home); 10 pages (History, Home, Identity-and-Profiles, MCP-Tools, Open-Decisions, Operator-Guide, Repositories, Runbooks, Safety-and-Gates, Workflow); wiki git log head `ef3dec2` |
Update this table whenever a wiki is published, re-synced, or found stale.
-40
View File
@@ -1,40 +0,0 @@
# Operator Runbooks
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
## PR review and merge
1. `gitea_resolve_task_capability(task="review_pr")`
2. `gitea_check_pr_eligibility` for review and merge actions.
3. Validate locally: tests, `py_compile`, `git diff --check`.
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
## Gitea Wiki sync
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
1. Preview (no network):
```bash
scripts/sync-gitea-wiki.sh
```
2. Operator-confirmed push:
```bash
GITEA_WIKI_SYNC_CONFIRM="SYNC WIKI Gitea-Tools" scripts/sync-gitea-wiki.sh --push
```
3. If clone fails on first run, bootstrap Home via Gitea API or UI, then re-run.
The script mirrors only `docs/wiki/*.md`, never deletes wiki pages, and never
prints credentials.
## Wiki Publication Readiness Gate (#224)
Actual Gitea Wiki publication is a **required repo readiness gate**.
1. **Closure prevention** — No wiki issue may close on markdown/helper work alone.
Closing requires live-Wiki proof: Wiki Home link plus page listing or wiki git log.
2. **Reviewer checklist** — PR template requires verifying the repo **Wiki tab**.
3. **Publication authority** — `--push` requires exact `GITEA_WIKI_SYNC_CONFIRM` phrase.
4. **Per-repo status** — Update [Repositories Map](Repositories.md) when published.
-19
View File
@@ -1,19 +0,0 @@
# Safety and Gates
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
*Prompts express intent; MCP tools enforce safety.*
## Primary gates
1. **Fail closed** — Unknown tasks, missing capability resolution, or profile mismatches block mutations.
2. **Task capability map**`gitea_resolve_task_capability` must precede gated issue/PR mutations.
3. **Issue lock**`gitea_lock_issue` required before author implementation on a tracked issue.
4. **Head SHA pinning** — Reviews and merges refuse when the PR head moved.
5. **Explicit merge confirmation**`gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
8. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
9. **Wiki publication (#224)**`docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
-34
View File
@@ -1,34 +0,0 @@
# Workflow Model
## Navigation
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
## Step 1: Review queue first (reviewer profile)
1. `gitea_resolve_task_capability(task="review_pr")`
2. List open PRs; pick the oldest eligible PR (not self-authored, mergeable).
3. Pin head SHA; validate diff scope and run tests locally.
4. `gitea_mark_final_review_decision``gitea_review_pr` / `gitea_submit_pr_review`
5. `gitea_merge_pr` only with `confirmation="MERGE PR <n>"` and pinned head SHA.
## Step 2: Implement issues (author profile)
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
handoffs, merged-PR completion). Stop if another session owns the lease.
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
mutate only from a `branches/` worktree after proving root, cwd, branch,
stable main-checkout branch, and session worktree path (no exceptions).
1. `gitea_resolve_task_capability` for the author task.
2. `gitea_lock_issue` before implementation mutations.
3. Claim with `gitea_mark_issue` / `status:in-progress` label.
4. Branch, implement, test, push, `gitea_create_pr`.
5. Release claim when done; never self-review the PR.
## Step 3: Operator follow-ups
Wiki publication, credential provisioning, and cross-repo mirror operations are
operator-confirmed actions — see [Runbooks](Runbooks.md).
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
-878
View File
@@ -1,878 +0,0 @@
"""Composable final-report validator for reviewer and reconciliation handoffs (#327).
Single entry point ``assess_final_report_validator`` runs task-specific rule
modules and returns structured findings suitable for controller handoff
``Blocker`` / ``Safe next action`` fields.
"""
from __future__ import annotations
import inspect
import re
from typing import Any, Callable
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
assess_email_disclosure,
assess_issue_filing_final_report,
assess_mutation_ledger_report,
assess_review_mutation_final_report,
assess_validation_report,
)
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
"reconcile_already_landed",
"author_issue",
"work_issue",
"issue_filing",
"issue_selection",
"inventory",
})
_TASK_KIND_ALIASES = {
"review": "review_pr",
"review-merge-pr": "review_pr",
"reconcile-landed-pr": "reconcile_already_landed",
"reconcile_already_landed": "reconcile_already_landed",
"work-issue": "work_issue",
"create_issue": "issue_filing",
}
_HANDOFF_ROLE_BY_TASK = {
"review_pr": "review",
"reconcile_already_landed": None,
"author_issue": "author",
"work_issue": "author",
"issue_filing": "issue_filing",
"issue_selection": None,
"inventory": "inventory",
}
_LEGACY_WORKSPACE_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*workspace\s+mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_VAGUE_MUTATIONS_NONE_RE = re.compile(
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
re.IGNORECASE | re.MULTILINE,
)
_BARE_PYTEST_RE = re.compile(r"\b(?:pytest|python\s+-m\s+pytest)\b", re.IGNORECASE)
_VALIDATION_ENV_RE = re.compile(
r"(?:working directory|cwd|executed (?:in|from)|command\s+path)\s*:|\bin\s+branches/",
re.IGNORECASE,
)
_MAIN_CHECKOUT_RE = re.compile(
r"(?:/Gitea-Tools(?:/|$)(?!branches/)|main checkout|shared checkout|control checkout)",
re.IGNORECASE,
)
_SAME_AS_MASTER_RE = re.compile(
r"same as master|same failures as master|matches master baseline",
re.IGNORECASE,
)
_BASELINE_WORKTREE_RE = re.compile(
r"baseline worktree",
re.IGNORECASE,
)
_APPROVAL_CLAIM_RE = re.compile(
r"(?:submitted\s+['\"]approve['\"]|review decision\s*:\s*approve|claims?\s+approve)",
re.IGNORECASE,
)
_FULL_SUITE_FAIL_RE = re.compile(
r"(?:full suite failed|full test suite failed|\d+\s+failed(?:,|\s))",
re.IGNORECASE,
)
_ALREADY_LANDED_RE = re.compile(
r"already[- ]landed|ancestor proof\s*:\s*passed|eligibility class\s*:\s*already_landed",
re.IGNORECASE,
)
_ELIGIBLE_REVIEW_RE = re.compile(
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
re.IGNORECASE,
)
_REVIEWED_LANDED_RE = re.compile(
r"(?:reviewed|approved).{0,40}already[- ]landed|already[- ]landed.{0,40}(?:reviewed|eligible)",
re.IGNORECASE | re.DOTALL,
)
_RECONCILE_STALE_FIELDS = (
"pr number opened",
"pinned reviewed head",
"scratch worktree used",
"review decision",
"merge result",
)
_RECONCILE_REQUIRED_FIELDS = (
"Selected PR",
"Eligibility class",
"Linked issue live status",
"Safe next action",
"No review/merge confirmation",
)
_LINKED_ISSUE_STATUS_RE = re.compile(
r"linked issue(?:\s+live)?\s+status\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_LINKED_ISSUE_NUMBER_RE = re.compile(
r"linked issue\s*:\s*#?(\d+)",
re.IGNORECASE,
)
_INVENTORY_COMPLETE_RE = re.compile(
r"inventory\s+(?:complete|completeness\s*:\s*complete)",
re.IGNORECASE,
)
_PAGINATION_PROOF_RE = re.compile(
r"(?:pagination\s+complete|final page|no next page|total\s+(?:open\s+)?pr\s+count|traversed\s+all\s+pages)",
re.IGNORECASE,
)
_GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE)
_READONLY_DIAG_RE = re.compile(
r"read[- ]only diagnostics\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_MUTATION_CATEGORY_FIELDS = (
"file edits by reviewer",
"worktree/index mutations",
"git ref mutations",
"mcp/gitea mutations",
"reconciliation mutations",
"file edits by reconciler",
)
def _normalize_task_kind(task_kind: str | None) -> str:
raw = (task_kind or "").strip().lower().replace(" ", "_")
return _TASK_KIND_ALIASES.get(raw, raw)
def validator_finding(
rule_id: str,
severity: str,
field: str,
reason: str,
safe_next_action: str,
) -> dict[str, str]:
"""Structured finding for controller handoff consumption."""
return {
"rule_id": rule_id,
"severity": severity,
"field": field,
"reason": reason,
"safe_next_action": safe_next_action,
}
def _findings_from_reasons(
rule_id: str,
reasons: list[str],
*,
field: str = "",
severity: str = "downgrade",
safe_next_action: str = "downgrade final report; repair missing proof fields",
) -> list[dict[str, str]]:
return [
validator_finding(rule_id, severity, field, reason, safe_next_action)
for reason in reasons
]
def _handoff_fields(report_text: str) -> dict[str, str]:
from review_proofs import _handoff_section_lines # local import avoids cycle at load
section = _handoff_section_lines(report_text) or []
fields: dict[str, str] = {}
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _rule_shared_controller_handoff(
report_text: str,
task_kind: str,
*,
local_edits: bool = False,
) -> list[dict[str, str]]:
role = _HANDOFF_ROLE_BY_TASK.get(task_kind)
result = assess_controller_handoff(
report_text,
role=role,
local_edits=local_edits,
)
verdict = result.get("verdict")
if verdict == "complete":
return []
severity = "block" if verdict == "missing" else "downgrade"
return _findings_from_reasons(
"shared.controller_handoff",
result.get("reasons") or [],
field="Controller Handoff",
severity=severity,
safe_next_action=(
"add a complete Controller Handoff section with task-appropriate fields"
if verdict == "missing"
else "fill missing controller handoff fields before submission"
),
)
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
result = assess_email_disclosure(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.email_disclosure",
result.get("reasons") or [],
field="Identity",
severity="downgrade",
safe_next_action="use username / profile identity; remove personal email",
)
def _rule_reviewer_legacy_workspace_mutations(
report_text: str,
*,
mutations_observed: bool = False,
) -> list[dict[str, str]]:
if not mutations_observed:
return []
if not _LEGACY_WORKSPACE_MUTATIONS_RE.search(report_text or ""):
return []
return [
validator_finding(
"reviewer.legacy_workspace_mutations",
"block",
"Workspace mutations",
"legacy 'Workspace mutations' field used after observed mutations; "
"use precise mutation categories instead",
"replace Workspace mutations with file/worktree/git/MCP category fields",
)
]
def _rule_reviewer_vague_mutations_none(
report_text: str,
*,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
) -> list[dict[str, str]]:
performed = any(
e.get("performed") is not False and not e.get("gated_rejected")
for e in (action_log or [])
)
if not (mutations_observed or performed):
return []
if not _VAGUE_MUTATIONS_NONE_RE.search(report_text or ""):
return []
return [
validator_finding(
"reviewer.vague_mutations_none",
"block",
"Mutations",
"report claims 'Mutations: none' while mutations were observed",
"list performed mutations under precise category fields",
)
]
def _rule_reviewer_mutation_categories(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
lower = text.lower()
if "mutations:" not in lower and "mutation categories" not in lower:
return [
validator_finding(
"reviewer.mutation_categories",
"downgrade",
"Mutations",
"reviewer handoff lacks precise mutation category fields",
"report file edits, worktree/index, git ref, MCP/Gitea, and review mutations separately",
)
]
if any(term in lower for term in ("workspace mutations", "mutations: none")):
return []
if any(field in lower for field in _MUTATION_CATEGORY_FIELDS):
return []
vague_only = "mutations:" in lower and not any(
marker in lower
for marker in ("file edits", "worktree", "git ref", "mcp/gitea", "review mutation")
)
if vague_only:
return [
validator_finding(
"reviewer.mutation_categories",
"downgrade",
"Mutations",
"mutation section is vague; precise categories are required",
"split mutations into file edits, worktree/index, git ref, MCP/Gitea, review",
)
]
return []
def _rule_reviewer_git_fetch_readonly(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
text = report_text or ""
fetch_observed = any(
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
for e in (action_log or [])
) or _GIT_FETCH_RE.search(text)
if not fetch_observed:
return []
readonly_match = _READONLY_DIAG_RE.search(text)
readonly_value = (readonly_match.group(1) if readonly_match else "").strip().lower()
fields = _handoff_fields(text)
git_ref_value = fields.get("git ref mutations", "").strip().lower()
git_ref_reported = bool(git_ref_value) and git_ref_value not in {
"none",
"n/a",
"no",
}
if git_ref_reported:
return []
if readonly_match and _GIT_FETCH_RE.search(readonly_value):
return [
validator_finding(
"reviewer.git_fetch_readonly",
"block",
"Git ref mutations",
"git fetch occurred but was classified only as read-only diagnostics",
"classify git fetch under Git ref mutations, not read-only diagnostics",
)
]
if not git_ref_reported and _GIT_FETCH_RE.search(text):
return [
validator_finding(
"reviewer.git_fetch_readonly",
"downgrade",
"Git ref mutations",
"git fetch mentioned without Git ref mutation classification",
"record git fetch under Git ref mutations",
)
]
return []
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
return []
if _VALIDATION_ENV_RE.search(text):
return []
return [
validator_finding(
"reviewer.validation_command_proof",
"downgrade",
"Validation",
"bare pytest command without working-directory or executable path proof",
"report exact validation command, cwd, and path proof for bare commands",
)
]
def _rule_reviewer_linked_issue(
report_text: str,
*,
linked_issue_lock: dict | None = None,
) -> list[dict[str, str]]:
lock = linked_issue_lock or {}
expected_issue = lock.get("issue_number")
expected_pr = lock.get("pr_number")
if expected_issue is None and expected_pr is None:
return []
text = report_text or ""
findings: list[dict[str, str]] = []
reported_issue = None
issue_match = _LINKED_ISSUE_NUMBER_RE.search(text)
if issue_match:
reported_issue = int(issue_match.group(1))
elif expected_issue is not None:
if f"#{expected_issue}" not in text and f"issue {expected_issue}" not in text.lower():
findings.append(
validator_finding(
"reviewer.linked_issue_missing",
"downgrade",
"Linked issue",
f"linked issue #{expected_issue} not documented in final report",
"document the linked issue number matching the selected PR",
)
)
if expected_issue is not None and reported_issue is not None and reported_issue != expected_issue:
findings.append(
validator_finding(
"reviewer.linked_issue_mismatch",
"block",
"Linked issue",
f"reported linked issue #{reported_issue} does not match "
f"selected PR linked issue #{expected_issue}",
"reconcile linked issue proof with the selected PR before reporting status",
)
)
if expected_pr is not None:
pr_tokens = (f"pr #{expected_pr}", f"pr{expected_pr}", f"#pr{expected_pr}")
if not any(token in text.lower().replace(" ", "") for token in pr_tokens):
if f"#{expected_pr}" not in text:
findings.append(
validator_finding(
"reviewer.linked_pr_missing",
"downgrade",
"Selected PR",
f"selected PR #{expected_pr} not consistently referenced",
"reference the selected PR number in handoff and diagnostics",
)
)
return findings
def _rule_reviewer_baseline_on_failure(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not (_APPROVAL_CLAIM_RE.search(text) and _FULL_SUITE_FAIL_RE.search(text)):
return []
if _BASELINE_WORKTREE_RE.search(text) and "baseline worktree path" in text.lower():
return []
return [
validator_finding(
"reviewer.baseline_on_full_suite_failure",
"block",
"Baseline worktree",
"approval claimed after full-suite failure without baseline worktree proof",
"create a clean branches/ baseline worktree and report path plus results",
)
]
def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _SAME_AS_MASTER_RE.search(text):
return []
if _BASELINE_WORKTREE_RE.search(text):
return []
return [
validator_finding(
"reviewer.main_checkout_baseline",
"block",
"Baseline worktree",
"vague 'same as master' claim without clean baseline worktree proof",
"validate against a clean branches/ baseline worktree; do not use main checkout",
)
]
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if "baseline worktree path" not in text.lower():
return []
fields = _handoff_fields(text)
baseline_path = ""
for key, value in fields.items():
if key.startswith("baseline worktree path"):
baseline_path = value
break
if baseline_path and _MAIN_CHECKOUT_RE.search(baseline_path):
return [
validator_finding(
"reviewer.main_checkout_path",
"block",
"Baseline worktree path",
"baseline validation used main/shared checkout instead of branches/ worktree",
"recreate baseline proof under branches/ and report that path",
)
]
return []
def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ALREADY_LANDED_RE.search(text):
return []
if not _ELIGIBLE_REVIEW_RE.search(text):
return []
return [
validator_finding(
"reviewer.already_landed_eligible_wording",
"block",
"Eligibility class",
"already-landed PR described as eligible for review or merge",
"classify as ALREADY_LANDED_RECONCILE_REQUIRED and stop normal review",
)
]
def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]:
from review_proofs import _handoff_section_lines
if _handoff_section_lines(report_text) is None:
return [
validator_finding(
"reconcile.controller_handoff",
"block",
"Controller Handoff",
f"final report has no section titled exactly '{HANDOFF_HEADING}'",
"add a reconciliation Controller Handoff section",
)
]
fields = _handoff_fields(report_text)
missing = [
name
for name in _RECONCILE_REQUIRED_FIELDS
if not (fields.get(name.lower()) or "").strip()
]
if missing:
return [
validator_finding(
"reconcile.controller_handoff",
"downgrade",
"Controller Handoff",
f"reconciliation handoff missing required field: {field}",
"complete the reconciliation handoff schema before submission",
)
for field in missing
]
return []
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
text = (report_text or "").lower()
findings = []
for field in _RECONCILE_STALE_FIELDS:
if f"{field}:" in text:
findings.append(
validator_finding(
"reconcile.stale_author_reviewer_fields",
"block",
field,
f"reconciliation handoff includes stale author/reviewer field '{field}'",
"use reconciliation schema only; remove author/reviewer review fields",
)
)
return findings
def _rule_reconcile_eligible_reviewed(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ALREADY_LANDED_RE.search(text):
return []
findings = []
if _ELIGIBLE_REVIEW_RE.search(text):
findings.append(
validator_finding(
"reconcile.eligible_reviewed_wording",
"block",
"Eligibility class",
"already-landed PR described as eligible for review or merge",
"report ALREADY_LANDED_RECONCILE_REQUIRED and reconciliation-only next steps",
)
)
if _REVIEWED_LANDED_RE.search(text):
findings.append(
validator_finding(
"reconcile.reviewed_landed_wording",
"block",
"Current status",
"already-landed PR described as reviewed or approval-eligible",
"use reconciliation status wording only",
)
)
return findings
def _rule_reconcile_linked_issue_live(
report_text: str,
*,
linked_issue_lock: dict | None = None,
) -> list[dict[str, str]]:
lock = linked_issue_lock or {}
text = report_text or ""
status_match = _LINKED_ISSUE_STATUS_RE.search(text)
if not status_match:
return []
status_value = status_match.group(1).strip().lower()
if "not verified" in status_value:
return []
live_proof = lock.get("live_fetched") is True or lock.get("verified_live") is True
if live_proof:
return []
if status_value not in {"", "none", "n/a", "unknown"}:
return [
validator_finding(
"reconcile.linked_issue_live_status",
"downgrade",
"Linked issue live status",
"linked issue status reported without live verification proof in this session",
"fetch linked issue live or report 'not verified in this session'",
)
]
return []
def _rule_reconcile_pagination_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _INVENTORY_COMPLETE_RE.search(text):
return []
if _PAGINATION_PROOF_RE.search(text):
return []
return [
validator_finding(
"reconcile.inventory_pagination_proof",
"downgrade",
"PR inventory",
"inventory completeness claimed without pagination or final-page proof",
"include pagination complete, final page, or total open PR count evidence",
)
]
def _rule_reviewer_validation_structured(
validation_report: dict | None,
) -> list[dict[str, str]]:
if not validation_report:
return []
result = assess_validation_report(validation_report)
if result.get("verdict") == "strong":
return []
severity = "block" if result.get("verdict") == "invalid" else "downgrade"
return _findings_from_reasons(
"reviewer.validation_structured",
result.get("reasons") or [],
field="Validation",
severity=severity,
safe_next_action="provide exact validation command, output read proof, and counts",
)
def _rule_reviewer_mutation_ledger(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
if not action_log:
return []
result = assess_mutation_ledger_report(report_text, action_log=action_log)
if result.get("proven"):
return []
return _findings_from_reasons(
"reviewer.mutation_ledger",
result.get("reasons") or [],
field="File edits by reviewer",
severity="block",
safe_next_action="align mutation ledger with observed file edits",
)
def _rule_reviewer_review_mutation(
report_text: str,
*,
review_decision_lock: dict | None = None,
) -> list[dict[str, str]]:
if not review_decision_lock:
return []
result = assess_review_mutation_final_report(report_text, review_decision_lock)
if result.get("complete"):
return []
return _findings_from_reasons(
"reviewer.review_mutation_proof",
result.get("reasons") or [],
field="Review mutations",
severity="downgrade",
safe_next_action="document exactly one live review mutation or authorized correction flow",
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_validation_command,
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
_rule_reviewer_main_checkout_baseline,
_rule_reviewer_main_checkout_path,
_rule_reviewer_already_landed_eligible,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
_rule_reconcile_pagination_proof,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
],
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reviewer_vague_mutations_none,
],
"issue_filing": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
],
}
def _aggregate_grade(findings: list[dict[str, str]]) -> tuple[str, bool, bool]:
blocked = any(f["severity"] == "block" for f in findings)
downgraded = any(f["severity"] == "downgrade" for f in findings)
if blocked:
return "blocked", True, downgraded
if downgraded:
return "downgraded", False, True
return "A", False, False
def _primary_safe_next_action(findings: list[dict[str, str]]) -> str:
for severity in ("block", "downgrade", "warning"):
for finding in findings:
if finding["severity"] == severity:
return finding["safe_next_action"]
return "proceed"
def _call_rule(
rule: Callable[..., list[dict[str, str]]],
report_text: str,
task_kind: str,
rule_kwargs: dict[str, Any],
) -> list[dict[str, str]]:
if rule is _rule_shared_controller_handoff:
return rule(
report_text,
task_kind,
local_edits=bool(rule_kwargs.get("local_edits")),
)
if rule is _rule_reviewer_validation_structured:
return rule(rule_kwargs.get("validation_report"))
if rule is _rule_reconcile_controller_handoff:
return rule(report_text)
bound: dict[str, Any] = {}
for name, param in inspect.signature(rule).parameters.items():
if name == "report_text":
bound[name] = report_text
elif name in rule_kwargs:
bound[name] = rule_kwargs[name]
elif param.default is inspect.Parameter.empty and param.kind != inspect.Parameter.VAR_KEYWORD:
continue
return rule(**bound)
def assess_final_report_validator(
report_text: str,
task_kind: str,
*,
review_decision_lock: dict | None = None,
linked_issue_lock: dict | None = None,
validation_report: dict | None = None,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
local_edits: bool = False,
issue_filing_lock: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
Returns structured findings plus a composite grade suitable for workflow
controllers. Optional *proof_locks* carry live session facts used to fail
closed on stale or contradictory report fields.
"""
normalized_kind = _normalize_task_kind(task_kind)
if normalized_kind not in FINAL_REPORT_TASK_KINDS:
return {
"task_kind": normalized_kind,
"grade": "blocked",
"blocked": True,
"downgraded": False,
"findings": [
validator_finding(
"shared.unknown_task_kind",
"block",
"Task",
f"unknown task kind '{task_kind}'",
"use a supported task kind from FINAL_REPORT_TASK_KINDS",
)
],
"checks": {},
"reasons": [f"unknown task kind '{task_kind}'"],
"safe_next_action": "set task_kind to a supported workflow mode",
"complete": False,
}
checks: dict[str, Any] = {}
findings: list[dict[str, str]] = []
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
checks["issue_filing"] = assess_issue_filing_final_report(
report_text,
**issue_filing_lock,
)
if checks["issue_filing"].get("downgraded"):
findings.extend(
_findings_from_reasons(
"issue_filing.composite",
checks["issue_filing"].get("reasons") or [],
field="Issue filing",
severity="downgrade",
)
)
rule_kwargs = {
"review_decision_lock": review_decision_lock,
"linked_issue_lock": linked_issue_lock,
"validation_report": validation_report,
"action_log": action_log,
"mutations_observed": mutations_observed,
"local_edits": local_edits,
}
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
findings.extend(_call_rule(rule, report_text, normalized_kind, rule_kwargs))
grade, blocked, downgraded = _aggregate_grade(findings)
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
return {
"task_kind": normalized_kind,
"grade": grade,
"blocked": blocked,
"downgraded": downgraded,
"findings": findings,
"checks": checks,
"reasons": reasons,
"safe_next_action": _primary_safe_next_action(findings),
"complete": grade == "A",
"handoff_heading": HANDOFF_HEADING,
}
+2 -10
View File
@@ -163,13 +163,12 @@ def audit_enabled():
def build_event(*, action, result, remote=None, server=None, repository=None,
issue_number=None, pr_number=None, profile_name=None,
audit_label=None, authenticated_username=None, target_branch=None,
head_sha=None, reason=None, request_metadata=None, now=None,
mcp_namespace=None, task_role=None, operation=None):
head_sha=None, reason=None, request_metadata=None, now=None):
"""Build a redacted, JSON-able audit record for a mutating action."""
ts = now or datetime.datetime.now(datetime.timezone.utc)
if isinstance(ts, datetime.datetime):
ts = ts.isoformat()
event = {
return {
"timestamp": ts,
"action": action,
"action_type": "mutating",
@@ -187,13 +186,6 @@ def build_event(*, action, result, remote=None, server=None, repository=None,
"reason": _redact_str(reason) if reason else reason,
"request_metadata": redact(request_metadata) if request_metadata is not None else None,
}
if mcp_namespace is not None:
event["mcp_namespace"] = mcp_namespace
if task_role is not None:
event["task_role"] = task_role
if operation is not None:
event["operation"] = operation
return event
def write_event(event, path=None):
-37
View File
@@ -420,43 +420,6 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
return results
def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs):
"""Fetch one page from a Gitea list endpoint with explicit pagination metadata.
Returns ``(items, pagination)`` where *pagination* includes ``has_more``,
``next_page``, and ``is_final_page`` derived from the returned page length.
"""
page = max(1, int(page))
limit = max(1, min(50, int(limit)))
page_url = _add_query(url, page=page, limit=limit)
data = api_request("GET", page_url, auth_header, **kwargs)
if data is None:
pagination = {
"page": page,
"per_page": limit,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
}
return [], pagination
if not isinstance(data, list):
raise RuntimeError(
f"expected a list page from Gitea, got {type(data).__name__}"
)
returned = len(data)
has_more = returned >= limit
pagination = {
"page": page,
"per_page": limit,
"returned_count": returned,
"has_more": has_more,
"next_page": page + 1 if has_more else None,
"is_final_page": not has_more,
}
return data, pagination
def gitea_url(host, path):
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
if not path.startswith("/"):
+1 -9
View File
@@ -205,7 +205,7 @@ def selected_profile_name():
def is_runtime_switching_enabled(path=None):
"""Check if runtime profile switching is enabled in config."""
"""Check if runtime profile switching is explicitly enabled in config."""
try:
config = load_config(path)
except Exception:
@@ -213,18 +213,10 @@ def is_runtime_switching_enabled(path=None):
if not config:
return False
rules = config.get("rules") or {}
if rules.get("allow_runtime_switching") is False:
return False
if config.get("allow_runtime_switching") is False:
return False
if rules.get("allow_runtime_switching") is True:
return True
if config.get("allow_runtime_switching") is True:
return True
# Default to True if multiple profiles exist in the config
profiles = config.get("profiles") or {}
if len(profiles) > 1:
return True
return False
-5737
View File
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
"""Pre-create issue duplicate gate (#207)."""
from __future__ import annotations
import re
import unicodedata
VERDICT_NO_DUPLICATE = "no_duplicate_found"
VERDICT_DUPLICATE = "duplicate_found"
VERDICT_AMBIGUOUS = "ambiguous_duplicate_stop"
_STOPWORDS = frozenset({"a", "an", "the", "and", "or", "for", "to", "of", "in", "on"})
def normalize_issue_title(title: str) -> str:
"""Lowercase, punctuation-stripped, whitespace-collapsed title."""
text = unicodedata.normalize("NFKC", (title or "").strip().lower())
text = re.sub(r"[^\w\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _title_tokens(title: str) -> set[str]:
return {
t for t in normalize_issue_title(title).split()
if t and t not in _STOPWORDS
}
def titles_near_duplicate(proposed: str, existing: str) -> bool:
"""True when normalized titles match or are near-duplicates."""
norm_a = normalize_issue_title(proposed)
norm_b = normalize_issue_title(existing)
if not norm_a or not norm_b:
return False
if norm_a == norm_b:
return True
if norm_a in norm_b or norm_b in norm_a:
return True
tokens_a = _title_tokens(proposed)
tokens_b = _title_tokens(existing)
if not tokens_a or not tokens_b:
return False
overlap = tokens_a & tokens_b
union = tokens_a | tokens_b
ratio = len(overlap) / len(union)
if ratio >= 0.85:
return True
wall_pair = (
{"hard", "wall"} <= tokens_a and {"wall"} <= tokens_b
) or (
{"hard", "wall"} <= tokens_b and {"wall"} <= tokens_a
)
return wall_pair
def assess_pre_create_duplicate(
proposed_title: str,
existing_issues: list[dict],
*,
duplicate_override_reason: str | None = None,
split_from_issue: int | None = None,
) -> dict:
"""Evaluate duplicate risk immediately before issue creation."""
proposed_title = (proposed_title or "").strip()
if not proposed_title:
return {
"verdict": VERDICT_AMBIGUOUS,
"performed": False,
"reasons": ["issue title is required"],
"matches": [],
}
override = (duplicate_override_reason or "").strip()
if override and split_from_issue is not None:
return {
"verdict": VERDICT_NO_DUPLICATE,
"performed": True,
"override_applied": True,
"split_from_issue": split_from_issue,
"override_reason": override,
"reasons": [],
"matches": [],
}
matches = []
for issue in existing_issues or []:
existing_title = (issue.get("title") or "").strip()
if not existing_title:
continue
if titles_near_duplicate(proposed_title, existing_title):
matches.append({
"number": issue.get("number"),
"title": existing_title,
"state": issue.get("state"),
})
if not matches:
return {
"verdict": VERDICT_NO_DUPLICATE,
"performed": True,
"normalized_title": normalize_issue_title(proposed_title),
"reasons": [],
"matches": [],
}
if len(matches) > 3:
return {
"verdict": VERDICT_AMBIGUOUS,
"performed": False,
"normalized_title": normalize_issue_title(proposed_title),
"reasons": [
"too many near-duplicate title matches; fail closed "
"until operator clarifies"
],
"matches": matches[:5],
}
return {
"verdict": VERDICT_DUPLICATE,
"performed": False,
"normalized_title": normalize_issue_title(proposed_title),
"reasons": [
f"duplicate issue title blocked: matches existing "
f"#{m['number']} ({m['state']})"
for m in matches
],
"matches": matches,
}
def pre_create_issue_duplicate_gate(
proposed_title: str,
existing_issues: list[dict],
*,
duplicate_override_reason: str | None = None,
split_from_issue: int | None = None,
allow_override: bool = False,
) -> dict:
"""Alias for ``assess_pre_create_duplicate`` (#207 suggested name)."""
reason = (duplicate_override_reason or "").strip()
if allow_override and not reason:
reason = "operator-approved split after duplicate review"
return assess_pre_create_duplicate(
proposed_title,
existing_issues,
duplicate_override_reason=reason or None,
split_from_issue=split_from_issue,
)
def assess_duplicate_search_proof(report_text: str, matches: list[dict]) -> dict:
"""Reject LLM duplicate summaries that omit known exact duplicates (#207)."""
text = (report_text or "").lower()
missing = []
for match in matches or []:
num = match.get("number")
title = (match.get("title") or "").lower()
if num is not None and f"#{num}" not in text and str(num) not in text:
missing.append(f"issue #{num}")
if title and title[:40] not in text:
missing.append(f"title '{match.get('title')}'")
if missing:
return {
"valid": False,
"reasons": [
"duplicate-search proof omitted required match: " + ", ".join(missing)
],
}
return {"valid": True, "reasons": []}
-223
View File
@@ -1,223 +0,0 @@
"""Issue-lock worktree validation (#249).
Author issue locks must validate the caller's own scratch clone (or declared
worktree path), not the shared MCP server working directory. A clean scratch at
``master``/``main`` must remain lockable while an unrelated session leaves the
shared dev worktree dirty or on a feature branch.
"""
from __future__ import annotations
import os
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
BASE_BRANCHES = frozenset({"master", "main", "dev"})
def resolve_author_worktree_path(
explicit: str | None,
project_root: str,
) -> str:
"""Resolve the author worktree path for lock/PR gates."""
path = (explicit or "").strip()
if not path:
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
if not path:
path = project_root
return os.path.realpath(os.path.abspath(path))
def read_worktree_git_state(worktree_path: str) -> dict:
"""Read branch name and porcelain status from a git worktree."""
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
root_res = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
"head_sha": head_sha,
"base_branch": base_branch,
"base_sha": base_sha,
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
}
def assess_issue_lock_worktree(
*,
worktree_path: str,
current_branch: str | None,
porcelain_status: str,
base_equivalent: bool | None = None,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree."""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
path = (worktree_path or "").strip()
if not path:
reasons.append("worktree path not declared for issue lock; fail closed")
return _assessment(False, reasons, path, None, [])
branch = (current_branch or "").strip()
dirty_files = parse_dirty_tracked_files(porcelain_status)
if dirty_files:
reasons.append(
"tracked file edits exist before issue lock; "
f"lock must precede implementation work in '{path}' "
f"(dirty files: {', '.join(dirty_files)})"
)
if base_equivalent is False:
reasons.append(
"issue lock worktree must be base-equivalent to one of "
f"{_base_list(bases)} before implementation work; inspected "
f"branch '{branch or '(detached)'}' at '{path}'"
)
elif base_equivalent is None:
if not branch:
reasons.append(
"current branch unknown (detached HEAD?); issue lock base-equivalence "
f"to {_base_list(bases)} could not be proven"
)
elif branch not in bases:
reasons.append(
"issue lock worktree base-equivalence could not be proven; "
f"branch '{branch}' is not {_base_list(bases)}"
)
proven = not reasons
return _assessment(
proven,
reasons,
path,
branch or None,
dirty_files,
inspected_git_root=inspected_git_root,
base_branch=base_branch,
base_equivalent=base_equivalent,
)
def format_issue_lock_worktree_error(assessment: dict) -> str:
"""Format a single fail-closed error for ``gitea_lock_issue``."""
reasons = list(assessment.get("reasons") or [])
if not reasons:
reasons = ["issue lock worktree validation failed"]
return "; ".join(reasons) + " (fail closed)"
def verify_pr_worktree_matches_lock(
locked_worktree_path: str | None,
declared_worktree_path: str | None,
project_root: str,
) -> dict:
"""PR creation must use the same worktree the lock was validated against."""
locked = (locked_worktree_path or "").strip()
if not locked:
return {"proven": True, "block": False, "reasons": []}
declared = resolve_author_worktree_path(declared_worktree_path, project_root)
locked_real = os.path.realpath(locked)
declared_real = os.path.realpath(declared)
if locked_real != declared_real:
return {
"proven": False,
"block": True,
"reasons": [
f"PR worktree '{declared_real}' does not match locked worktree "
f"'{locked_real}' (fail closed)"
],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
return {
"proven": True,
"block": False,
"reasons": [],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
def _base_list(bases: frozenset[str]) -> str:
return "/".join(sorted(bases))
def _assessment(
proven: bool,
reasons: list[str],
worktree_path: str,
current_branch: str | None,
dirty_files: list[str],
*,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_equivalent: bool | None = None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"inspected_git_root": inspected_git_root,
"current_branch": current_branch,
"dirty_files": dirty_files,
"base_branch": base_branch,
"base_equivalent": base_equivalent,
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
capture_output=True,
text=True,
check=False,
)
sha = (res.stdout or "").strip()
if res.returncode == 0 and sha == head_sha:
return ref, sha
return None, None
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env python3
"""MCP Discoverability Validation Tool for external servers (Issue #155)."""
import os
import sys
import json
import argparse
import subprocess
EXPECTED_JENKINS_TOOLS = {
"jenkins_whoami",
"jenkins_list_jobs",
"jenkins_latest_build",
"jenkins_build_status",
"jenkins_get_build",
}
EXPECTED_GLITCHTIP_TOOLS = {
"glitchtip_whoami",
"glitchtip_list_projects",
"glitchtip_list_unresolved",
"glitchtip_get_issue",
"glitchtip_recent_events",
"glitchtip_search",
}
RELOAD_INSTRUCTIONS = """
=== MCP CLIENT RELOAD/RECONNECT RUNBOOK ===
After registering or changing external MCP servers, reload your client to discover the new tools:
- Codex: Click 'Reload Developer Tools' or restart the editor.
- Gemini / Grok / ChatGPT Desktop: Restart the client or run the reload slash command if available.
- Claude Desktop: Use 'Developer -> Reload' or restart the app.
- General MCP Clients: Restart the process or reload the server config.
===========================================
"""
def parse_gitea_mcp_config(path):
if not path or not os.path.exists(path):
return {}
with open(path, "r", encoding="utf-8") as fh:
try:
return json.load(fh)
except Exception:
return {}
def read_json_rpc_response(proc, req_id):
import time
start_time = time.time()
while time.time() - start_time < 5.0:
line = proc.stdout.readline()
if not line:
break
try:
data = json.loads(line)
if data.get("id") == req_id:
return data
except json.JSONDecodeError:
continue
return None
def query_live_tools(command, args, env):
run_env = os.environ.copy()
if env:
run_env.update(env)
proc = subprocess.Popen(
[command] + args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=run_env,
text=True,
bufsize=1
)
tools = []
try:
# 1. Send initialize
init_req = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "mcp-discoverability-check", "version": "1.0.0"}
}
}
proc.stdin.write(json.dumps(init_req) + "\n")
proc.stdin.flush()
# Read init response
init_resp = read_json_rpc_response(proc, 1)
if init_resp:
# Send initialized notification
init_notif = {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
proc.stdin.write(json.dumps(init_notif) + "\n")
proc.stdin.flush()
# 2. Send tools/list
tools_req = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
proc.stdin.write(json.dumps(tools_req) + "\n")
proc.stdin.flush()
tools_resp = read_json_rpc_response(proc, 2)
if tools_resp and "result" in tools_resp and "tools" in tools_resp["result"]:
for t in tools_resp["result"]["tools"]:
tools.append(t["name"])
except Exception as e:
print(f"Error querying live tools: {e}", file=sys.stderr)
finally:
proc.terminate()
try:
proc.wait(timeout=2)
except Exception:
proc.kill()
return set(tools)
def validate_mcp_client_config(client_config_path, gitea_config_path=None, live=False):
if not client_config_path or not os.path.exists(client_config_path):
print(f"SKIPPED: MCP client config not found at '{client_config_path}'", file=sys.stderr)
return True
with open(client_config_path, "r", encoding="utf-8") as fh:
try:
config_data = json.load(fh)
except Exception as e:
print(f"Error parsing client config: {e}", file=sys.stderr)
return False
mcp_servers = config_data.get("mcpServers", {})
# Check for stale server names
stale_names = {"jenkins-readonly", "glitchtip-readonly"}
for name in mcp_servers:
if name in stale_names:
print(f"ERROR: Stale server name '{name}' configured. Use canonical names 'jenkins-mcp' or 'glitchtip-mcp'.", file=sys.stderr)
return False
gitea_data = parse_gitea_mcp_config(gitea_config_path)
enabled_services = set()
contexts = gitea_data.get("contexts", {})
profile_name = os.environ.get("GITEA_MCP_PROFILE")
if profile_name and "profiles" in gitea_data:
profile = gitea_data["profiles"].get(profile_name)
if profile and "context" in profile:
ctx_name = profile["context"]
ctx = contexts.get(ctx_name, {})
if ctx.get("enabled"):
services = ctx.get("services", {})
for s_name, s_data in services.items():
if s_data.get("enabled"):
enabled_services.add(s_name)
else:
for ctx_name, ctx in contexts.items():
if ctx.get("enabled"):
services = ctx.get("services", {})
for s_name, s_data in services.items():
if s_data.get("enabled"):
enabled_services.add(s_name)
if not enabled_services:
print("No external services enabled in Gitea contexts. Discoverability check complete.", file=sys.stderr)
return True
success = True
for service in enabled_services:
canonical_name = f"{service}-mcp"
if canonical_name not in mcp_servers:
stale_match = f"{service}-readonly"
if stale_match in mcp_servers:
print(f"ERROR: Server '{canonical_name}' references stale name '{stale_match}' (fail closed).", file=sys.stderr)
success = False
continue
print(f"ERROR: Enabled service '{service}' is not registered under canonical name '{canonical_name}' in client config.", file=sys.stderr)
success = False
continue
server_conf = mcp_servers[canonical_name]
command = server_conf.get("command")
args = server_conf.get("args") or []
env = server_conf.get("env") or {}
if not command:
print(f"ERROR: Server '{canonical_name}' has no command configured.", file=sys.stderr)
success = False
continue
expected_module = f"{service}_mcp"
if "-m" not in args or expected_module not in args:
print(f"ERROR: Server '{canonical_name}' args do not point to expected module '{expected_module}' (args: {args}).", file=sys.stderr)
success = False
continue
profile_var = f"{service.upper()}_MCP_PROFILE"
config_var = f"{service.upper()}_MCP_CONFIG"
if profile_var not in env or config_var not in env:
print(f"ERROR: Server '{canonical_name}' env is missing required variables '{profile_var}' or '{config_var}'.", file=sys.stderr)
success = False
continue
if live:
tools = query_live_tools(command, args, env)
if not tools:
print("SKIPPED: server enabled but no usable tools visible", file=sys.stdout)
success = False
continue
expected = EXPECTED_JENKINS_TOOLS if service == "jenkins" else EXPECTED_GLITCHTIP_TOOLS
missing = expected - tools
if missing:
print(f"ERROR: Server '{canonical_name}' is missing expected tools: {', '.join(missing)}", file=sys.stderr)
success = False
else:
print(f"SUCCESS: Server '{canonical_name}' discoverability verified.", file=sys.stderr)
else:
print(f"SUCCESS: Server '{canonical_name}' static registration verified.", file=sys.stderr)
return success
def main():
parser = argparse.ArgumentParser(description="MCP client registration discoverability checks.")
parser.add_argument("--client-config", help="Path to MCP client config JSON file.")
parser.add_argument("--gitea-config", help="Path to Gitea MCP config JSON file.")
parser.add_argument("--live", action="store_true", help="Perform live stdio checks on configured servers.")
parser.add_argument("--runbook", action="store_true", help="Print reload/reconnect guide runbook instructions.")
args = parser.parse_args()
if args.runbook:
print(RELOAD_INSTRUCTIONS)
return 0
if not args.client_config:
print("ERROR: --client-config must be specified.", file=sys.stderr)
return 2
ok = validate_mcp_client_config(
client_config_path=args.client_config,
gitea_config_path=args.gitea_config,
live=args.live
)
if not ok:
print(RELOAD_INSTRUCTIONS, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+3688 -41
View File
File diff suppressed because it is too large Load Diff
-333
View File
@@ -1,333 +0,0 @@
"""Merged PR branch and worktree cleanup reconciliation (#269).
Builds dry-run reports for merged pull requests and optionally executes
remote branch deletion and local worktree removal when every safety gate passes.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
def extract_linked_issue(title: str, body: str) -> int | None:
"""Return the first Closes/Fixes issue number from PR metadata."""
for text in (title or "", body or ""):
match = CLOSES_FIXES_RE.search(text)
if match:
return int(match.group(1))
return None
def branch_worktree_folder(branch: str) -> str:
return (branch or "").replace("/", "-")
def resolve_worktree_path(project_root: str, branch: str) -> str:
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
lock_path = (path or ISSUE_LOCK_FILE).strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
lock = read_issue_lock(lock_path)
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
heads: set[str] = set()
for pr in open_prs or []:
head = pr.get("head")
if isinstance(head, dict):
ref = head.get("ref")
else:
ref = head
if ref:
heads.add(str(ref))
return heads
def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
path = (worktree_path or "").strip()
if not path or not os.path.isdir(path):
return {
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
dirty_files = parse_dirty_tracked_files(status_res.stdout or "")
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
return {
"exists": True,
"clean": not dirty_files,
"current_branch": current_branch,
"head_sha": head_sha,
"dirty_files": dirty_files,
}
def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
if not head_sha:
return None
res = subprocess.run(
["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
return True
if res.returncode == 1:
return False
return None
def assess_remote_branch_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
remote_branch_exists: bool,
open_pr_heads: set[str],
protected_branches: frozenset[str] | None = None,
head_on_master: bool | None,
delete_capability_allowed: bool,
active_lock: bool,
) -> dict[str, Any]:
protected = protected_branches or PROTECTED_BRANCHES
reasons: list[str] = []
if not merged:
reasons.append("PR is not merged")
if not remote_branch_exists:
reasons.append("remote branch already absent")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if active_lock:
reasons.append("active issue lock references this branch")
if head_on_master is False:
reasons.append("PR head is not an ancestor of master")
if not delete_capability_allowed:
reasons.append("delete_branch capability is not allowed in the active profile")
safe = merged and remote_branch_exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"remote_branch_exists": remote_branch_exists,
"safe_to_delete_remote": safe,
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
def assess_local_worktree_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
worktree_state: dict[str, Any],
active_lock: bool,
) -> dict[str, Any]:
reasons: list[str] = []
exists = bool(worktree_state.get("exists"))
if not merged:
reasons.append("PR is not merged")
if not exists:
reasons.append("local worktree not present")
if exists and worktree_state.get("clean") is False:
dirty = worktree_state.get("dirty_files") or []
reasons.append(
"local worktree has tracked edits"
+ (f" ({', '.join(dirty)})" if dirty else "")
)
if exists:
current_branch = worktree_state.get("current_branch")
if current_branch and current_branch != head_branch:
reasons.append(
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
)
if active_lock:
reasons.append("active issue lock references this branch")
safe = merged and exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"worktree_path": worktree_state.get("worktree_path"),
"worktree_exists": exists,
"worktree_clean": worktree_state.get("clean"),
"safe_to_remove_worktree": safe,
"block_reasons": reasons,
"recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
}
def build_pr_cleanup_entry(
*,
pr: dict[str, Any],
project_root: str,
open_pr_heads: set[str],
remote_branch_exists: bool,
head_on_master: bool | None,
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
pr_number = int(pr["number"])
head_branch = pr.get("head") or ""
if isinstance(head_branch, dict):
head_branch = head_branch.get("ref") or ""
title = pr.get("title") or ""
body = pr.get("body") or ""
merged = bool(pr.get("merged_at"))
worktree_path = resolve_worktree_path(project_root, head_branch)
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
remote = assess_remote_branch_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
remote_branch_exists=remote_branch_exists,
open_pr_heads=open_pr_heads,
protected_branches=protected_branches,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
active_lock=active_lock,
)
local = assess_local_worktree_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
worktree_state=worktree_state,
active_lock=active_lock,
)
return {
"pr_number": pr_number,
"issue_number": extract_linked_issue(title, body),
"title": title,
"head_branch": head_branch,
"merge_commit_sha": pr.get("merge_commit_sha"),
"merged_at": pr.get("merged_at"),
"merged": merged,
"remote_branch": remote,
"local_worktree": local,
}
def build_reconciliation_report(
*,
project_root: str,
closed_prs: list[dict[str, Any]],
open_prs: list[dict[str, Any]],
remote_branch_exists: dict[str, bool],
head_on_master: dict[int, bool | None],
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
open_heads = collect_open_pr_heads(open_prs)
entries: list[dict[str, Any]] = []
for pr in closed_prs or []:
if not pr.get("merged_at"):
continue
pr_number = int(pr["number"])
head = pr.get("head") or ""
if isinstance(head, dict):
head = head.get("ref") or ""
entries.append(
build_pr_cleanup_entry(
pr=pr,
project_root=project_root,
open_pr_heads=open_heads,
remote_branch_exists=bool(remote_branch_exists.get(head)),
head_on_master=head_on_master.get(pr_number),
delete_capability_allowed=delete_capability_allowed,
issue_lock_path=issue_lock_path,
protected_branches=protected_branches,
)
)
return {
"project_root": os.path.realpath(project_root),
"merged_pr_count": len(entries),
"entries": entries,
"dry_run": True,
"executed": False,
}
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
worktree_path = resolve_worktree_path(project_root, branch)
if not os.path.isdir(worktree_path):
return {
"success": False,
"performed": False,
"message": f"worktree not found: {worktree_path}",
}
res = subprocess.run(
["git", "-C", project_root, "worktree", "remove", worktree_path],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return {
"success": False,
"performed": False,
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
}
return {
"success": True,
"performed": True,
"message": f"removed worktree {worktree_path}",
"worktree_path": worktree_path,
}
+82 -16
View File
@@ -7,16 +7,16 @@ making any API call. Merge is handled solely by the gated `gitea_merge_pr` MCP
workflow (#16), which enforces identity/profile/eligibility, explicit
confirmation, expected head SHA checking, and self-merge protection.
Live review submission is also disabled (#211): use the gated
``gitea_submit_pr_review`` MCP workflow, which enforces validation-phase
dry-run, final decision marking, and single-terminal review mutation rules.
Usage (review only — disabled):
Usage (review only):
review_pr.py --pr-number 12 --event APPROVE --body "Approved and signed off"
"""
import os
import sys
import json
import base64
import argparse
import urllib.request
import urllib.error
# Auto-execute using the project's local virtual environment Python
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
@@ -24,7 +24,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import add_remote_args
from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url, get_profile
def main(argv=None):
@@ -42,26 +42,92 @@ def main(argv=None):
help="Ignored — CLI merge is disabled (see --merge).")
args = parser.parse_args(argv)
# Fail closed: direct CLI merge is disabled (#16). LLM automations were
# using this flag as an ungated merge bypass. Merge is only available via
# the gated `gitea_merge_pr` MCP workflow, which enforces
# identity/profile/eligibility, explicit confirmation, expected head SHA,
# and self-merge protection. No API call is made here.
if args.merge:
print(
"Direct CLI merge is disabled. Merge is only available through the "
"gated #16 workflow (MCP tool 'gitea_merge_pr'), which enforces "
"identity/profile/eligibility, explicit confirmation, expected head "
"SHA checking, and self-merge protection. Re-run without --merge to "
"see the review-submission guard message.",
"submit a review only.",
file=sys.stderr,
)
return 2
print(
"Direct CLI review submission is disabled (#211). Use the gated "
"'gitea_submit_pr_review' MCP workflow, which enforces validation-phase "
"dry-run, gitea_mark_final_review_decision, and single-terminal review "
"mutation rules.",
file=sys.stderr,
)
return 2
host, org, repo = resolve_remote(args)
# ── Mutation Authority context wall check (Issue #194) ──
LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
# Resolve current CLI profile
cli_profile = get_profile().get("profile_name")
locked_profile = lock_data.get("current_profile")
if cli_profile != locked_profile:
print(
f"Mismatched active profile vs mutation profile (CLI override rejected): "
f"CLI profile '{cli_profile}' does not match locked active profile '{locked_profile}' (fail closed)",
file=sys.stderr
)
return 3
except Exception as e:
print(f"Mutation authority check failed: {e}", file=sys.stderr)
return 3
body = args.body
if args.body_file:
if args.body_file == "-":
body = sys.stdin.read()
else:
with open(args.body_file, "r", encoding="utf-8") as fh:
body = fh.read()
auth = get_auth_header(host)
if not auth:
print(f"Could not get credentials or token for {host}.", file=sys.stderr)
return 1
# 1. Fetch PR to get the latest head commit SHA (required for review validation)
pr_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}"
try:
pr_data = api_request("GET", pr_url, auth)
except Exception as e:
print(f"Error fetching PR #{args.pr_number}: {e}", file=sys.stderr)
return 1
commit_sha = pr_data.get("head", {}).get("sha")
if not commit_sha:
print(f"Could not find head commit SHA for PR #{args.pr_number}.", file=sys.stderr)
return 1
# 2. Submit the PR review
review_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}/reviews"
payload = {
"body": body,
"event": args.event,
"commit_id": commit_sha
}
try:
api_request("POST", review_url, auth, payload)
print(f"Successfully submitted review for PR #{args.pr_number}: event={args.event}")
except Exception as e:
print(f"Error submitting review: {e}", file=sys.stderr)
return 1
# Merge is intentionally not performed here — see the fail-closed guard
# above. Use the gated `gitea_merge_pr` MCP workflow (#16) to merge.
return 0
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
+12 -3699
View File
File diff suppressed because it is too large Load Diff
-164
View File
@@ -1,164 +0,0 @@
"""Prior-blocker skip proof verifier for reviewer queue reports (#318)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_BLOCKING_DECISION_RE = re.compile(
r"(?:blocking review decision|review decision|blocking decision)\s*:\s*request[_ ]changes|"
r"request[_ ]changes",
re.IGNORECASE,
)
_BLOCKING_HEAD_RE = re.compile(
r"(?:blocking review head(?:\s+sha)?|blocker head(?:\s+sha)?|review head at blocker)",
re.IGNORECASE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed (?:after|since) (?:the )?blocker|head unchanged since blocker)",
re.IGNORECASE,
)
_BLOCKER_REASON_RE = re.compile(
r"(?:reason (?:it )?remains blocked|remains blocked because|blocking category)",
re.IGNORECASE,
)
_LIVE_BLOCKER_PROOF_RE = re.compile(
r"(?:blocker revalidated live|live proof|gitea_get_pr_review_feedback|"
r"gitea_view_pr|review feedback fetched|current review state)",
re.IGNORECASE,
)
_BLOCKER_UNVERIFIED_RE = re.compile(r"\bBLOCKER_STATUS_UNVERIFIED\b")
def _pr_section(text: str, pr_number: int) -> str:
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_prior_blocker_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate live blocker proof for skipped earlier open PRs (#318)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
verified = entry.get("blocker_verified")
head_changed = entry.get("head_changed_since_blocker")
blocking_head = (entry.get("blocking_review_head_sha") or "").strip() or None
current_head = (entry.get("head_sha") or "").strip() or None
skip_reason = (entry.get("skip_reason") or entry.get("blocking_decision") or "").lower()
item_reasons: list[str] = []
if head_changed is True:
item_reasons.append(
f"PR #{pr_number} head changed after blocker; it cannot be skipped "
"based on stale REQUEST_CHANGES"
)
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if "request_changes" in skip_reason or entry.get("blocking_decision") == "request_changes":
if verified is False:
if not _BLOCKER_UNVERIFIED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} blocker proof unavailable; report must classify "
"BLOCKER_STATUS_UNVERIFIED"
)
else:
if not _BLOCKING_DECISION_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing blocking review decision proof"
)
if not _has_head_sha(section, current_head):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if blocking_head and not _BLOCKING_HEAD_RE.search(section):
if blocking_head.lower() not in section.lower():
item_reasons.append(
f"PR #{pr_number} skip missing blocking review head SHA"
)
if head_changed is False and not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if not _BLOCKER_REASON_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing reason-it-remains-blocked"
)
if not _LIVE_BLOCKER_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing live blocker proof for this session"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": (
"BLOCKER_STATUS_UNVERIFIED"
if verified is False
else "BLOCKED_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"fetch live review feedback and document blocker proof for each skipped PR, "
"or classify BLOCKER_STATUS_UNVERIFIED"
if reasons
else "proceed"
),
}
-213
View File
@@ -1,213 +0,0 @@
"""Reviewer local-fallback detection for normal PR review workflows (#324).
Normal reviewer runs must use MCP tools. Reading profile secret files or
running local Gitea helper scripts while MCP is available is a fail-closed
violation. Explicit recovery mode may use local fallback only with full proof.
"""
from __future__ import annotations
import re
from typing import Any
LOCAL_GITEA_SCRIPT_NAMES = (
"list_prs.py",
"view_pr.py",
"create_pr.py",
"merge_pr.py",
"edit_pr.py",
"list_issues.py",
"delete_branch.py",
"create_issue.py",
"close_issue.py",
"review_pr.py",
"mark_issue.py",
"mirror_refs.sh",
)
PROFILE_SECRET_MARKERS = (
"profiles.json",
".config/gitea-tools/profiles",
"gitea_auth.py",
"gitea_config.py",
"keychain",
"credential fill",
"token store",
)
_RECOVERY_MODE_RE = re.compile(
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
r"mcp tools unavailable|no mcp path)\b",
re.IGNORECASE,
)
_FALLBACK_CLASSIFICATION_RE = re.compile(
r"\b(?:local fallback|fallback mode|used local gitea|ran local script)\b",
re.IGNORECASE,
)
_MCP_TOOL_USE_RE = re.compile(
r"\b(?:gitea_list_prs|gitea_view_pr|gitea_review_pr|gitea_merge_pr|"
r"gitea_resolve_task_capability|gitea_whoami|mcp tool)\b",
re.IGNORECASE,
)
_LOCAL_SCRIPT_RE = re.compile(
r"(?:^|[\s\"'`/])(?:" + "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES) + r")",
re.IGNORECASE,
)
_PROFILE_ACCESS_RE = re.compile(
r"(?:read|open|inspect|cat|view|access(?:ed)?|loaded?)\s+(?:file\s+)?[`'\"]?"
r"[^`'\"]*profiles\.json|profiles\.json[`'\"]?\s+(?:read|opened|inspected|accessed)|"
r"~/?\.config/gitea-tools/profiles",
re.IGNORECASE,
)
_LOCAL_ENV_SCRIPT_RE = re.compile(
r"GITEA_MCP_(?:CONFIG|PROFILE)=.*\b(?:python\s+)?(?:list_prs|view_pr|create_pr|merge_pr)\.py",
re.IGNORECASE,
)
_RECOVERY_PROOF_FIELDS = (
("identity proof", ("identity proof", "exact identity proof")),
("profile proof", ("profile proof", "exact profile proof")),
("repo proof", ("repo proof", "exact repo proof")),
("capability proof", ("capability proof", "exact capability proof")),
("mcp unavailable reason", (
"why mcp was unavailable",
"mcp unavailable",
"mcp unavailability",
)),
)
def _collect_observed_text(
report_text: str,
action_log: list[dict] | None,
) -> str:
chunks = [report_text or ""]
for entry in action_log or []:
for key in ("command", "action", "detail", "path", "script"):
value = entry.get(key)
if value:
chunks.append(str(value))
return "\n".join(chunks)
def _detect_profile_secret_access(text: str) -> list[str]:
reasons = []
lower = text.lower()
if _PROFILE_ACCESS_RE.search(text):
reasons.append("report or action log shows profiles.json/profile secret access")
for marker in PROFILE_SECRET_MARKERS:
if marker == "profiles.json":
continue
if marker in lower and "do not" not in lower and "must not" not in lower:
if any(verb in lower for verb in ("read ", "open ", "inspect ", "cat ", "loaded ")):
reasons.append(f"profile secret surface '{marker}' accessed during review")
return reasons
def _detect_local_script_use(text: str) -> list[str]:
reasons = []
match = _LOCAL_SCRIPT_RE.search(text)
if match:
reasons.append(
f"local Gitea helper script invoked ({match.group(0).strip()})"
)
if _LOCAL_ENV_SCRIPT_RE.search(text):
reasons.append(
"local Gitea script run with GITEA_MCP_CONFIG/GITEA_MCP_PROFILE env overrides"
)
return reasons
def _recovery_proof_missing(text: str) -> list[str]:
lower = text.lower()
missing = []
for label, aliases in _RECOVERY_PROOF_FIELDS:
if not any(alias in lower for alias in aliases):
missing.append(label)
if not _FALLBACK_CLASSIFICATION_RE.search(text):
missing.append("fallback classification")
if not _RECOVERY_MODE_RE.search(text):
missing.append("recovery mode declaration")
return missing
def assess_reviewer_fallback_report(
report_text: str,
*,
action_log: list[dict] | None = None,
recovery_mode: bool = False,
mcp_available: bool = True,
mcp_tools_used: bool | None = None,
) -> dict[str, Any]:
"""Fail closed when normal reviewer workflows use local Gitea fallbacks (#324)."""
text = _collect_observed_text(report_text, action_log)
lower = (report_text or "").lower()
profile_violations = _detect_profile_secret_access(text)
script_violations = _detect_local_script_use(text)
violations = profile_violations + script_violations
if mcp_tools_used is None:
mcp_tools_used = bool(_MCP_TOOL_USE_RE.search(report_text or ""))
elif mcp_tools_used is False and _MCP_TOOL_USE_RE.search(report_text or ""):
mcp_tools_used = True
reasons: list[str] = []
recovery_declared = recovery_mode or bool(_RECOVERY_MODE_RE.search(report_text or ""))
if violations and mcp_available and not recovery_declared:
reasons.extend(violations)
if mcp_tools_used:
reasons.append(
"MCP tools were available and used; local fallback/profile access is forbidden"
)
else:
reasons.append(
"MCP path is available; normal review must not use local Gitea fallbacks"
)
if violations and recovery_declared:
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"recovery-mode fallback missing proof fields: "
+ ", ".join(missing)
)
if (
not violations
and recovery_declared
and _FALLBACK_CLASSIFICATION_RE.search(report_text or "")
and not recovery_mode
):
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"report claims local fallback but recovery proof is incomplete: "
+ ", ".join(missing)
)
proven = not reasons
blocked = bool(reasons) and not recovery_declared or any(
"recovery-mode fallback missing" in r or "recovery proof is incomplete" in r
for r in reasons
)
return {
"proven": proven,
"block": blocked or (bool(reasons) and mcp_available and not recovery_declared),
"downgraded": bool(reasons) and not blocked,
"recovery_mode": recovery_declared,
"mcp_available": mcp_available,
"mcp_tools_used": mcp_tools_used,
"profile_violations": profile_violations,
"script_violations": script_violations,
"reasons": reasons,
"safe_next_action": (
"use MCP reviewer tools only; do not read profiles.json or run local Gitea scripts"
if reasons and not recovery_declared
else (
"complete recovery-mode fallback proof before claiming local fallback"
if reasons
else "proceed with MCP tools"
)
),
}
-159
View File
@@ -1,159 +0,0 @@
"""Non-mergeable PR skip conflict-proof verifier for reviewer reports (#322)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_MERGEABILITY_FALSE_RE = re.compile(
r"(?:mergeable\s*:\s*false|not mergeable|non[- ]mergeable|mergeability\s*:\s*false|"
r"mergeability result\s*:\s*false)",
re.IGNORECASE,
)
_CONFLICT_PROOF_RE = re.compile(
r"(?:merge-tree|git merge-tree|gitea_view_pr|gitea_check_pr_eligibility|"
r"conflict proof|mergeability tool|merge simulation|conflicting files?)",
re.IGNORECASE,
)
_CONFLICTING_FILES_RE = re.compile(
r"(?:conflicting files?|conflict files?)\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed since|head unchanged since)",
re.IGNORECASE,
)
_MERGEABILITY_UNVERIFIED_RE = re.compile(
r"\bMERGEABILITY_UNVERIFIED\b",
)
def _pr_section(text: str, pr_number: int) -> str:
"""Return report lines likely describing one skipped PR."""
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_non_mergeable_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate skipped non-mergeable PR documentation in reviewer reports (#322)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
mergeable = entry.get("mergeable")
verified = entry.get("mergeability_verified")
classification = (entry.get("classification") or "").strip().upper()
head_sha = (entry.get("head_sha") or "").strip() or None
item_reasons: list[str] = []
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if mergeable is False or verified is False:
if not _MERGEABILITY_UNVERIFIED_RE.search(section) and verified is False:
if "MERGEABILITY_UNVERIFIED" not in classification:
item_reasons.append(
f"PR #{pr_number} conflict proof unavailable; report must classify "
"MERGEABILITY_UNVERIFIED"
)
elif verified is not False:
if not _MERGEABILITY_FALSE_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing mergeability:false proof"
)
if not _has_head_sha(section, head_sha):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if not _CONFLICT_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing conflict proof command/tool"
)
if entry.get("head_changed_since_prior_blocker") is not None:
if not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if (
entry.get("conflicting_files")
and not _CONFLICTING_FILES_RE.search(section)
and not any(
path.lower() in section.lower()
for path in (entry.get("conflicting_files") or [])
)
):
item_reasons.append(
f"PR #{pr_number} has conflicting files in session proof but "
"report omits file-level conflict proof"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": classification or (
"MERGEABILITY_UNVERIFIED" if verified is False else "NON_MERGEABLE_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"document each skipped non-mergeable PR with head SHA, mergeability result, "
"conflict proof command, conflicting files, and head-change status"
if reasons
else "proceed"
),
}
-210
View File
@@ -1,210 +0,0 @@
"""Fail-closed reviewer worktree and local-git safety proofs (#233).
Reviewer sessions must never stash, reset, or otherwise manipulate unrelated
local changes from another session. When the active worktree has dirty tracked
files outside the PR scope, the workflow must stop or switch to a disposable
scratch worktree (``scripts/worktree-review``).
Git command policy (#243): reviewers use an allowlist, not a blocklist.
Any ``git`` invocation that does not match ``_READONLY_REVIEWER_GIT`` is
forbidden — including ``checkout HEAD --``, ``checkout .``, ``switch``,
and uncommon ``stash`` subcommands that older blocklists missed.
"""
from __future__ import annotations
import re
import shlex
# Read-only git operations reviewers may use for validation (#243 allowlist).
_READONLY_REVIEWER_GIT = re.compile(
r"\bgit\b(?:\s+(?:-C\s+\S+\s+)?)?"
r"(?:fetch|status|diff|log|show|rev-parse|branch(?:\s+--show-current)?|"
r"worktree\s+list|worktree\s+add)",
re.IGNORECASE,
)
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
"""Return tracked paths with local modifications from ``git status --porcelain``.
Untracked entries (``??``) are ignored — they do not block reviewer work
when a scratch worktree is used, and authors may have unrelated untracked
files without implying reviewer interference.
"""
paths: list[str] = []
for line in (porcelain or "").splitlines():
if not line or len(line) < 4:
continue
if line.startswith("??"):
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ", 1)[1].strip()
if path:
paths.append(path)
return paths
def files_outside_pr_scope(
dirty_files: list[str] | None,
pr_scope_files: list[str] | None,
) -> list[str]:
"""Dirty tracked files not explained by the PR diff file set."""
dirty = [p for p in (dirty_files or []) if p]
scope = {p for p in (pr_scope_files or []) if p}
if not dirty:
return []
if not scope:
return list(dirty)
return [path for path in dirty if path not in scope]
def _is_git_command(command: str) -> bool:
return bool(_GIT_INVOCATION.search((command or "").strip()))
def is_readonly_reviewer_git_command(command: str) -> bool:
"""True when the command is an explicitly allowed read-only git operation."""
text = (command or "").strip()
if not text or not _is_git_command(text):
return False
return bool(_READONLY_REVIEWER_GIT.search(text))
def is_forbidden_reviewer_git_command(command: str) -> bool:
"""True when a git command is not on the reviewer readonly allowlist."""
text = (command or "").strip()
if not text or not _is_git_command(text):
return False
return not is_readonly_reviewer_git_command(text)
def assess_reviewer_git_command_log(commands: list[str] | None) -> dict:
"""Fail closed when reviewer shell history includes forbidden git mutations."""
forbidden = [
cmd for cmd in (commands or []) if is_forbidden_reviewer_git_command(cmd)
]
if forbidden:
return {
"proven": False,
"block": True,
"forbidden_commands": forbidden,
"reasons": [
"reviewer workflow executed forbidden local git mutation: "
f"{cmd!r}"
for cmd in forbidden
],
"safe_next_action": (
"stop; report worktree interference; do not stash/reset/checkout "
"unrelated files — use scripts/worktree-review instead"
),
}
return {
"proven": True,
"block": False,
"forbidden_commands": [],
"reasons": [],
"safe_next_action": "proceed",
}
def assess_reviewer_worktree_proof(proof: dict | None) -> dict:
"""Evaluate reviewer worktree safety before checkout/diff/validation/review.
*proof* keys:
- ``worktree_path`` (required)
- ``porcelain_status`` or ``dirty_files``
- ``pr_scope_files`` (paths in the PR diff)
- ``scratch_used`` (bool)
- ``scratch_path`` (when scratch_used)
- ``git_commands`` (shell commands executed this session)
- ``unrelated_mutations_claimed`` (bool) — stash/reset/drop reported
"""
proof = dict(proof or {})
reasons: list[str] = []
worktree_path = (proof.get("worktree_path") or "").strip()
if not worktree_path:
reasons.append("reviewer worktree path not reported; fail closed")
if proof.get("dirty_files") is not None:
dirty_files = list(proof.get("dirty_files") or [])
else:
dirty_files = parse_dirty_tracked_files(proof.get("porcelain_status") or "")
pr_scope = list(proof.get("pr_scope_files") or [])
unrelated = files_outside_pr_scope(dirty_files, pr_scope)
scratch_used = bool(proof.get("scratch_used"))
scratch_path = (proof.get("scratch_path") or "").strip()
is_dirty = bool(dirty_files)
unrelated_dirty = bool(unrelated)
if unrelated_dirty and not scratch_used:
reasons.append(
"worktree has dirty tracked files outside PR scope "
f"({', '.join(unrelated)}); stop or use a scratch worktree"
)
if scratch_used and not scratch_path:
reasons.append(
"scratch worktree was used but scratch_path was not reported"
)
if proof.get("unrelated_mutations_claimed"):
reasons.append(
"reviewer reported stash/reset/checkout cleanup of unrelated "
"local changes; this is forbidden"
)
command_assessment = assess_reviewer_git_command_log(
list(proof.get("git_commands") or [])
)
if command_assessment["block"]:
reasons.extend(command_assessment["reasons"])
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"is_dirty": is_dirty,
"dirty_files": dirty_files,
"unrelated_dirty_files": unrelated,
"scratch_used": scratch_used,
"scratch_path": scratch_path or None,
"unrelated_mutations_avoided": not bool(
proof.get("unrelated_mutations_claimed")
or command_assessment.get("forbidden_commands")
),
"safe_next_action": (
"proceed"
if proven
else command_assessment.get("safe_next_action")
or "stop; use scripts/worktree-review or report dirty worktree"
),
"forbidden_commands": command_assessment.get("forbidden_commands", []),
}
def assess_author_worktree_continuity(proof: dict | None) -> dict:
"""Authors may keep dirty feature worktrees; reviewers may not manipulate them.
This helper only proves the task role is author when dirty unrelated files
exist — it does not grant reviewers an exception.
"""
proof = dict(proof or {})
role = (proof.get("task_role") or "").strip().lower()
dirty_files = list(proof.get("dirty_files") or [])
if role == "author" and dirty_files:
return {
"allowed": True,
"reasons": [
"author task may continue with dirty tracked files in its own "
"worktree; reviewer interference rules do not apply"
],
}
if role == "reviewer" and dirty_files:
return assess_reviewer_worktree_proof(proof)
return {"allowed": True, "reasons": []}
-89
View File
@@ -1,89 +0,0 @@
"""Block author mutations from reviewer-bound MCP namespaces (#209).
Every mutation must prove that the active profile role, inferred MCP
namespace, and declared task role align. Reviewer sessions cannot silently
perform author-side work (especially PR creation).
"""
from __future__ import annotations
import gitea_config
import role_session_router
def infer_mcp_namespace(profile_name: str | None) -> str:
"""Map a runtime profile name to its MCP namespace label."""
lower = (profile_name or "").strip().lower()
if "reviewer" in lower and "author" not in lower:
return "gitea-reviewer"
if "author" in lower:
return "gitea-author"
return profile_name or "gitea-default"
def derive_role_kind(allowed, forbidden=()) -> str:
"""Classify the active profile the same way as ``mcp_server._role_kind``."""
def can(op):
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
author = can("gitea.pr.create") or can("gitea.branch.push")
if review and author:
return "mixed"
if review:
return "reviewer"
if author:
return "author"
return "limited"
def check_author_mutation_namespace(
mutation_task: str,
profile: dict,
) -> tuple[bool, list[str]]:
"""Return (allowed, reasons). Fail closed on reviewer/author mismatch."""
required_role = role_session_router.required_role_for_task(mutation_task)
if required_role != "author":
return True, []
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
active_role = derive_role_kind(allowed, forbidden)
profile_name = profile.get("profile_name") or ""
namespace = infer_mcp_namespace(profile_name)
if mutation_task == "create_pr":
if active_role == "reviewer" or namespace == "gitea-reviewer":
return False, [
"author mutation 'create_pr' blocked in reviewer MCP namespace "
f"({namespace}); launch gitea-author",
]
if active_role == "reviewer" or namespace == "gitea-reviewer":
if mutation_task == "create_issue":
ok, _ = gitea_config.check_operation(
"gitea.issue.create", allowed, forbidden)
if ok:
return True, []
return False, [
f"author mutation '{mutation_task}' blocked: active session is "
f"reviewer-bound ({profile_name} / {namespace})",
]
return True, []
def mutation_audit_context(mutation_task: str, profile: dict, *,
remote=None, repository=None) -> dict:
"""Structured mutation metadata for audit records (#209)."""
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
return {
"mcp_namespace": infer_mcp_namespace(profile.get("profile_name")),
"profile_name": profile.get("profile_name"),
"task_role": role_session_router.required_role_for_task(mutation_task),
"operation": mutation_task,
"remote": remote,
"repository": repository,
}
-362
View File
@@ -1,362 +0,0 @@
"""Pre-task role/session router (#206).
Classifies a declared task type against the active MCP profile/session and
returns a route result before any downstream mutation tools run.
"""
from __future__ import annotations
import os
ROUTE_ALLOWED = "allowed_current_session"
ROUTE_WRONG_ROLE = "wrong_role_stop"
ROUTE_TO_AUTHOR = "route_to_author_session"
ROUTE_TO_REVIEWER = "route_to_reviewer_session"
ROUTE_AMBIGUOUS = "ambiguous_task_stop"
ROUTE_INFRA_STOP = "infra_stop"
_CONFLICT_HEAD = b"<" * 7 + b" "
_CONFLICT_TAIL = b">" * 7 + b" "
_CONFLICT_SEPARATOR = b"=" * 7
def python_bytes_have_conflict_markers(content: bytes) -> bool:
"""Return True when *content* contains git merge-conflict marker lines."""
for line in content.splitlines():
stripped = line.rstrip(b"\r\n")
if stripped.startswith(_CONFLICT_HEAD):
return True
if stripped.startswith(_CONFLICT_TAIL):
return True
if stripped == _CONFLICT_SEPARATOR:
return True
return False
def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
"""Skip venv/git/cache and sibling worktrees under orchestration checkout.
When *project_root* is itself a worktree inside ``branches/``, still scan
that tree do not treat the ``branches`` path segment as a skip signal.
"""
rel = os.path.relpath(walk_root, project_root)
if rel == ".":
return False
head = rel.split(os.sep, 1)[0]
if head in ("venv", ".git", ".pytest_cache"):
return True
if head == "branches":
nested = os.path.join(project_root, "branches")
if os.path.isdir(nested) and (
walk_root == nested or walk_root.startswith(nested + os.sep)
):
return True
return False
REVIEWER_TASKS = frozenset({
"review_pr",
"merge_pr",
"blind_pr_queue_review",
"request_changes_pr",
"approve_pr",
})
AUTHOR_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"claim_issue",
"create_branch",
"push_branch",
"create_pr",
"comment_pr",
"address_pr_change_requests",
"delete_branch",
})
TASK_REQUIRED_ROLE = {
"create_issue": "author",
"comment_issue": "author",
"close_issue": "author",
"claim_issue": "author",
"create_branch": "author",
"push_branch": "author",
"create_pr": "author",
"comment_pr": "author",
"address_pr_change_requests": "author",
"delete_branch": "author",
"review_pr": "reviewer",
"merge_pr": "reviewer",
"blind_pr_queue_review": "reviewer",
"request_changes_pr": "reviewer",
"approve_pr": "reviewer",
}
WRONG_ROLE_REVIEWER_MSG = (
"Wrong role/session for reviewer task. Launch reviewer MCP namespace."
)
_session_last_route: dict | None = None
def required_role_for_task(task_type: str) -> str | None:
return TASK_REQUIRED_ROLE.get((task_type or "").strip())
def route_task_session(
task_type: str,
*,
active_profile: str,
active_role_kind: str,
allowed_in_current_session: bool,
runtime_switching_supported: bool = False,
) -> dict:
"""Return routing verdict for *task_type* under the active session."""
task_type = (task_type or "").strip()
required_role = required_role_for_task(task_type)
if required_role is None:
result = {
"task_type": task_type,
"required_role": None,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_AMBIGUOUS,
"downstream_allowed": False,
"reasons": [
f"unknown task type '{task_type}'; cannot route session "
"(fail closed)"
],
"message": (
"Ambiguous task type; relaunch with an explicit task before "
"any tool use."
),
}
_record_route(result)
return result
if required_role == "reviewer":
infra = assess_infra_stop()
if infra["infra_stop"]:
detail = "; ".join(infra.get("infra_stop_reasons") or [])
message = (
"infra_stop: Unresolved merge conflict or mid-merge state detected "
f"in MCP runtime source ({detail}). Please resolve all conflicts "
"manually, finish/abort the merge, and retry."
)
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_INFRA_STOP,
"downstream_allowed": False,
"reasons": [message],
"message": message,
"infra_stop_assessment": infra,
}
_record_route(result)
return result
if allowed_in_current_session:
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_ALLOWED,
"downstream_allowed": True,
"reasons": [],
"message": "Task role matches active session; proceed.",
}
_record_route(result)
return result
if required_role == "reviewer":
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [
WRONG_ROLE_REVIEWER_MSG,
"Reviewer tasks cannot run in author-bound sessions.",
"Static-profile mode does not permit in-place role switching.",
],
"message": WRONG_ROLE_REVIEWER_MSG,
"runtime_switching_supported": runtime_switching_supported,
"profile_switch_blocked": not runtime_switching_supported,
}
_record_route(result)
return result
if required_role == "author":
route = ROUTE_TO_AUTHOR
message = (
"Wrong role/session for author task. Launch author MCP namespace."
)
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": route,
"downstream_allowed": False,
"reasons": [message],
"message": message,
"runtime_switching_supported": runtime_switching_supported,
"profile_switch_blocked": not runtime_switching_supported,
}
_record_route(result)
return result
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_AMBIGUOUS,
"downstream_allowed": False,
"reasons": ["unable to classify task role (fail closed)"],
"message": "Ambiguous task type; stop before any mutation.",
}
_record_route(result)
return result
def last_route() -> dict | None:
return _session_last_route
def clear_route_state():
global _session_last_route
_session_last_route = None
def _record_route(result: dict):
global _session_last_route
_session_last_route = dict(result)
def sync_route_from_capability(capability: dict) -> None:
"""Align sticky route state with operation-scoped capability resolution (#228)."""
capability = capability or {}
task = (capability.get("requested_task") or "").strip()
required_role = capability.get("required_role_kind")
if not task or not required_role:
return
if capability.get("allowed_in_current_session"):
_record_route({
"task_type": task,
"required_role": required_role,
"active_role": required_role,
"active_profile": capability.get("active_profile"),
"route_result": ROUTE_ALLOWED,
"downstream_allowed": True,
"reasons": [],
"message": (
f"Operation-scoped task '{task}' resolved for current session; "
"proceed."
),
})
return
if required_role == "reviewer" and capability.get("stop_required"):
_record_route({
"task_type": task,
"required_role": required_role,
"active_role": capability.get("required_role_kind"),
"active_profile": capability.get("active_profile"),
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [WRONG_ROLE_REVIEWER_MSG],
"message": WRONG_ROLE_REVIEWER_MSG,
})
def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool, list[str]]:
"""Block author-side fallback after a reviewer wrong_role_stop (#206).
An explicit operation-scoped author capability resolution for the same
*mutation_task* clears the sticky reviewer denial (#228).
"""
last = _session_last_route
if not last:
return True, []
if (
last.get("route_result") == ROUTE_ALLOWED
and last.get("task_type") == mutation_task
and last.get("required_role") == "author"
):
return True, []
if last.get("route_result") != ROUTE_WRONG_ROLE:
return True, []
if last.get("required_role") != "reviewer":
return True, []
if mutation_task in AUTHOR_TASKS:
return False, [
WRONG_ROLE_REVIEWER_MSG,
"Author-side mutations are blocked after a reviewer-task "
"wrong_role_stop unless the operator explicitly resolves the "
"author task via gitea_resolve_task_capability.",
f"Attempted fallback mutation: {mutation_task}",
]
return True, []
def first_conflict_marker_path(project_root: str | None = None) -> str | None:
"""Return the first .py path containing a git conflict marker, or None."""
root_dir = project_root or os.path.dirname(os.path.abspath(__file__))
for root, dirs, files in os.walk(root_dir):
if skip_python_scan_walk_root(root_dir, root):
continue
for file in files:
if not file.endswith(".py"):
continue
file_path = os.path.join(root, file)
try:
with open(file_path, "rb") as f:
if python_bytes_have_conflict_markers(f.read()):
return file_path
except OSError:
pass
return None
def _default_project_root() -> str:
override = (os.environ.get("GITEA_MCP_PROJECT_ROOT") or "").strip()
if override:
return os.path.realpath(override)
return os.path.dirname(os.path.abspath(__file__))
def assess_infra_stop(project_root: str | None = None) -> dict:
"""Recompute infra_stop from live git and source scan state (#285)."""
root_dir = os.path.realpath(project_root or _default_project_root())
reasons: list[str] = []
mid_merge = False
git_dir = os.path.join(root_dir, ".git")
if os.path.isdir(git_dir):
for marker in ("MERGE_HEAD", "rebase-merge", "rebase-apply"):
marker_path = os.path.join(git_dir, marker)
if os.path.exists(marker_path):
mid_merge = True
reasons.append(f"git state: {marker} present under {git_dir}")
conflict_file = first_conflict_marker_path(root_dir)
if conflict_file:
reasons.append(
f"conflict markers detected in {conflict_file} (project_root={root_dir})"
)
infra_stop = mid_merge or bool(conflict_file)
return {
"infra_stop": infra_stop,
"project_root": root_dir,
"conflict_file": conflict_file,
"mid_merge": mid_merge,
"infra_stop_reasons": reasons,
}
def check_mid_merge(project_root: str | None = None) -> bool:
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
return assess_infra_stop(project_root)["infra_stop"]
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# sync-gitea-wiki.sh — mirror the repo-tracked docs/wiki/ pages into the
# Gitea native wiki. docs/wiki/ remains the source of truth; the Gitea Wiki
# is a read convenience mirrored FROM it, never edited directly.
#
# scripts/sync-gitea-wiki.sh dry-run (default): print the plan
# scripts/sync-gitea-wiki.sh --push actually sync, ONLY with the exact
# confirmation below
#
# Safety contract:
# - Dry-run is the default and performs no network or repository-mutating
# operation (only a local read of the origin remote URL).
# - A push requires GITEA_WIKI_SYNC_CONFIRM to equal exactly
# "SYNC WIKI <repo-name>" (repo name derived from the origin remote).
# Anything else refuses before any clone happens.
# - The wiki remote is derived from the local origin remote; nothing is
# hardcoded here and no credential material is read, printed, or stored
# by this script — git's own configured auth is used as-is.
# - Only *.md pages from docs/wiki/ are mirrored; nothing else is touched
# and no page is deleted from the wiki by this script.
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$here/.." && pwd)"
wiki_src="$repo_root/docs/wiki"
[ -d "$wiki_src" ] || { echo "error: $wiki_src not found" >&2; exit 1; }
if git -C "$repo_root" remote get-url origin >/dev/null 2>&1; then
origin_url="$(git -C "$repo_root" remote get-url origin)"
elif git -C "$repo_root" remote get-url prgs >/dev/null 2>&1; then
origin_url="$(git -C "$repo_root" remote get-url prgs)"
else
echo "error: configure an origin or prgs git remote" >&2
exit 1
fi
repo_name="$(basename "$origin_url" .git)"
wiki_remote="${origin_url%.git}.wiki.git"
expected_confirm="SYNC WIKI $repo_name"
pages=()
while IFS= read -r -d '' f; do
pages+=("$(basename "$f")")
done < <(find "$wiki_src" -maxdepth 1 -name '*.md' -print0 | sort -z)
mode="${1:-}"
if [ "$mode" != "--push" ]; then
echo "dry-run: would sync ${#pages[@]} pages from docs/wiki/ to the '$repo_name' Gitea Wiki:"
for p in "${pages[@]}"; do
echo " $p"
done
echo "dry-run: no git or network operation performed."
echo "To sync for real: GITEA_WIKI_SYNC_CONFIRM=\"$expected_confirm\" $0 --push"
exit 0
fi
if [ "${GITEA_WIKI_SYNC_CONFIRM:-}" != "$expected_confirm" ]; then
echo "refused: --push requires GITEA_WIKI_SYNC_CONFIRM to equal exactly:" >&2
echo " $expected_confirm" >&2
exit 1
fi
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
echo "cloning wiki repository for '$repo_name'"
if ! git clone --quiet -- "$wiki_remote" "$workdir/wiki" 2>/dev/null; then
echo "error: could not clone the wiki repository. If this repo's wiki has" >&2
echo "never been initialized, create its first page once in the Gitea UI" >&2
echo "(Wiki tab -> New Page), then re-run this script." >&2
exit 1
fi
cp "$wiki_src"/*.md "$workdir/wiki/"
if git -C "$workdir/wiki" status --porcelain | grep -q .; then
git -C "$workdir/wiki" add -A
git -C "$workdir/wiki" commit --quiet -m "docs: sync from repo docs/wiki (source of truth)"
echo "pushing wiki update for '$repo_name'"
git -C "$workdir/wiki" push --quiet
echo "done: ${#pages[@]} pages synced."
else
echo "done: wiki already up to date; nothing pushed."
fi
+479 -129
View File
@@ -1,179 +1,529 @@
---
name: llm-project-workflow
description: >-
Router skill for safe LLM project work: identify task mode, load the matching
canonical workflow file, enforce mode isolation, and emit the correct final
report schema. Use at the start of any implementation, review, merge,
reconciliation, or issue-filing task.
Portable, safe operating workflow for LLMs working on any Git/forge project:
issue-first, isolated branch worktrees, no self-review/self-merge, distinct
author/reviewer profiles, cleanup after merge, and fail-closed behavior.
Use at the start of any implementation, review, or merge task on a repo.
---
# LLM Project Workflow Skill
# LLM Project Workflow
This skill is a **router**. Do not perform project work from this file alone.
A reusable workflow any LLM can follow to work on any repository safely. Copy
this `skills/llm-project-workflow/` directory into another project unchanged;
adapt only the forge-specific names in [Adapting to a project](#adapting-to-a-project).
Before any project mutation, identify the task mode and load the matching
workflow file.
## Workflow modes
| Task mode | Workflow | Final report schema |
|-----------|----------|---------------------|
| PR review / approval / merge | [`workflows/review-merge-pr.md`](workflows/review-merge-pr.md) | [`schemas/review-merge-final-report.md`](schemas/review-merge-final-report.md) |
| Reconcile already-landed open PRs | [`workflows/reconcile-landed-pr.md`](workflows/reconcile-landed-pr.md) | [`schemas/reconcile-landed-final-report.md`](schemas/reconcile-landed-final-report.md) |
| Create or update Gitea issues | [`workflows/create-issue.md`](workflows/create-issue.md) | [`schemas/create-issue-final-report.md`](schemas/create-issue-final-report.md) |
| Work on an assigned issue / author code | [`workflows/work-issue.md`](workflows/work-issue.md) | [`schemas/work-issue-final-report.md`](schemas/work-issue-final-report.md) |
## Universal rules
- Prove identity, active profile, runtime context, and **exact** capability before
mutation.
- A nearby capability does not count.
- Do not self-review or self-merge.
- Do not mix modes in one run.
- If the required workflow cannot be loaded, stop and produce a recovery handoff
only.
- Final report must use the schema for the loaded workflow.
- If a task requires a different mode, stop and produce a handoff for the
correct workflow.
## Mode isolation
A run that starts in `review-merge-pr` mode may not create process issues,
implement fixes, or edit source files.
A run that starts in `reconcile-landed-pr` mode may not approve, request
changes, merge, implement fixes, or create normal issues.
A run that starts in `create-issue` mode may not review, approve, request
changes, merge, implement fixes, create branches, commit, push, or create PRs.
A run that starts in `work-issue` mode may not review, approve, request changes,
merge, close PRs, or act as reviewer.
If the task requires a different mode, stop and produce a handoff for the
correct workflow.
The core promise: **an LLM never does unsafe or untracked work.** Every change
is tracked by an issue, isolated in its own worktree, reviewed by a different
identity, and cleaned up only after a real merge.
---
## Definitions
- **Merged**: Gitea PR metadata says `merged=true`.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata
may not say merged.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
- **Closed-not-merged**: PR state is closed and `merged=false`.
- **Reconciled**: Verified whether closed-not-merged or already-landed content
is present on the target branch; issue/label/tracker state repaired.
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
## Work Selection Rule for LLMs
## A. Issue-first rule
Before starting any issue or PR work, acquire or verify a work lease. Do not
begin coding, reviewing, fixing, branching, committing, pushing, commenting, or
creating a PR until you prove the target is not already being worked.
**No repository change without a tracking issue.** This includes creating,
editing, deleting, or `chmod`-ing files; docs; scripts; commits; pushes; and PRs.
Required checks:
1. Before any change, confirm a tracking issue exists.
2. If none exists, create one first (title + problem + scope + acceptance).
3. Claim it (assign yourself or apply the `status:in-progress` label) and comment
that work is starting, including the planned branch name.
4. **If the issue cannot be created or claimed, stop.** Do not touch files.
1. List open PRs.
2. Search for PRs linked to the target issue.
3. Search local and remote branches for the issue number.
4. Search registered worktrees for the issue branch.
5. Check dirty worktrees.
6. Check active leases or recent handoffs.
7. Check whether the issue was already completed by a merged PR.
Reading the repo, running read-only status/`git log`, and creating/claiming the
issue itself are allowed from the orchestration checkout without a prior issue.
If another active session owns the lease, stop with "work already claimed" or
produce a handoff.
Additional issue-first rules:
For Gitea-Tools: `gitea_lock_issue` is the fail-closed lease gate before author
mutations; `status:in-progress` and claim comments are supporting lease signals.
- Do not implement code without an issue unless explicitly authorized.
- **Design-only work uses a discussion/RFC issue** — create one or comment on
the existing one. Design debates belong on the issue, where other LLMs
comment directly. Discussion-only tasks must **not** create branches or PRs;
their comments should include recommendations, risks, open questions, and a
Controller Handoff (§K; compact format unless high-risk).
- **If the repo/tracker home for the work is unclear, stop and ask for an
owner decision.** Do not create a new repository or a new tracker unless
explicitly approved by the owner.
## Global LLM Worktree Rule
## B. Isolated worktree rule
The main project checkout is a stable control checkout on `master`, `main`, or
`dev`. All LLM task work must happen inside the project's `branches/` directory.
**Never implement or review in the main checkout.** The main checkout is for
orchestration and status only (issue creation, `git status`, creating worktrees).
If `cwd` is not inside `branches/`, stop before any file edit, test write,
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
- Each issue gets its own branch worktree under an ignored `branches/` directory.
- Review work uses a **separate** review worktree, never the author's folder.
- Dirty work in one branch folder must not block starting another issue.
- No LLM may edit another issue's worktree unless explicitly assigned to it.
- Branch folders are removed only after the PR is merged/closed **and** cleanup
is explicitly part of the task.
## Shell Spawn Hard-Stop Rule
Every implementation branch **must include its issue number** so it is
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
command failure. After two consecutive spawn failures, hard-stop shell use for
the session and emit a recovery report (#258).
Allowed implementation patterns:
## Isolated worktree naming
- `fix/issue-123-short-description`
- `feat/issue-123-short-description`
- `docs/issue-123-short-description`
- `chore/issue-123-short-description`
Implementation: `(fix|feat|docs|chore)/issue-<number>-<short-description>`
Review-only branches:
Review: `review/pr-<number>-<short-description>`
- `review/pr-456-short-description`
## Subagent Tool-Budget Guardrails
Use a filesystem-safe folder under `branches/` by replacing slashes with
hyphens, for example `branches/fix-issue-123-short-description`.
General-purpose subagents on **single-step MCP tasks** (for example
`gitea_commit_files`) must not expand into 100+ tool-call retry spirals with
WebFetch/Playwright/manual-encoding fallbacks (issue #259).
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
is maintained by:
Default budgets (stop when exceeded):
- the branch name (contains the issue number),
- a claim comment on the issue, e.g.
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
- the PR body — `Closes #123` or `Fixes #123` when the PR should close the issue
(do NOT use `Implements #123` or `Refs #123` to close, as Gitea will not auto-close),
- cleanup after merge — remove the remote branch, local branch, and the issue
worktree folder, and drop `status:in-progress`.
- **Single-step MCP mutation** (`commit_files`, `create_pr`, `lock_issue`):
15 tool calls, 5 minutes wall time.
- **Review / merge queue inspection**: 40 tool calls, 15 minutes.
- **Non-mutating exploration**: 60 tool calls, 20 minutes.
For projects using `Gitea-Tools` helpers:
Rules:
```bash
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
scripts/worktree-review fix/issue-123-example # → branches/review-fix-issue-123-example (detached)
scripts/worktree-clean --delete-branch fix/issue-123-example
```
1. When the main session has `gitea.repo.commit`, call `gitea_commit_files`
directly — do not delegate commit to a subagent (#260).
2. After shell spawn failure (#258), attempt the native MCP tool once before
any fallback; shell unavailability never authorizes WebFetch/Playwright/
manual base64.
3. Never resume a failed subagent into a larger retry loop or spawn a second
subagent for the same deterministic step — stop and report.
4. When `gitea_commit_files` is available, forbid WebFetch, Playwright,
manual encoding, and ad-hoc `_encode_*` / `_emit_*` helpers in the repo.
Manual equivalent:
Worktree folder: branch with `/` replaced by `-` under `branches/`.
```bash
git fetch <remote> --prune
git worktree add -b fix/issue-123-example branches/fix-issue-123-example <remote>/master
cd branches/fix-issue-123-example
```
Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
`scripts/worktree-clean`.
`venv/` and similar are not copied into new worktrees — run checks with a known
interpreter path, or create a venv inside the branch folder.
## Identity and profile safety
## C. Identity and profile safety
- Author and reviewer identities must be distinct.
- Never place raw tokens in LLM/MCP config.
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
- Use canonical execution profiles where available; the profile is the role, not the LLM. A task selects a profile; a profile is not permanently assigned.
- **Author and reviewer identities must be distinct.**
- Never place raw tokens/passwords in an LLM/MCP client config. Reference secrets by keychain id or environment variable name only. Prefer a single canonical config file selected by two env vars, e.g.:
- `GITEA_MCP_CONFIG` — path to the canonical profiles file
- `GITEA_MCP_PROFILE` — the profile to activate
- **Dual-Profile MCP Launcher Pattern (Recommended):** To avoid relaunch bottlenecks and PR-author deadlocks, register multiple instances of the same MCP server in the client's configuration simultaneously (e.g., `gitea-author` and `gitea-reviewer`), each pointing to its respective `GITEA_MCP_PROFILE`.
- Tool calls become namespace-scoped: `mcp__gitea-author__*` and `mcp__gitea-reviewer__*`.
- **Trust Model:** Separate tokens remain separate. Profile gates enforce allowed operations, `whoami` is still checked, and self-review/self-merge prevention remains mandatory. This pattern is for convenience and does not bypass security gates.
- **Deadlock Warning:** Reviewer/merge identities must not be used to create PRs, as this makes the reviewer the PR author in Gitea and blocks independent review. PRs should normally be created by the author/work identity, keeping the reviewer identity available for reviews.
- **Fallback:** If a dual-server launcher is not available in the client, relaunch or restart the client with the correct profile environment variable before claiming work.
- **If the authenticated user equals the PR author, stop** — no self-review, no self-merge.
## D. Branch naming
```text
fix/issue-123-short-description
feat/issue-123-short-description
docs/issue-123-short-description
review/pr-456-scope-check
```
Worktree folder = branch with `/` replaced by `-`
(`branches/fix-issue-123-short-description`).
## E. Start-work workflow
1. Verify the orchestration checkout (right repo, clean tree).
2. Fetch/prune: `git fetch <remote> --prune`.
3. Confirm local `master` equals remote `master` (`git rev-list --left-right --count <remote>/master...master``0 0`).
4. Create/claim the issue (§A).
5. Create the isolated worktree (§B) from latest remote `master`.
6. Implement the narrow scope only — no unrelated refactors or formatting churn.
7. Add/update focused tests when behavior changes.
8. Run the checks (tests, compile/lint, `git diff --check`, secret scan).
9. Commit with an issue-linked message.
10. Push the branch.
11. Open a PR to `master`.
12. **If you are the author, stop before review/merge.**
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
- why the PR merge path could not be used
- exact commits pushed
- PR metadata state
- issue labels/state repaired
- whether the PR is closed-not-merged
## F. Review workflow
1. Use a separate review worktree (`scripts/worktree-review <branch>`), detached.
2. Verify your authenticated identity.
3. Verify the PR author — **you must not be the author.** Self-review
contamination must be *evidence-backed* (#173): state the authenticated
reviewer identity, the PR author identity, whether this session
authored/touched the PR branch, and the evidence source for any
"same-session author" claim. If the evidence is missing, report the
status as **unknown** — never declare contamination by assumption — and
choose another PR or stop (`review_proofs.assess_self_review_contamination`).
4. Verify the worktree is clean.
5. **Checkout proof (#173):** before reviewing or validating, prove and
state: the selected PR head SHA from Gitea (pinned), the local checkout
SHA (`git rev-parse HEAD`), that `HEAD ==` the pinned PR head SHA, and
that the diff base is the PR base branch. If `HEAD` does not match the
pinned head, **stop before review/merge**
(`review_proofs.verify_pinned_head_checkout`).
6. **Inventory proof (#173 + repo disambiguation hardening):** a blind queue
review must prove listing completeness before claiming "only PRs found".
Use repo-name disambiguation:
- "Gitea-Tools" / "gitea tool" / "MCP Gitea tool" / "gitea MCP tool" /
"gitea-tools repo" resolve **only** to `Scaled-Tech-Consulting/Gitea-Tools`.
- "mcp-control-plane" resolves only to `Scaled-Tech-Consulting/mcp-control-plane`.
- Ambiguous ("open PRs", no explicit repo, "MCP Gitea tooling") → inventory
**both** configured repos.
Report must state exactly which repo(s) were checked. If only one checked:
"Only <repo> was checked. Other configured repos were not checked. This is
not a complete queue inventory." Never let a single-repo zero hide PRs in
the other.
Both configured repos must be reported with state filter, pagination proof,
and open-PR count (`review_proofs.assess_inventory_completeness` and
`resolve_repos_from_user_reference`).
7. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
8. Run the tests. Validation reporting must include the exact command and
exact results: pass/fail, counts of tests passed/skipped/failed, any
ignored paths and why they are safe to ignore, and whether the command
differs from the repository's canonical validation command. Only claim a
validation result after the command has completed and its output has
been read (`review_proofs.assess_validation_report`).
9. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
10. The final report must distinguish (`review_proofs.build_final_report`):
identity eligible; PR author different from reviewer; session
contamination absent (with evidence); validation performed on the pinned
head; merge performed; issue status verified. If any proof is missing,
stop or downgrade the result instead of merging confidently.
## G. Merge / cleanup workflow
Only an eligible (non-author) reviewer merges. Before merging: always verify
the authenticated identity **and** the PR author; respect runtime profile
gates; run independent validation (do not trust the author's reported
results); and merge with a **pinned head SHA** and, where supported, the
**expected changed-file set**, so a moved head or widened diff refuses the
merge. After a real merge:
1. Confirm remote `master` actually contains the merge commit or expected squashed changes via post-merge file-presence verification (A PR is not done just because `master` moved or is marked "closed". Verify that expected files added/modified in the PR are actually present on `master` using `git pull`, `git log --oneline -- <file>`, or `git merge-base --is-ancestor`; linked issues are closed; `status:in-progress` is removed).
2. Close/release the issue.
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
5. Remove the local branch.
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
7. Fetch/prune.
8. Confirm the main checkout is clean and current (`0 0` vs remote).
9. Final merge/reconciliation reports must include: PR metadata (state, merged flag, merge commit/hash), Git content (remote master hash, expected content present or not), and the exact post-merge verification method used & results.
Never run cleanup before the merge is confirmed on remote `master`.
## H. Fail-closed cases
**Stop and report — take no mutating action — if:**
- No issue exists and one cannot be created.
- Worktree state is unclear or unexpected.
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
- A PR is closed but not merged (closed with `merged=false`). In this case:
- stop normal review/merge
- do not delete branches/worktrees
- do not start dependent work
- run reconciliation
- Local `master` is ahead of remote unexpectedly.
- The authenticated user is the PR author (for review/merge).
- Secrets/tokens appear in the diff.
- Tests fail.
- A cleanup step would delete unmerged work.
When in doubt, stop and surface the discrepancy; do not guess or work around a gate.
## I. Recovery patterns
- **Dirty worktree from another issue:** do not touch it. Start your issue in its
own new worktree; unrelated dirty work must not block you.
- **Local `master` ahead of remote unexpectedly:** do not push `master`. Confirm
the commits are preserved on a feature branch (local + remote) first, then
`git reset --hard <remote>/master` to realign. Never discard commits that are
not safely pushed elsewhere.
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
- **Branch deleted before merge:** if the commits still exist locally (a branch or
reflog), re-push them and reopen the PR; otherwise recover via
`git fsck --lost-found`. Preserve first, then proceed.
- **Unauthorized/untracked file created:** do not commit it. Leave pre-existing
untracked artifacts (e.g. editor/agent dirs, reports) alone; stage only the
files your issue names (`git add <files>`, never blind `git add -A`).
- **Preserve commits before a reset:** confirm the target commits are reachable
from a branch that is pushed to the remote, then reset. Verify with
`git branch --contains <sha>` and `git log <remote>/<branch>`.
## J. Prompt snippets
Ready-to-copy templates live in [`templates/`](templates/):
- [`start-issue.md`](templates/start-issue.md) — start a new issue.
- [`review-pr.md`](templates/review-pr.md) — review a PR.
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
## K. Controller Handoff (required, every task)
Every LLM task **must end with a `Controller Handoff`** — whether the
task was implementation, review, merge, issue triage, documentation,
discussion-only, or blocked planning. It lets a controller LLM understand the
current state immediately, without rereading the conversation.
**The compact format is the default.** It is written for controller-LLM
readability, not as a full human status report. PR bodies still carry the
full review detail — the handoff never replaces PR documentation.
Compact format (default, canonical field set per issue #182):
```md
## Controller Handoff
Every task must end with a section titled exactly `Controller Handoff`. Compact
format canonical field set per issue #182; mode-specific schemas in
`schemas/*-final-report.md` define required fields. Use the final report schema
for the loaded workflow mode — not the legacy compact block alone.
`review_proofs.assess_controller_handoff()` validates presence.
- Task:
- Repo:
- Role:
- Identity:
- Issue/PR:
- Branch/SHA:
- Files changed:
- Validation:
- Mutations:
- Current status:
- Blockers:
- Next:
- Safety:
```
## Prompt templates
Role-specific fields (append to the compact block):
Ready-to-copy task prompts live in [`templates/`](templates/):
- review/merge tasks: `Selected PR:`, `Reviewer eligibility:`,
`Pinned reviewed head:`, `Review decision:`, `Merge result:`,
`Linked issue status:`, `Cleanup status:`
- author tasks: `Selected issue:`, `Claim/comment status:`,
`PR number opened:`, `No review/merge:` (explicit confirmation)
- queue/inventory tasks: `Repositories checked:`, `Open PR counts:`,
`Selected PR or reason none selected:`, `Inventory completeness:`
- [`start-issue.md`](templates/start-issue.md) — author work (loads `work-issue.md`)
- [`review-pr.md`](templates/review-pr.md) — review (loads `review-merge-pr.md`)
- [`merge-pr.md`](templates/merge-pr.md) — merge (loads `review-merge-pr.md`)
- [`recover-bad-state.md`](templates/recover-bad-state.md)
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
- [`release-tag.md`](templates/release-tag.md)
The section title must be exactly `Controller Handoff`.
`review_proofs.assess_controller_handoff()` validates this section; reports
missing it (or missing required fields) are downgraded. The handoff never
replaces the full report — it is the compact continuation summary at the end,
and the full report must still carry exact validation results and mutation
confirmation.
The `Safety:` line is never omitted; it is usually:
```text
no self-review; no self-merge; no tags; no secrets; no prod
```
Rules (both formats):
- Never omit the handoff, and never omit the safety confirmations.
- Never bury blockers in earlier text only — they must appear here.
- If you opened a PR, state clearly that review is needed.
- If you reviewed but could not merge, name the exact gate that blocked it.
- If you only commented on a discussion issue, say no code review is needed
but owner/design feedback may be needed.
- If release state was touched, state exactly which tag/commit changed and why.
- If blocked (permissions, missing repo, missing second reviewer identity,
stale dependency, unclear tracker home): stop and report clearly; **never
bypass classifiers, profile gates, missing permissions, or live-consent
requirements**; give the owner concrete options.
**Use the long format below instead of the compact one only when the task was
high-risk or complex** — i.e. when any of these happened:
- a merge, tag, or release
- failed validation
- permissions/profile gates blocked work
- secrets or production access were involved
- a complicated owner decision
- multiple repos or cross-issue state
- the owner explicitly asks for the full format
Long format (high-risk/complex tasks only):
```md
## Controller Handoff Summary
### Work performed
Briefly state what was done.
### Current state
Include:
- current repo
- current branch or master commit
- issue number(s)
- PR number(s), if any
- whether work is complete, blocked, ready for review, or discussion-only
### Files changed
List files changed, or say `None`.
### Validation
List commands run and results, or say `Not applicable — discussion only`.
### Issues encountered
List errors, confusing state, permission/profile problems, stale branches,
failing tests, missing labels, or blocked decisions.
### Review needed?
Say one of:
- `No review needed — discussion/comment only`
- `Review needed — PR is open`
- `Independent non-author review needed`
- `Owner decision needed`
- `Blocked`
### Next recommended action
State exactly what should happen next.
### Safety confirmations
Confirm:
- no self-review
- no self-merge
- no release/tag changes unless explicitly requested
- no secrets committed
- no production access used unless explicitly authorized
```
### Example blocked handoff
```md
## Example blocked handoff
### Work performed
Audited phase-2 MCP Control Plane planning. Found target repo
`mcp-control-plane` does not exist. Prepared issue pack but did not file it.
### Current state
- Repo: `Scaled-Tech-Consulting/Gitea-Tools`, unmodified
- Target repo: `mcp-control-plane`, missing
- Issues: none open in Gitea-Tools
- PRs: none open
- Status: blocked pending owner decision
### Files changed
None.
### Validation
Tracker/repo audit only. No code validation required.
### Issues encountered
Repo creation was denied by permission/classifier because it would be scope
escalation without live consent.
### Review needed?
Owner decision needed.
### Next recommended action
Owner must choose:
1. create `Scaled-Tech-Consulting/mcp-control-plane`
2. authorize repo creation while present
3. file phase-2 issues in Gitea-Tools instead
### Safety confirmations
- no self-review
- no self-merge
- no release/tag changes
- no secrets committed
- no production access used
```
## Adapting to a project
| Placeholder | Example here |
|-------------|--------------|
| `<remote>` | `prgs` |
| default branch | `master` |
| profile env vars | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | `branches/` |
| helpers | `scripts/worktree-start` / `-review` / `-clean` |
Replace these project-specific names when copying the skill elsewhere:
| Placeholder | Meaning | Example here |
|-------------|---------|--------------|
| `<remote>` | Git remote for the forge | `prgs` |
| default branch | Integration branch | `master` |
| profile env vars | Canonical config + profile selectors | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | Ignored worktree directory | `branches/` |
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
The rules in §A–§K are project-agnostic and should not change.
## Versioning And Tagging
Releases follow SemVer from remote `master` only, after full test suite passes.
See [`templates/release-tag.md`](templates/release-tag.md) and
`scripts/release-tag`.
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
unstable). Choose the bump by the largest change since the last tag:
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
Tags must:
- be created **only from `master`** (the exact commit on remote `master`),
- be created **only after the full test suite passes**,
- be **annotated** tags (`git tag -a`), never lightweight,
- include release notes / a changelog summary referencing the merged PRs/issues.
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
work, or commits not present on remote `master`.
Additional tag rules:
- Do **not** create, move, delete, or push tags unless explicitly instructed.
- Tag only **after** the intended PR is merged, and tag only the **verified
final master merge commit** (never the PR branch head unless the merge
commit is exactly that commit).
- Always **report the tag target commit** in the final report / handoff.
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
1. `git fetch <remote> --prune`.
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
3. Run the full test suite; stop on any failure.
4. Inspect merged issues/PRs since the last tag
(`git log --oneline <last-tag>..<remote>/master`).
5. Choose the version bump.
6. Create the annotated tag on remote `master` with release notes.
7. Push the tag.
8. Create/update release notes if the forge supports it.
Where present, `scripts/release-tag` automates this with all gates built in
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
explicit and warns.
@@ -1,46 +0,0 @@
# Create-issue controller handoff schema
**Task mode:** `create-issue`
End every create-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Requested issue task:
- Workflow source:
- Capability proof:
- Duplicate search terms:
- Duplicate search pagination proof:
- Duplicates found:
- Issues created:
- Issues commented:
- Issues edited:
- Issues skipped as duplicates:
- Labels/assignees/milestones changed:
- File edits by issue creator:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Label/assignment/milestone mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
@@ -1,53 +0,0 @@
# Reconcile-landed controller handoff schema
**Task mode:** `reconcile-landed-pr`
End every reconciliation run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Reject stale author/reviewer fields: `PR number opened`, `Pinned reviewed head`,
`Scratch worktree used`, `Workspace mutations`, `Mutations: None` (when mutations
occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- PR live state:
- Candidate head SHA:
- Target branch:
- Target branch SHA:
- Ancestor proof:
- Linked issue:
- Linked issue live status:
- Eligibility class:
- Capabilities proven:
- Missing capabilities:
- PR comments posted:
- Issue comments posted:
- PRs closed:
- Issues closed:
- File edits by reconciler:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Reconciliation mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
- No review/merge confirmation:
```
Identity format: `username / profile` (not personal email unless required — #305).
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
@@ -1,94 +0,0 @@
# Review-merge controller handoff schema
**Task mode:** `review-merge-pr`
End every review/merge run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
`Workspace mutations`, `Mutations: None` (when mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- Linked issue:
- Eligibility class:
- Queue ordering policy:
- Inventory pagination proof:
- Earlier PRs skipped:
- Candidate head SHA:
- Reviewed head SHA:
- Target branch:
- Target branch SHA:
- Already-landed gate:
- Author-safety result:
- Prior request-changes state:
- Review worktree used:
- Review worktree path:
- Review worktree inside branches:
- Review worktree HEAD state:
- Review worktree dirty before validation:
- Review worktree dirty after validation:
- Baseline worktree used:
- Baseline worktree path:
- Files reviewed:
- Validation:
- Official validation integrity status:
- Terminal review mutation:
- Review decision:
- Merge preflight:
- Merge result:
- Linked issue status:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout updated:
- File edits by reviewer:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Review mutations:
- Merge mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
### Already-landed handoff overrides
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
- Reviewed head SHA: `none`
- Review worktree used: `false`
- Review worktree path: `none`
- Review decision: `none`
- Merge result: `none`
Identity format: `username / profile` (not personal email unless required — #305).
### Queue-status-only runs (no selected PR)
When the run inventories the queue but selects no PR for review:
- Selected PR: `none`
- Already-landed gate, Author-safety result, Merge preflight: `not applicable` or `not run` — never `passed`
- Review worktree detail fields: `not applicable` or `none` when Review worktree used is `false`
- Blockers must not be `none` if the narrative says all open PRs are conflicted, blocked, or unverified
- Inventory pagination proof must cite final-page metadata, not default page-size assumptions
Verifier: `review_proofs.assess_queue_status_report()`.
Narrative final report and controller handoff must agree on eligibility class,
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
terminal review mutation, merge result, and linked issue status.
@@ -1,73 +0,0 @@
# Work-issue controller handoff schema
**Task mode:** `work-issue`
End every work-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected issue:
- Eligibility class:
- Issue ordering policy:
- Issue inventory pagination proof:
- Earlier issues skipped:
- Duplicate active work proof:
- Claim/lock state:
- Stable branch:
- Stable branch SHA:
- Branch name:
- Worktree path:
- Worktree inside branches:
- Worktree branch/HEAD state:
- Worktree dirty before implementation:
- Files changed:
- Validation:
- Baseline comparison:
- Commit SHA:
- Push result:
- PR number:
- PR URL:
- PR verification:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout used for task work:
- File edits by author:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Branch mutations:
- Commit mutations:
- Push mutations:
- PR mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
Narrative final report and controller handoff must agree on eligibility class,
selected issue, and mutation ledger categories (#319, #320).
`git fetch` and ref-updating commands belong under `Git ref mutations`, not
`Read-only diagnostics` (#297).
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
@@ -5,10 +5,6 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: merge PR #<pr> for issue #<n> if it is eligible and checks pass.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
author → STOP.
@@ -24,21 +20,10 @@ Steps:
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
2. Verify authenticated identity + active profile.
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
output (or runtime context) proving merge_pr is allowed — a bare
"capability checks passed" claim is downgraded.
5. Final live-state recheck (#179), immediately before the merge mutation —
re-read the live PR and prove:
- PR still open
- live head SHA still equals the pinned/reviewed head SHA
- base branch unchanged
- no undismissed REQUEST_CHANGES / blocking review state remains
If any recheck fails → STOP, re-pin, re-validate.
6. If any gate fails → STOP and report.
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
pinning the reviewed head SHA (expected_head_sha) and, where supported,
the changed-file set.
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
4. If any gate fails → STOP and report.
4. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
optionally pinning the reviewed head SHA / changed-file set.
5. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
Then run the cleanup template (worktree-cleanup.md):
@@ -17,43 +17,12 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
configured repos were not checked. This is not a complete queue inventory."
- A single-repo "no open PRs" result MUST NOT be reported as global "no open PRs"
if the other configured repo was not inventoried.
- PR inventory trust gate (#196): before reporting "no open PRs" or "queue empty",
the workflow must run `pr_inventory_trust_gate` (via the live inventory path or
`review_proofs.assess_reviewer_queue_inventory`). Only `trusted_empty` allows a
clean empty-queue stop. Report `pr_inventory_trust_gate.status`, reasons, and
corroboration in the final report. A bare `[]` from `gitea_list_prs` is never
sufficient proof.
- Empty-queue report wall (#198): if the final report claims "no open PRs",
"queue empty", or "nothing to review", it must include verbatim:
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
commit is not valid corroboration. Author-bound sessions must not present
reviewer queue inventory as a reviewer decision.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
report the starting worktree path and whether it was dirty. If unrelated
tracked files exist outside the PR scope, STOP or run
`scripts/worktree-review <pr-head-branch>` and validate in the scratch path.
Scratch-clone validation is the norm; tests must not assume the shared
development worktree or a repo-local ``venv/`` (#245).
NEVER run `git stash`, `git stash pop/drop`, `git checkout --`, `git reset`,
or `git clean` to manage another session's dirty files.
- Final report must state: Worktree path, Worktree dirty (yes/no),
Scratch worktree used (yes/no + path if yes), and confirm no unrelated local
files were modified, stashed, reset, or dropped.
- You must NOT be the PR author. If the authenticated user == PR author, stop.
A different LLM-Agent-SHA does NOT make you a different actor — only a
different authenticated Gitea user does (docs/llm-agent-sha.md).
- Do not pivot from a reviewer queue task into author implementation unless
the operator explicitly retasks the run. If author namespace was used, the
final report must justify why; author mutations after reviewer queue work
without explicit authorization are a role-boundary violation.
- Do not merge if any check fails.
Steps:
@@ -63,11 +32,6 @@ Steps:
- Target task role: reviewer identity (must NOT be the PR author)
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
2. Verify your authenticated identity (whoami) and the active profile.
Capability evidence (#179): cite the exact gitea_resolve_task_capability
output (or runtime context) for review_pr (and merge_pr if merging later);
a bare "capability checks passed" claim is downgraded. Stay in the
reviewer namespace: any author-namespace call must be justified in the
report (#179).
3. Fetch the PR facts: PR author, head SHA, state (must be open), base branch.
Pin the head SHA in your notes; every later step validates THAT SHA.
4. If authenticated user == PR author → STOP (no self-review).
@@ -75,10 +39,6 @@ Steps:
cannot evidence whether this session authored/touched the PR branch,
report contamination as UNKNOWN (not contaminated, not clean) and choose
another PR or stop.
Role-boundary claims must also be evidence-backed (#175): report whether
reviewer namespace, author namespace, author mutations, or review mutations
occurred. Use `review_proofs.assess_role_boundary`; if it is not clean,
downgrade or stop instead of claiming an A-level run.
5. scripts/worktree-review <pr-head-branch> # detached, branches/review-*
cd branches/review-<pr-head-branch-slug>
6. Checkout proof (#173) — prove and state, before any diff review or
@@ -90,23 +50,12 @@ Steps:
If HEAD does not match the pinned head → STOP before review/merge.
7. Confirm the worktree is clean. Inspect the FULL diff; confirm scope matches
issue #<n>; flag any unrelated files, secrets, or formatting churn. Check that the PR body correctly uses Gitea-closing keywords (`Closes #N` or `Fixes #N`) instead of non-closing ones (`Implements #N`, `Refs #N`).
Secret/provenance sweep must be exact (#179): state the exact command,
script, grep pattern, or named sweep method AND the scope scanned (e.g.
`git diff prgs/master...HEAD | grep -inE '<pattern>'`); "checked the diff
for secrets" alone is downgraded.
8. Run the test suite; report the exact command and exact results — pass/fail
plus passed/skipped/failed counts, any ignored paths and why they are safe
to ignore, and whether the command differs from the repository's canonical
validation command. Only claim a result after the output has been read.
9. Final live-state recheck (#179), immediately before submitting the review
verdict — re-read the live PR and prove:
- PR still open
- live head SHA still equals the pinned head SHA from step 3
- base branch unchanged
- no undismissed REQUEST_CHANGES / blocking review state left unaccounted
If anything moved → STOP, re-pin, re-validate before any verdict.
10. Post the review verdict: approve only if scope is clean and checks pass;
otherwise request changes with specifics. Never merge from this review step.
9. Post the review verdict: approve only if scope is clean and checks pass;
otherwise request changes with specifics. Never merge from this review step.
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
Review Metadata:
@@ -5,10 +5,6 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: implement <issue title / one-line goal>.
Load canonical workflow: skills/llm-project-workflow/workflows/work-issue.md
Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report.md
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
Rules (llm-project-workflow):
- No repo changes without a tracking issue. If none exists, create one first;
if it can't be created, stop.
@@ -17,21 +13,6 @@ Rules (llm-project-workflow):
- Do not self-review or self-merge.
Steps:
0. Work Selection Rule — before any claim, branch, or file edits, acquire or
verify a work lease. Required checks: list open PRs; search PRs linked to
the target issue; search local/remote branches for the issue number; search
registered worktrees for the issue branch; check dirty worktrees; check
active leases or recent handoffs; check whether a merged PR already
completed the issue. If another session owns the lease, stop (continue only
as lease owner, review the existing PR, hand off, request takeover after
expiry, or report "work already claimed"). Never open a parallel branch/PR
unless the old branch is proven abandoned and takeover is recorded.
0b. Global LLM Worktree Rule — before any mutation, prove and state: project
root; cwd; current branch; stable branch for the main checkout (master/main/dev);
session-owned worktree path under branches/. If cwd is not inside branches/,
STOP (no exceptions — not for docs, tests, small fixes, review fixes, conflicts,
or cleanup). Main checkout is control-only: read-only inspect, fetch, create
worktrees, stable-branch update after merge, explicit repair.
1. Identity Checklist: Before claiming work, verify and state:
- Required identity/profile for this task: author (allowed to push branches / create PRs)
- Current authenticated identity (from whoami): <username>
@@ -45,19 +26,8 @@ Steps:
cd branches/<type>-issue-<n>-<slug>
6. Implement the narrow scope only; add/update focused tests if behavior changes.
7. Checks: run the test suite, compile/lint changed files, git diff --check,
and scan the diff for secrets. Record the branch name and HEAD SHA at
validation time.
8. Branch proof before commit (#177) — prove and state:
- git branch --show-current == the intended issue branch from step 5
- the branch is NOT master/main/develop/development/dev
- branch and HEAD unchanged since step 7 (another session can switch a
shared checkout mid-session; if drift is detected, STOP and reconcile
before committing)
If a commit accidentally lands on a protected branch: do NOT push;
report the accident and the exact repair steps — never silently continue.
9. Commit (issue-linked message). Branch proof before push (#177): local
branch == push target branch == intended issue branch, none protected.
Then push the branch and open a PR to master.
and scan the diff for secrets.
8. Commit (issue-linked message), push the branch, open a PR to master.
*The PR body MUST use closing keywords like `Closes #N` or `Fixes #N` to close the issue; do NOT use `Implements #N` or `Refs #N` for closing, as Gitea will not auto-close it.*
Include an "LLM Handoff Metadata" block in the PR body (attribution only;
never an eligibility input — docs/llm-agent-sha.md):
@@ -70,7 +40,7 @@ Steps:
- Branch: <branch>
- Worktree: <worktree path>
- Self-review allowed: no
10. Stop before review/merge — you are the author.
9. Stop before review/merge — you are the author.
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
§K (compact; long form only on the high-risk triggers), including the author
@@ -1,666 +0,0 @@
---
task_mode: create-issue
canonical: true
final_report_schema: ../schemas/create-issue-final-report.md
---
# Create issue workflow (canonical)
**Task mode:** `create-issue`
This file is the canonical issue-creation workflow for Gitea-Tools. Load it
before any issue mutation. Final report schema:
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
**Default task prompt:**
> Create or update Gitea issues in this project only if every identity,
> capability, duplicate-search, issue-scope, final-report, mutation-ledger,
> and proof-wording gate passes.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an issue-creation workflow. It is not a reviewer workflow and not an
implementation workflow.
---
## 0. Load the canonical workflow first
Before starting issue creation or issue update work, check whether the project provides a canonical create-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `create-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs
* close issues unless the user explicitly asks and exact close capability is proven
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run tests unless the canonical workflow explicitly requires validation for issue creation
* perform reviewer-only actions
* perform author/coder-only actions
* perform MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile
* repo/project
* runtime context
* exact capability for reading/searching issues
* exact capability for creating issues, if creating issues
* exact capability for commenting on issues, if commenting on existing issues
* exact capability for editing issues, if editing existing issues
* exact capability for applying labels, if applying labels
* exact capability for assigning issues, if assigning issues
* exact capability for closing issues, only if explicitly requested
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize `create_issue`
* `create_pr` does not authorize `create_issue`
* `review_pr` does not authorize `create_issue`
* `merge_pr` does not authorize `issue_comment`
* `gitea.read` does not authorize creating, commenting, editing, labeling, assigning, or closing issues
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* dirty control checkout, if the canonical workflow treats that as blocking
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue duplicate search, issue creation, issue commenting, issue editing, labeling, assignment, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches.
Do not create commits.
Do not push.
Do not run implementation work.
Do not run reviewer validation.
Reading repository files is allowed only when needed to understand the requested issue and only if the canonical workflow permits it.
If the main checkout is dirty and the project treats dirty control checkout as blocking, stop and produce a recovery handoff.
## 5. No raw MCP repair during issue creation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during issue creation.
If MCP repair is required, stop issue creation and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with create-issue mode.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue creation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue creation
During normal issue-creation workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Understand the requested issue work
Before searching or creating issues, restate the requested issue-creation task in operational terms.
Identify:
* target repo/project
* issue topic
* issue type, if known
* whether this is a new issue, duplicate check, issue update, or issue-comment task
* whether the user provided exact title/body text
* whether acceptance criteria were provided
* whether multiple issues are requested
* whether labels, assignees, milestones, or links are requested
* whether any requested action requires capability beyond issue creation
Do not invent missing requirements.
If the request is ambiguous but safe to proceed, make a reasonable best-effort issue with clear assumptions.
If ambiguity would cause unsafe or wrong mutation, stop and ask for clarification or produce a recovery handoff according to project policy.
## 9. Duplicate search before mutation
Before creating any issue, search open and closed issues for duplicates.
Duplicate search must happen before each issue creation unless a batch search clearly covers all proposed issues.
Search terms must include:
* exact proposed issue title
* key noun phrase from the problem
* key workflow/tool name
* key failure phrase or error phrase
* likely alternate wording
* linked PR number, issue number, or file name, if relevant
If the user supplied search terms, use those too.
For each proposed issue, report:
* search terms used
* matching issue numbers/titles
* whether each match is open or closed
* whether any match fully covers the requested issue
* whether any match partially covers the requested issue
* whether a new issue is still needed
Do not create a duplicate issue if an existing issue fully covers the problem.
If a duplicate exists and fully covers the problem, stop issue creation for that topic and report the duplicate.
If a duplicate exists but is missing important acceptance criteria, comment on the existing issue only if exact `issue_comment` capability is proven and the user/task authorizes commenting.
If commenting is not authorized, report the existing issue and the missing criteria in the final handoff.
## 10. Issue inventory pagination rule
If listing/searching issues returns paginated results, follow pagination until the tool proves there are no more pages.
Do not assume search results or issue inventory are complete.
Pagination proof must not rely on assumed default API page size.
Search/inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “duplicate search complete” merely because the result count is less than an assumed default page size.
If pagination metadata is absent and the tool cannot page, report `ISSUE_SEARCH_PAGINATION_UNPROVEN`.
If duplicate search cannot be trusted, do not create the issue unless the canonical workflow explicitly permits best-effort issue creation with that limitation disclosed.
## 11. Issue creation scope rule
Create only issues within the requested scope.
Do not create extra issues just because related problems are noticed.
Do not create process-hardening issues unless the user explicitly requested process-hardening or the current task is explicitly about workflow/tooling gaps.
Do not create implementation issues during reviewer mode.
Do not create reviewer issues during work-on-issue mode.
Do not create issues in a different repository unless the user explicitly asked and exact capability is proven.
If multiple issues are requested, create only the requested issues and only after duplicate search for each one.
If a proposed issue is too broad, split it only if the user requested splitting or the canonical workflow requires issue granularity.
## 12. Issue content quality rule
Every created issue must be actionable.
Include, when applicable:
* title
* problem statement
* observed evidence
* expected behavior
* required behavior
* acceptance criteria
* affected workflow/tool/files
* safety or security considerations
* duplicate search summary
* related issues or PRs
* non-goals, if useful
Acceptance criteria must be concrete and testable.
Avoid vague issues like:
* “make workflow better”
* “fix LLM behavior”
* “improve process”
* “handle this better”
Instead, describe the exact wall, gate, verifier, test, schema, helper, or prompt change required.
## 13. Issue title rule
Use concise, specific titles.
Good title patterns:
* `Enforce <specific gate>`
* `Add verifier for <specific report/proof problem>`
* `Split <large workflow> into <specific components>`
* `Block <unsafe action> during <workflow mode>`
* `Require <proof type> before <claim/action>`
Avoid titles that are too broad or emotional.
The title should be unique enough that duplicate search can find it later.
## 14. Issue body rule
Issue body must include the full acceptance criteria.
Do not create placeholder issues.
Do not create issues with only a title unless the user explicitly requested title-only creation.
If the user supplied exact issue body text, preserve it unless it contains unsafe instructions, stale facts, or contradictions.
If edits are needed, make the smallest correction necessary and report the correction.
Do not silently change requested meaning.
## 15. Labels, assignees, and metadata
Apply labels, assignees, milestones, or project fields only if:
* the user requested them, or
* the canonical workflow requires them, and
* exact capability is proven.
Do not guess labels if project label policy is unknown.
If labels are useful but capability or policy is unclear, mention recommended labels in the final report instead of applying them.
Do not assign issues to people unless explicitly requested or required by project workflow.
## 16. Comment-on-existing issue rule
Comment on an existing issue only if:
* an existing issue partially covers the requested work, or
* the user asked to add information to an existing issue, or
* the canonical workflow requires duplicate consolidation comments, and
* exact `issue_comment` capability is proven.
Comment must be specific and useful.
Include:
* why the existing issue is relevant
* what acceptance criteria or evidence should be added
* whether this avoids creating a duplicate
Do not comment just to say “duplicate found” unless the project workflow requires it.
Do not close duplicate issues unless explicitly requested and exact close capability is proven.
## 17. No hidden mutations
Do not perform unreported mutations.
Every issue creation, issue comment, issue edit, label change, assignment, milestone change, close/reopen action, or external-state change must be reported.
If a tool call is dry-run-only, confirmation-gated, rejected, or no-op, report it separately from performed mutations.
A dry run is not a mutation.
A rejected call is not a performed mutation.
A successful issue creation is an issue mutation.
A successful issue comment is an issue mutation.
A successful label/assignment/milestone update is an issue mutation or external-state mutation.
## 18. Issue creation gate
Before creating each issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `create_issue` capability is still valid
* duplicate search was completed or limitation was explicitly allowed
* proposed title is not a duplicate
* proposed body includes actionable acceptance criteria
* target repo is correct
* no mode switch has occurred
If any gate fails, do not create the issue.
Produce a recovery handoff or duplicate report.
## 19. Issue commenting gate
Before commenting on an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `issue_comment` capability is still valid
* target issue number is correct
* comment body is specific and useful
* comment will not duplicate an existing comment
* no mode switch has occurred
If any gate fails, do not comment.
Produce a recovery handoff or report the intended comment as a recommendation only.
## 20. Issue edit/update gate
Before editing an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact edit capability is still valid
* target issue number is correct
* update is explicitly requested or required by canonical workflow
* update does not erase useful existing content
* no mode switch has occurred
If any gate fails, do not edit.
Prefer commenting over editing unless the user explicitly requested an edit or the canonical workflow requires issue body updates.
## 21. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* runtime context summary
* exact capability proof summary
* requested issue-creation task
* duplicate search terms used
* duplicate search result
* pagination/final-page proof for issue search, if applicable
* issues created, with issue numbers and URLs
* existing issues commented, with issue numbers and URLs
* existing issues edited, with issue numbers and URLs
* issues skipped as duplicates, with issue numbers and titles
* labels/assignees/milestones applied, if any
* blockers, if stopped
* confirmation that no PR review, approval, request-changes, merge, branch, checkout, commit, push, or repo-file mutation was performed
If the report and actual tool/command log disagree, fix the report before final output.
## 22. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
Use precise wording:
* `File edits by issue creator: none`
* `Worktree/index mutations: none`
* `Git ref mutations: none`
* `Issue mutations: ...`
If no repo files were edited, say:
`File edits by issue creator: none`
If no branches/worktrees were touched, say:
`Worktree/index mutations: none`
If no git refs were updated, say:
`Git ref mutations: none`
Do not hide issue mutations inside vague `MCP/Gitea mutations`.
## 23. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during issue-creation runs unless the canonical workflow or operator explicitly requires it.
If any file is edited, created, generated, or written, report it under `File edits by issue creator`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final status was checked after the write
Do not say `File edits by issue creator: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
Default behavior: do not create local artifacts during issue creation.
## 24. Forbidden final-report claims unless proven
Do not claim:
* `duplicate search complete`
* `no duplicate found`
* `issue created`
* `issue commented`
* `issue updated`
* `label applied`
* `capability proven`
* `runtime safe`
* `all gates passed`
* `no file edits`
* `no unsafe mutation`
* `no PR mutation`
* `no repo mutation`
* `pagination complete`
* `final page`
* `no next page`
unless the corresponding proof is included.
If anything blocks safe issue creation or issue update, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 25. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* duplicate search complete
* no duplicate found
* issue created
* issue commented
* issue updated
* labels applied
* capability proven
* runtime safe
* all gates passed
* no file edits
* no unsafe mutation
* no PR mutation
* no repo mutation
* pagination complete
* final page
* no next page
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 26. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by issue creator` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was created, `Issue mutations` lists it
* if any issue was commented, `Issue mutations` lists it
* if any issue was edited, `Issue mutations` lists it
* if any labels/assignees/milestones were changed, the correct mutation category lists it
* if duplicate search is claimed complete, pagination/final-page proof is present or limitation is disclosed
* if no duplicate is claimed, search terms and results are present
* if issue created is claimed, issue number and URL are present
* if no PR mutation is claimed, no PR tool/action was used
* if no repo mutation is claimed, no branch/worktree/file/commit/push action occurred
* if all gates passed is claimed, every required gate has proof
If any contradiction exists, fix the final report before output.
## 27. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Requested issue task:
* Workflow source:
* Capability proof:
* Duplicate search terms:
* Duplicate search pagination proof:
* Duplicates found:
* Issues created:
* Issues commented:
* Issues edited:
* Issues skipped as duplicates:
* Labels/assignees/milestones changed:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 28. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* duplicate search cannot be performed and best-effort creation is not allowed
* issue search pagination cannot be proven and best-effort creation is not allowed
* duplicate fully covers the requested issue
* requested issue body is unsafe or not actionable
* target repo cannot be proven
* create_issue capability is missing
* issue_comment capability is missing for a required comment
* issue edit capability is missing for a required edit
* mode switch would be required
* any report contradiction cannot be resolved
Blocked handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
@@ -1,387 +0,0 @@
---
task_mode: reconcile-landed-pr
canonical: true
final_report_schema: ../schemas/reconcile-landed-final-report.md
---
# Reconcile already-landed open PR workflow (canonical)
**Task mode:** `reconcile-landed-pr`
This file is the canonical reconciliation workflow for open PRs whose head SHA
is already an ancestor of the target branch. Load it before any reconciliation
mutation. Final report schema:
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
**Default task prompt:**
> Reconcile already-landed open PRs in this project. Do not review or merge
> normal PRs. Close or comment only when exact capability is proven.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is a reconciliation workflow. It is not a normal PR review/merge workflow.
---
## 0. Load the canonical workflow first
Before starting reconciliation work, check whether the project provides a
canonical reconcile-landed-PR workflow through a project skill, runbook, or MCP
helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and
produce a recovery handoff only.
## 1. Mode isolation
This run is `reconcile-landed-pr` mode only.
**Do not review or merge normal PRs.**
Do not:
* approve PRs
* request changes on PRs
* merge PRs
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run normal PR validation as review approval input
* perform author/coder implementation work
* perform raw MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode,
stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile (reconciler or author with close capabilities, as required)
* repo/project
* runtime context
* exact capability for reading/listing PRs and issues
* exact capability for PR inspect (`gitea.read` / view PR)
* exact capability for issue inspect
* exact capability for PR comment, if commenting
* exact capability for issue comment, if commenting
* exact capability for PR close, if closing PRs
* exact capability for issue close, if closing issues
A nearby capability does not count.
Examples:
* `review_pr` does not authorize PR close
* `merge_pr` does not authorize PR close or issue close
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize PR close
* `gitea.read` does not authorize close or comment mutations
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue inventory, ancestry proof, commenting, closing, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct close or comment replay
commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches, commits, or pushes.
Do not run implementation or reviewer validation worktrees for code edits.
Reading repository files is allowed only when needed to understand
reconciliation scope and only if this workflow permits it.
## 5. No raw MCP repair during reconciliation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or
perform control-checkout repair during reconciliation.
If MCP repair is required, stop and produce a separate `CONTROL-CHECKOUT REPAIR
MODE` handoff.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task
tools, or monitoring tasks during reconciliation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery
handoff.
## 7. No local Gitea fallback during normal reconciliation
During normal reconciliation workflows, do not read Gitea profile secret files.
Do not inspect `profiles.json`, local token stores, credential files, `.env`
Gitea credentials, keychain dumps, or token helper outputs.
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable
and identity/profile/capability can be independently proven.
## 8. Build a complete live open PR inventory
List open PRs for the target repo according to project policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` /
final page
* the workflow traversed pages until an empty page or explicit final page was
returned
* the tool response includes total-count or pagination metadata proving all
relevant PRs were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response
explicitly proves the server honored that page size and did not truncate results
If pagination cannot be proven, report `INVENTORY_PAGINATION_UNPROVEN` and stop
unless project policy allows best-effort reconciliation with that limitation
disclosed.
## 9. Already-landed proof
For each candidate PR, prove whether the PR head SHA is already landed on the
target branch.
**Already-landed proof** must include:
* PR number and title
* candidate head SHA (full 40-hex)
* target branch name
* target branch SHA (full 40-hex) after fetch
* ancestor proof method (`git merge-base --is-ancestor`, equivalent forge API, or
documented project helper)
* ancestor proof result (true/false)
* live PR state (open/closed, merged flag)
Do not classify a PR as already-landed without live ancestor proof.
If ancestry cannot be proven, classify as `ANCESTRY_UNPROVEN` and skip close
mutations.
## 10. Linked issue live verification
If the PR claims to close or link an issue, fetch the linked issue live before
reporting its status.
If the linked issue was not fetched live in the current session, report:
`Linked issue status: not verified in this session`
Do not claim `issue open`, `issue closed`, or `issue resolved` without live
proof.
## 11. Reconciliation selection rules
Select PRs eligible for reconciliation:
* open PR state
* `merged=false` unless project policy says otherwise
* head SHA is ancestor of target branch (already-landed proof passed)
* not selected for normal review/merge in this run
Eligibility class for selected PRs: `ALREADY_LANDED_RECONCILE_REQUIRED`
Do not select PRs that fail already-landed proof for normal review/merge
treatment in this mode.
## 12. Reconciliation comment policy
Post a reconciliation comment only if:
* exact PR-comment or issue-comment capability is proven
* the comment adds durable evidence (ancestor proof summary, recommended close
action, linked issue status)
* the comment will not duplicate an equivalent recent reconciliation comment
If comment capability is missing, record the intended comment in the final
handoff only.
## 13. PR close rules
Close a PR only if:
* already-landed proof passed in this session
* exact PR-close capability is proven
* PR is still open at mutation time (live re-fetch)
* head SHA still matches the proved candidate head SHA
If PR-close capability is missing, produce a recovery handoff with exact PR,
proof, and required capability. Do not loop forever re-blocking the reviewer
queue.
## 14. Issue close rules
Close a linked issue only if:
* exact issue-close capability is proven
* linked issue was fetched live
* issue resolution is justified by landed content and project policy
* issue is still open at mutation time
If issue-close capability is missing, report the gap in the handoff.
## 15. Missing capability behavior
If any required mutation capability is missing:
* do not improvise with review/merge tools
* do not ask the operator to bypass capability gates
* produce a recovery handoff listing exact missing capabilities
* include safe next action (profile switch, human close, or dedicated reconciler
profile)
## 16. Mutation classification
Use precise mutation categories in the final report:
* File edits by reconciler: (expect `none`)
* Worktree/index mutations:
* Git ref mutations: (`git fetch` belongs here, not read-only diagnostics)
* MCP/Gitea mutations:
* Reconciliation mutations: (PR comment, issue comment, PR close, issue close)
* External-state mutations:
* Read-only diagnostics:
Do not use legacy `Workspace mutations`.
## 17. Identity privacy rule
Report identity as `username / profile` (#305).
Do not disclose personal email in final reports unless explicitly required.
## 18. Precise final report
Include:
* canonical workflow source/version/hash
* authenticated identity/profile
* repo/project
* capability proof summary (separate lines for inspect, comment, PR close,
issue close)
* inventory pagination proof
* selected PR(s) with already-landed proof
* linked issue live status
* mutations performed or blocked
* missing capabilities
* confirmation that no normal review, approval, request-changes, or merge was
performed
## 19. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
during reconciliation unless explicitly required.
If any file is edited, report under `File edits by reconciler`.
Default: no repo file edits.
## 20. Forbidden unsupported claims unless proven
Do not claim:
* `already-landed`
* `PR closed`
* `issue closed`
* `pagination complete`
* `inventory complete`
* `all gates passed`
* `no unsafe mutation`
unless the corresponding proof is included.
## 21. Proof wording enforcement
Forbidden unless supported by current-session evidence:
* pagination complete
* final page
* no next page
* PR closed
* issue closed
* all gates passed
If proof comes from prior state, label as prior proof, not live proof.
## 22. Final self-check before output
Verify:
* no normal review/merge mutations occurred
* `git fetch` is under Git ref mutations if it occurred
* already-landed proof is present for each selected PR
* handoff uses reconciliation schema, not author/reviewer merge schema
* no contradiction between narrative report and controller handoff
## 23. Controller handoff schema
End every run with `Controller Handoff` per
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
## 24. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* `infra_stop` appears
* inventory pagination cannot be proven and best-effort is not allowed
* already-landed proof cannot be completed
* required close capability is missing and mutation was attempted
* live PR/issue state contradicts proof
* any report contradiction cannot be resolved
Do not improvise around the gates.
File diff suppressed because it is too large Load Diff
@@ -1,888 +0,0 @@
---
task_mode: work-issue
canonical: true
final_report_schema: ../schemas/work-issue-final-report.md
---
# Work issue workflow (canonical)
**Task mode:** `work-issue`
This file is the canonical author/coder workflow for Gitea-Tools. Load it
before any issue implementation mutation. Final report schema:
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
**Default task prompt:**
> Find the next eligible issue in this project, work on it only if all gates
> pass, and create a PR when complete.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an author/coder workflow. It is not a reviewer workflow.
---
## 0. Load the canonical workflow first
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `work-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs unless the PR creation workflow explicitly does so through Gitea automation
* close unrelated issues
* mutate reviewer state
* perform reviewer-only actions
* create process-hardening issues unless explicitly authorized and the workflow switches to issue-creation mode
If the task requires review, merge, issue creation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active author/coder profile
* repo/project
* runtime context
* exact capability for reading issues
* exact capability for claiming/locking issues, if available
* exact capability for branch creation, if handled through MCP
* exact capability for pushing branches, if applicable
* exact capability for creating PRs
* exact capability for commenting on issues or PRs, if needed
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `review_pr` does not authorize `merge_pr`
* `create_pr` does not authorize `merge_pr`
* `issue_comment` does not authorize `create_issue`
* `gitea.read` does not authorize issue claim, PR creation, or branch mutation
If capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* dirty control checkout
* dirty task worktree
* missing capability
* workspace mismatch
* stale target branch state
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue issue selection, claiming, implementation, validation, commit, push, PR creation, cleanup, or handoff mutation.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
The main project checkout must stay on `master`, `main`, or `dev`.
Do not do task work in the main checkout.
Do not edit files in the main checkout.
Do not run tests in the main checkout.
Do not commit from the main checkout.
Do not create PRs from the main checkout.
All task work must happen under the projects `branches/` directory.
No exceptions for small fixes, docs, tests, cleanup, conflict resolution, emergencies, or “just one file.”
If the main checkout is dirty before selection, stop and produce a recovery handoff.
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
## 5. No raw MCP repair during normal issue work
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
If MCP repair is required, stop issue work and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with work-on-issue mode.
Do not use successful repair as permission to resume the same issue workflow. After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue work.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Long synchronous commands, such as a test suite, are allowed only if they are run directly and reported with exact command, working directory, and result.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue work
During normal author/coder workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Build a complete live issue inventory
List open issues according to the projects issue selection policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Do not claim `next eligible issue`, `oldest eligible issue`, or complete issue inventory unless pagination is proven.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “inventory complete” merely because the result count is less than an assumed default page size.
For each candidate issue, identify:
* issue number
* title
* labels
* status
* author/requester, if relevant
* assignee/owner, if any
* linked PRs, if any
* dependency/blocker labels, if any
* whether it appears already claimed
* whether it appears already implemented or superseded
* whether it is eligible under project rules
Final report must include pagination/final-page proof.
## 9. Issue selection rules
State the issue ordering policy before selecting an issue.
If the project uses oldest-first, explicitly sort or reason by issue number or created date.
Do not rely on API response order unless the tool proves that order matches the project policy.
Do not pick:
* already-claimed issues
* issues assigned to another active worker
* issues with an open PR already covering the work
* duplicate issues
* blocked issues
* dependency-blocked issues
* already implemented issues
* issues outside the current requested scope
* process-hardening issues unless this run was explicitly started for process-hardening work
* reviewer-only issues if this is author/coder mode
For every earlier issue skipped, report:
* issue number
* current status
* blocking category
* proof used
* whether there is an open PR
* whether there is an active claim
* reason it is not eligible
If eligibility cannot be proven, classify it as:
`ISSUE_ELIGIBILITY_UNVERIFIED`
Then stop or produce a recovery handoff according to project policy.
Do not select an issue based only on memory from a previous session.
## 10. Linked PR / duplicate active work proof
Before claiming or working on an issue, check whether there is already an open PR, branch, or active claim for that issue.
If an open PR already exists for the issue, do not implement duplicate work.
Classify the issue as:
`OPEN_PR_EXISTS`
and skip it only if project policy allows skipping.
If branch naming or PR title convention links issues to branches, search for matching branches or PRs.
Report:
* issue number
* linked/open PRs found
* matching branches found, if checked
* active claims found
* duplicate work status
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
If claim/lock requires a Gitea mutation, prove exact capability first.
If claim/lock fails, stop.
Do not implement unclaimed work.
If the claim/lock gates are broken, produce a recovery handoff.
Create a tooling issue only if this run is explicitly authorized to switch to issue-creation mode and exact `create_issue` capability is proven.
Report:
* claim mechanism used
* claim result
* claim timestamp, if available
* issue owner/assignee after claim, if available
## 12. Refresh stable branch before branch/worktree creation
Fetch the stable target branch from the remote before creating a task branch or worktree.
Do not rely on stale local `master`, `main`, or `dev`.
Record the fetched stable branch SHA.
If the stable branch cannot be fetched or verified, stop and produce a recovery handoff.
`git fetch`, `git remote update`, and any command that updates refs must be reported under `Git ref mutations`, not read-only diagnostics.
## 13. Branch and worktree ownership rule
Create a fresh session-owned worktree under `branches/`.
Prefer a branch name that includes the issue number, for example:
`feat/issue-<ISSUE_NUMBER>-short-description`
or:
`fix/issue-<ISSUE_NUMBER>-short-description`
Prefer a worktree path like:
`branches/issue-<ISSUE_NUMBER>-short-description`
Before any file edits, prove:
* project root
* current working directory
* main checkout branch
* stable branch
* stable branch SHA
* task branch name
* session-owned worktree path
* worktree path is inside `branches/`
* worktree is not the main checkout
* clean tracked state
* clean untracked state
* worktree HEAD/branch state
Do not reuse an existing worktree unless safe-reuse proof passes.
Safe-reuse proof must include:
* exact worktree path
* worktree is inside `branches/`
* worktree is not the main checkout
* worktree is not owned by another active task/session
* clean tracked state
* clean untracked state
* current branch/head before reset
* reset target SHA
* explicit project policy allowing reuse/reset
Do not run `git reset --hard`, `git clean`, checkout, or other destructive commands unless the worktree is session-owned or safe-reuse proof passes.
If safe-reuse proof cannot be produced, create a fresh session-owned worktree.
## 14. Implementation scope rule
Implement only what is required for the selected issue.
Do not perform opportunistic refactors.
Do not fix unrelated tests unless they are required for the selected issue and clearly documented.
Do not modify reviewer workflow files unless the selected issue explicitly requires workflow changes.
Do not modify Gitea profiles, MCP authorization, tokens, secrets, deployment config, production config, or credentials unless the selected issue explicitly requires it and exact capability/proof gates pass.
Do not introduce provenance markers, agent signatures, temporary files, debug dumps, or generated artifacts unless required.
If implementation uncovers a separate issue, note it in the final report or create a follow-up issue only if exact capability is proven and project policy allows it.
## 15. File edit rule
All edits must happen only inside the session-owned issue worktree.
Do not edit files in the main checkout.
Do not edit files in reviewer worktrees.
Do not edit unrelated worktrees.
Track every edited, created, deleted, or generated file.
If any file is edited, created, generated, or written, report it under `File edits by author`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final `git status` was run after the write
Do not say `File edits by author: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
## 16. Validation rule
Run appropriate validation for the selected issue.
Validation may include:
* targeted tests
* full test suite
* compile checks
* lint checks
* type checks
* diff checks
* secret/provenance checks
* dangerous artifact checks
* project-specific validation
If validation cannot run, explain why and include the exact failure.
Do not hide failures.
Do not claim success if tests failed.
Do not skip required validation silently.
Do not bypass MCP gates.
Report every validation command with:
* exact command
* working directory
* exit code or pass/fail result
* summary count if available
* whether it was targeted, full-suite, compile, lint, diff, secret/provenance, or diagnostic validation
If using bare `pytest`, also report:
* `which pytest`
* `pytest --version`
* whether it resolves to the project venv
Prefer the project venv executable when available.
## 17. Baseline comparison rule
Do not run tests in the main checkout.
If the full suite fails and you need to prove failures are pre-existing, create a clean baseline worktree under `branches/`, such as:
`branches/baseline-master-issue-<ISSUE_NUMBER>`
Baseline comparison must include:
* baseline worktree path
* baseline target SHA
* task branch SHA
* exact command run on both worktrees
* baseline failures
* task branch failures
* proof the failure signatures match
* proof the baseline worktree was clean before and after validation
* proof the issue worktree was clean before and after validation
Do not claim “same as master” unless the clean baseline worktree proof is included.
Do not claim “full-suite failures are pre-existing” unless baseline proof is complete and the failure signatures match.
If full-suite failures differ or proof is incomplete, do not create a PR unless project policy explicitly allows PR creation with documented validation failures.
## 18. Pre-commit review
Before committing, review the actual diff.
Check:
* correctness
* tests
* scope
* security boundaries
* workflow rule compliance
* whether the implementation really satisfies the selected issue
* unrelated changes
* dangerous generated artifacts
* secrets
* provenance markers
* temporary agent files
* debug output
* formatting-only churn
* docs/tests consistency
Run:
* `git status`
* `git diff --stat`
* `git diff`
* project-required diff checks
Do not commit if unrelated or unsafe changes are present.
## 19. Commit rules
Commit only from the session-owned issue worktree.
Do not commit from the main checkout.
Commit only after implementation and required validation pass, unless project policy explicitly allows draft PRs with failing validation.
Commit message must reference the issue number.
Preferred format:
`fix: short summary (Closes #<ISSUE_NUMBER>)`
or:
`feat: short summary (Closes #<ISSUE_NUMBER>)`
Before commit, prove:
* worktree path
* branch name
* selected issue number
* staged files
* diff summary
* validation status
After commit, record:
* commit SHA
* commit message
* changed files
Do not amend, reset, rebase, squash, or force-push unless the project workflow explicitly allows it and the worktree is session-owned.
## 20. Push rules
Push only the session-owned task branch.
Do not push `master`, `main`, `dev`, tags, or unrelated branches.
Before push, prove:
* current branch
* upstream/remote target
* commit SHA being pushed
* selected issue number
* branch name matches the issue
After push, report:
* remote
* branch
* pushed commit SHA
* push result
If push fails, stop and produce a recovery handoff.
## 21. PR creation rules
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
Do not create a PR if:
* issue was not claimed/locked
* issue eligibility was unproven
* duplicate open PR exists
* task branch does not reference the issue
* implementation is incomplete
* validation failed without allowed exception
* worktree is dirty
* secrets/provenance/dangerous artifacts are present
* capability for PR creation is missing
* runtime context is blocked
* authenticated identity/profile changed unexpectedly
PR must reference or close the issue.
PR body must include:
* summary
* linked issue
* files changed
* validation commands and results
* risk
* exact worktree path
* branch name
* commit SHA
* known limitations, if any
Do not merge your own PR.
Do not approve your own PR.
Do not request changes on your own PR.
After PR creation, fetch or view the PR to verify:
* PR number
* PR URL
* PR title
* base branch
* head branch
* linked issue
* head SHA
* open status
## 22. Cleanup rules
Clean only session-owned temporary/baseline worktrees if the project workflow explicitly allows cleanup.
Do not delete unrelated branches/worktrees.
Do not delete the task worktree if the project expects it to remain for handoff unless policy says cleanup is allowed after PR creation.
Do not update the main checkout unless the canonical workflow explicitly allows it.
Any cleanup is a mutation and must be reported.
## 23. Recovery handoff rules
If blocked, produce a recovery handoff with:
* exact blocker
* failed tool/function, if any
* repo/project
* selected issue, if one was safely selected
* eligibility class
* claim/lock state
* branch name, if created
* worktree path, if created
* stable branch and stable branch SHA, if known
* files changed, if any
* validation state
* commit SHA, if committed
* PR number/URL, if created
* exact state reached before stopping
* safe next action
* statement that no unsafe mutation was attempted
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 24. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* capability proof summary
* issue inventory proof, including pagination/final-page proof
* issue ordering policy used
* selected issue number/title
* eligibility class
* skipped earlier issues and proof, if any
* duplicate active work proof
* claim/lock result
* stable branch and stable branch SHA
* branch name
* worktree path
* worktree inside `branches/`: true/false
* worktree branch/HEAD state
* worktree dirty before implementation: true/false
* files changed
* validation commands and results
* baseline comparison result, if used
* pre-commit diff review result
* commit SHA and commit message, if committed
* push result, if pushed
* PR number and URL, if created
* PR verification result, if created
* cleanup result
* blockers, if stopped
* confirmation that the main checkout was not used for task work
If the report and actual tool/command log disagree, fix the report before final output.
## 25. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
Use precise wording:
* `File edits by author: none`
* `Worktree/index mutations: ...`
* `Git ref mutations: ...`
* `MCP/Gitea mutations: ...`
Do not collapse issue, branch, commit, push, PR, cleanup, or external-state mutations into vague wording.
## 26. Forbidden final-report claims unless proven
Do not claim:
* `next eligible issue`
* `oldest eligible issue`
* `issue claimed`
* `no duplicate work`
* `no open PR`
* `worktree clean`
* `validation passed`
* `same as master`
* `full-suite failures are pre-existing`
* `committed`
* `pushed`
* `PR created`
* `issue closed`
* `main checkout untouched`
* `no file edits`
* `no unsafe mutation`
* `all gates passed`
* `target branch up to date`
unless the corresponding proof is included.
If anything blocks safe work or PR creation, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 27. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* next eligible issue
* oldest eligible issue
* inventory complete
* no duplicate work
* issue claimed
* worktree clean
* validation passed
* same as master
* full-suite failures are pre-existing
* committed
* pushed
* PR created
* issue closed
* target branch up to date
* all gates passed
* no unsafe mutation
* no file edits
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 28. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by author` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was claimed/commented/updated, `Issue mutations` lists it
* if any branch was created, `Branch mutations` lists it
* if any commit was created, `Commit mutations` lists it
* if any push occurred, `Push mutations` lists it
* if any PR was created, `PR mutations` lists it
* if any cleanup happened, `Cleanup mutations` lists it
* if any issue/PR external state changed, `External-state mutations` lists it
* if pagination is claimed complete, final-page proof is present
* if same-as-master is claimed, baseline proof is complete
* if selected issue is claimed next eligible, every earlier issue has proof-backed skip reasoning
* if PR created is claimed, PR verification proof is present
* if main checkout untouched is claimed, main checkout status proof is present
If any contradiction exists, fix the final report before output.
## 29. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Selected issue:
* Eligibility class:
* Issue ordering policy:
* Issue inventory pagination proof:
* Earlier issues skipped:
* Duplicate active work proof:
* Claim/lock state:
* Stable branch:
* Stable branch SHA:
* Branch name:
* Worktree path:
* Worktree inside branches:
* Worktree branch/HEAD state:
* Worktree dirty before implementation:
* Files changed:
* Validation:
* Baseline comparison:
* Commit SHA:
* Push result:
* PR number:
* PR URL:
* PR verification:
* Main checkout branch:
* Main checkout dirty state:
* Main checkout used for task work:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 30. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* issue inventory pagination cannot be proven
* issue ordering cannot be proven
* issue eligibility cannot be proven
* duplicate active work cannot be checked
* selected issue is already claimed by another worker
* selected issue already has an open PR
* claim/lock fails
* stable branch cannot be fetched
* task worktree cannot be created safely
* task worktree is dirty before implementation
* validation cannot run
* validation fails without allowed exception
* baseline comparison is required but incomplete
* diff review finds unrelated or unsafe changes
* commit fails
* push fails
* PR creation fails
* PR verification fails
* any report contradiction cannot be resolved
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
-166
View File
@@ -1,166 +0,0 @@
"""Fail-closed subagent delegation gates (#266).
Subagents can bypass or lose context for worktree, capability, mutation,
retry, and reporting rules. These helpers make delegation an explicit,
provable decision instead of a default: deterministic write tasks stay
inline unless the parent session records why a subagent is needed and
hands the subagent the full gate context it must operate under.
Like ``author_proofs``/``review_proofs``, the helpers are pure (no git,
no API calls): the parent workflow gathers the facts and passes them in,
so the same logic works from prompts, harness assertions, and tests.
Nothing here weakens the review/merge/permission gates a delegated
subagent is subject to the same gates as its parent.
"""
# AC1: deterministic write workflows a subagent must never run by default.
DETERMINISTIC_WRITE_TASKS = frozenset({
"claim_issue",
"create_branch",
"edit_code",
"commit",
"push_branch",
"create_pr",
"review_pr",
"merge_pr",
"cleanup_branch",
"close_issue",
})
# Read-only delegation that needs no explicit authorization.
READ_ONLY_TASKS = frozenset({
"read_files",
"code_search",
"inventory_prs",
"summarize_issue",
"explore_codebase",
})
# AC3: context a subagent must inherit from the parent session before any
# authorized write delegation may proceed.
REQUIRED_INHERITED_CONTEXT = (
"issue_lock",
"branch",
"worktree_path",
"identity_profile",
"allowed_tool_class",
"command_deny_list",
"validation_ledger_requirement",
"final_report_schema",
)
# AC4: proof fields a subagent final report must carry — the same fields a
# parent workflow's final report requires.
REQUIRED_SUBAGENT_REPORT_FIELDS = (
"identity_profile",
"worktree_path",
"branch",
"changed_files",
"validation_results",
"workspace_mutations",
)
def _clean(value):
return (value or "").strip() if isinstance(value, str) else value
def _classify_task(task_type):
task = _clean(task_type)
if not task:
return "", "unknown"
if task in DETERMINISTIC_WRITE_TASKS:
return task, "deterministic_write"
if task in READ_ONLY_TASKS:
return task, "read_only"
return task, "unknown"
def _missing_context_fields(inherited_context):
context = inherited_context or {}
missing = []
for field in REQUIRED_INHERITED_CONTEXT:
value = context.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
return missing
def assess_subagent_delegation(task_type, *, explicitly_allowed=False,
justification=None, inherited_context=None):
"""Decide whether delegating *task_type* to a subagent may proceed.
Fail closed: unknown tasks are blocked; deterministic write tasks are
blocked unless explicitly allowed (AC1) with a recorded justification
(AC2) and the full inherited gate context (AC3). Read-only delegation
is allowed without explicit authorization.
Returns {'block', 'allowed', 'task_type', 'task_class', 'reasons',
'missing_context'}.
"""
task, task_class = _classify_task(task_type)
reasons = []
missing_context = []
if task_class == "unknown":
reasons.append(
f"task type '{task}' is not a recognized delegation class; "
"run it inline in the parent session (fail closed)"
)
elif task_class == "deterministic_write":
if not explicitly_allowed:
reasons.append(
f"deterministic write task '{task}' must run inline unless "
"subagent use is explicitly allowed by the parent session"
)
if not _clean(justification):
reasons.append(
"no recorded justification for why a subagent is needed; "
"the parent session must record one before delegating"
)
missing_context = _missing_context_fields(inherited_context)
if missing_context:
reasons.append(
"subagent would not inherit required gate context: "
+ ", ".join(missing_context)
)
block = bool(reasons)
return {
"block": block,
"allowed": not block,
"task_type": task,
"task_class": task_class,
"reasons": reasons,
"missing_context": missing_context,
}
def validate_subagent_report(report_fields):
"""AC4: accept subagent output only with the parent-grade proof fields.
*report_fields* maps field name -> reported value. Missing or blank
proof fields make the report invalid (fail closed).
Returns {'valid', 'block', 'reasons', 'missing_fields'}.
"""
reasons = []
report = report_fields or {}
missing = []
for field in REQUIRED_SUBAGENT_REPORT_FIELDS:
value = report.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
if missing:
reasons.append(
"subagent final report missing required proof fields: "
+ ", ".join(missing)
)
valid = not reasons
return {
"valid": valid,
"block": not valid,
"reasons": reasons,
"missing_fields": missing,
}
-130
View File
@@ -1,130 +0,0 @@
"""Shared task→permission map for resolver and tool gates (#69).
``gitea_resolve_task_capability`` and issue-mutating MCP tools must agree on
which profile operation each task requires. This module is the single source
of truth; regression tests assert tool gates cannot drift from it.
"""
from __future__ import annotations
TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"create_issue": {
"permission": "gitea.issue.create",
"role": "author",
},
"comment_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"close_issue": {
"permission": "gitea.issue.close",
"role": "author",
},
"claim_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"mark_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"lock_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"set_issue_labels": {
"permission": "gitea.issue.comment",
"role": "author",
},
"create_branch": {
"permission": "gitea.branch.create",
"role": "author",
},
"push_branch": {
"permission": "gitea.branch.push",
"role": "author",
},
"create_pr": {
"permission": "gitea.pr.create",
"role": "author",
},
"comment_pr": {
"permission": "gitea.pr.comment",
"role": "author",
},
"close_pr": {
"permission": "gitea.pr.close",
"role": "author",
},
"address_pr_change_requests": {
"permission": "gitea.branch.push",
"role": "author",
},
"review_pr": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"merge_pr": {
"permission": "gitea.pr.merge",
"role": "reviewer",
},
"blind_pr_queue_review": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"request_changes_pr": {
"permission": "gitea.pr.request_changes",
"role": "reviewer",
},
"approve_pr": {
"permission": "gitea.pr.approve",
"role": "reviewer",
},
"delete_branch": {
"permission": "gitea.branch.delete",
"role": "author",
},
"commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"gitea_commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"reconcile_merged_cleanups": {
"permission": "gitea.read",
"role": "author",
},
}
# Issue-mutating MCP tools and their resolver task keys.
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
"gitea_create_issue": "create_issue",
"gitea_close_issue": "close_issue",
"gitea_create_issue_comment": "comment_issue",
"gitea_mark_issue": "mark_issue",
"gitea_set_issue_labels": "set_issue_labels",
"gitea_commit_files": "commit_files",
}
def required_permission(task: str) -> str:
"""Return the canonical operation a *task* requires (fail closed)."""
try:
return TASK_CAPABILITY_MAP[task]["permission"]
except KeyError as exc:
raise KeyError(f"Unknown task/action: {task!r} (fail closed)") from exc
def required_role(task: str) -> str:
"""Return author/reviewer role kind for *task* (fail closed)."""
try:
return TASK_CAPABILITY_MAP[task]["role"]
except KeyError as exc:
raise KeyError(f"Unknown task/action: {task!r} (fail closed)") from exc
def tool_required_permission(tool_name: str) -> str:
"""Return the operation an issue-mutating tool must gate on."""
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
-39
View File
@@ -1,39 +0,0 @@
"""Shared pytest fixtures for the Gitea-Tools test suite."""
import sys
import pytest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@pytest.fixture(autouse=True)
def _reset_mutation_authority(monkeypatch):
"""Isolate the in-process mutation authority between tests (#199).
The mutation-authority gate stays LIVE in every test this fixture only
clears the per-process record and the session profile lock so one test's
seeded authority (or an intentionally mismatched one) cannot leak into
the next test. It must never replace verify_mutation_authority with a
no-op: individual tests that need a specific authority state set it up
explicitly.
"""
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
try:
import mcp_server
except Exception:
yield
return
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
try:
import capability_stop_terminal
capability_stop_terminal.clear()
except Exception:
pass
yield
try:
import capability_stop_terminal
capability_stop_terminal.clear()
except Exception:
pass
-87
View File
@@ -1,87 +0,0 @@
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import agent_temp_artifacts
import mcp_server
class TestAgentTempArtifactPatterns(unittest.TestCase):
def test_detects_root_level_encode_emit_inline(self):
porcelain = (
"?? _encode_commit_payload.py\n"
"?? _emit_payload.py\n"
"?? _inline_b64.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
["_emit_payload.py", "_encode_commit_payload.py", "_inline_b64.py"],
)
def test_ignores_nested_and_unrelated_untracked(self):
porcelain = (
"?? scripts/_encode_x.py\n"
"?? README-draft.md\n"
" M task_capability_map.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
[],
)
class TestPreflightWarnings(unittest.TestCase):
def setUp(self):
self._snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._snapshot
)
@patch(
"mcp_server._get_workspace_porcelain",
return_value="?? _encode_commit_payload.py\n",
)
def test_assess_preflight_surfaces_warning_not_block(self, _porcelain):
status = mcp_server.assess_preflight_status()
self.assertTrue(status["preflight_ready"])
self.assertEqual(status["preflight_block_reasons"], [])
self.assertEqual(len(status["preflight_warnings"]), 1)
self.assertIn("_encode_commit_payload.py", status["preflight_warnings"][0])
class TestIssueLockArtifactWarning(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
@patch("issue_lock_worktree.read_worktree_git_state")
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
mock_state.return_value = {
"current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n",
}
result = mcp_server.gitea_lock_issue(
issue_number=261,
branch_name="docs/issue-261-agent-artifact-cleanup",
remote="prgs",
)
self.assertTrue(result["success"])
self.assertIn("warnings", result)
self.assertIn("_emit_payload.py", result["warnings"][0])
if __name__ == "__main__":
unittest.main()
+12 -44
View File
@@ -162,8 +162,7 @@ class _AuditWiringBase(unittest.TestCase):
def _env(self, **extra):
env = {"GITEA_AUDIT_LOG": self.audit_path,
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": (
"read,merge,gitea.issue.create,gitea.issue.close")}
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
env.update(extra)
return env
@@ -176,12 +175,9 @@ class _AuditWiringBase(unittest.TestCase):
class TestSimpleToolAudit(_AuditWiringBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_issue_success_audited(self, _auth, _get_all, mock_api, _role):
def test_create_issue_success_audited(self, _auth, mock_api):
# 1: create POST result, 2: identity /user lookup for the audit record.
mock_api.side_effect = [
{"number": 11, "html_url": "https://gitea.prgs.cc/issues/11"},
@@ -200,12 +196,9 @@ class TestSimpleToolAudit(_AuditWiringBase):
self.assertEqual(rec["issue_number"], 11)
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_issue_failure_audited(self, _auth, _get_all, mock_api, _role):
def test_create_issue_failure_audited(self, _auth, mock_api):
mock_api.side_effect = [
RuntimeError("HTTP 500: boom"),
{"login": "author-bot"}, # identity lookup for the audit record
@@ -230,31 +223,19 @@ class TestSimpleToolAudit(_AuditWiringBase):
self.assertEqual(recs[0]["issue_number"], 42)
self.assertEqual(recs[0]["authenticated_username"], "mgr-bot")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, mock_api):
# No GITEA_AUDIT_LOG -> audit is a no-op: exactly one API call, no file.
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
with patch.dict(os.environ, {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}, clear=True):
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-author"}, clear=True):
gitea_create_issue(title="x", remote="prgs")
issue_posts = [
c for c in mock_api.call_args_list if c.args[0] == "POST"
]
self.assertEqual(len(issue_posts), 1)
mock_api.assert_called_once()
self.assertEqual(self._records(), [])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_secrets_never_written(self, _auth, _get_all, mock_api, _role):
def test_secrets_never_written(self, _auth, mock_api):
mock_api.side_effect = [
{"number": 3, "html_url": "http://x/3"},
{"login": "author-bot"},
@@ -267,12 +248,9 @@ class TestSimpleToolAudit(_AuditWiringBase):
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
self.assertNotIn(secret, blob)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_audit_failure_never_breaks_action(self, _auth, _get_all, mock_api, _role):
def test_audit_failure_never_breaks_action(self, _auth, mock_api):
mock_api.side_effect = [
{"number": 9, "html_url": "http://x/9"},
{"login": "author-bot"},
@@ -293,12 +271,9 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merge_success_audited(self, _auth, mock_api):
# user, pr, feedback pr+reviews, merge POST, readback.
# user, pr, merge POST, readback — no extra identity call (uses result).
mock_api.side_effect = [
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
@@ -335,20 +310,13 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_submit_review_success_audited(self, _auth, mock_api):
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 7},
]
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(8, "approve", remote="prgs")
r = gitea_submit_pr_review(pr_number=8, action="approve",
body="LGTM", remote="prgs",
final_review_decision_ready=True)
body="LGTM", remote="prgs")
self.assertTrue(r["performed"])
recs = self._records()
self.assertEqual(len(recs), 1)
-234
View File
@@ -1,234 +0,0 @@
"""Tests for author-side branch-identity proofs (Issue #177).
Issue #177 (author-side counterpart of the #173 reviewer proofs) requires
author workflows to *prove* local git state before staging, committing, or
pushing, instead of discovering drift after the fact:
1. The current branch equals the intended feature branch and is never a
protected branch (master/main/develop/development/dev).
2. Branch or HEAD drift between validation and commit including external
branch switches in a shared worktree stops the workflow.
3. A push requires local branch, remote target branch, and intended issue
branch to all match.
4. An accidental commit on a protected branch must not be pushed and its
repair must be reported, never silently continued.
These are the harness assertions from the issue's Required behavior 5.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from author_proofs import ( # noqa: E402
PROTECTED_BRANCHES,
assess_protected_branch_commit,
build_commit_push_report,
detect_branch_drift,
verify_branch_for_commit,
verify_push_target,
)
FEATURE = "feat/issue-177-branch-drift-proofs"
HEAD_1 = "64dc334a92685b7b6a1fdb7ffe363f02a69f5dbd"
HEAD_2 = "ccc5ef79dfe629853e144763238593bd808d57e0"
class TestProtectedBranches(unittest.TestCase):
def test_known_protected_names(self):
for name in ("master", "main", "develop", "development", "dev"):
self.assertIn(name, PROTECTED_BRANCHES)
class TestVerifyBranchForCommit(unittest.TestCase):
"""Required behavior 1: prove the branch before staging/committing."""
def test_on_intended_feature_branch_is_proven(self):
proof = verify_branch_for_commit(FEATURE, FEATURE)
self.assertTrue(proof["proven"])
self.assertFalse(proof["block"])
def test_commit_attempted_while_on_master_is_blocked(self):
# Harness assertion (behavior 5, bullet 1).
proof = verify_branch_for_commit("master", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
self.assertTrue(any("master" in r for r in proof["reasons"]))
def test_every_protected_branch_is_blocked_as_current(self):
for name in PROTECTED_BRANCHES:
proof = verify_branch_for_commit(name, FEATURE)
self.assertTrue(proof["block"], name)
def test_intended_branch_may_not_be_protected(self):
proof = verify_branch_for_commit("master", "master")
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_wrong_feature_branch_is_blocked(self):
proof = verify_branch_for_commit("feat/issue-178-other-work", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_missing_current_branch_fails_closed(self):
proof = verify_branch_for_commit("", FEATURE)
self.assertTrue(proof["block"])
def test_missing_intended_branch_fails_closed(self):
proof = verify_branch_for_commit(FEATURE, None)
self.assertTrue(proof["block"])
class TestBranchDrift(unittest.TestCase):
"""Required behaviors 2 + 3: drift between validation and commit stops
the workflow."""
def test_no_drift_when_branch_and_head_unchanged(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_1)
self.assertFalse(drift["drifted"])
self.assertFalse(drift["block"])
def test_branch_drift_between_validation_and_commit_is_blocked(self):
# Harness assertion (behavior 5, bullet 2).
drift = detect_branch_drift(FEATURE, HEAD_1, "feat/other", HEAD_1)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
def test_shared_worktree_branch_switch_is_detected(self):
# Harness assertion (behavior 5, bullet 4): an external session
# switching the shared checkout to another branch (e.g. master)
# must be detected as drift, not treated as exceptional noise.
drift = detect_branch_drift(FEATURE, HEAD_1, "master", HEAD_1)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
self.assertTrue(any("switch" in r.lower() for r in drift["reasons"]))
def test_head_moved_since_validation_is_blocked(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_2)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
self.assertTrue(any("HEAD" in r for r in drift["reasons"]))
def test_missing_state_fails_closed(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, None)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
class TestVerifyPushTarget(unittest.TestCase):
"""Required behavior 1 (push leg) + acceptance: push needs proof that
local, remote, and intended branches all match."""
def test_matching_local_remote_and_intended_is_proven(self):
proof = verify_push_target(FEATURE, FEATURE, FEATURE)
self.assertTrue(proof["proven"])
self.assertFalse(proof["block"])
def test_push_target_mismatch_is_blocked(self):
# Harness assertion (behavior 5, bullet 3).
proof = verify_push_target(FEATURE, "feat/issue-178-other-work", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_local_branch_differs_from_intended_is_blocked(self):
proof = verify_push_target("feat/other", FEATURE, FEATURE)
self.assertTrue(proof["block"])
def test_pushing_a_protected_branch_is_blocked(self):
proof = verify_push_target("master", "master", "master")
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_missing_remote_target_fails_closed(self):
proof = verify_push_target(FEATURE, "", FEATURE)
self.assertTrue(proof["block"])
class TestProtectedBranchAccident(unittest.TestCase):
"""Required behavior 4: accidental protected-branch commits must not be
pushed and their repair must be reported."""
def test_feature_branch_commit_is_not_an_accident(self):
result = assess_protected_branch_commit(FEATURE)
self.assertFalse(result["accident"])
self.assertEqual(result["violations"], [])
def test_commit_on_master_is_an_accident_and_must_not_push(self):
result = assess_protected_branch_commit(
"master", pushed=False, repair_reported=True
)
self.assertTrue(result["accident"])
self.assertTrue(result["must_not_push"])
self.assertEqual(result["violations"], [])
self.assertTrue(result["repair_required"])
def test_pushing_the_accident_is_a_violation(self):
result = assess_protected_branch_commit(
"master", pushed=True, repair_reported=True
)
self.assertTrue(any("push" in v.lower() for v in result["violations"]))
def test_silent_repair_is_a_violation(self):
# Harness assertion (behavior 5, bullet 5): the repair path must not
# silently continue without reporting.
result = assess_protected_branch_commit(
"master", pushed=False, repair_reported=False
)
self.assertTrue(any("report" in v.lower() for v in result["violations"]))
class TestCommitPushReport(unittest.TestCase):
"""Acceptance criteria: the final report includes branch proof before
commit and before push, and blocks instead of continuing."""
def _report(self, **overrides):
kwargs = {
"commit_proof": verify_branch_for_commit(FEATURE, FEATURE),
"drift": detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_1),
"push_proof": verify_push_target(FEATURE, FEATURE, FEATURE),
"accident": assess_protected_branch_commit(FEATURE),
}
kwargs.update(overrides)
return build_commit_push_report(**kwargs)
def test_fully_proven_report_is_ok(self):
report = self._report()
self.assertEqual(report["status"], "ok")
self.assertTrue(report["branch_proof_before_commit"])
self.assertTrue(report["branch_proof_before_push"])
self.assertFalse(report["drift_detected"])
self.assertEqual(report["violations"], [])
def test_commit_proof_failure_blocks(self):
report = self._report(
commit_proof=verify_branch_for_commit("master", FEATURE)
)
self.assertEqual(report["status"], "blocked")
self.assertFalse(report["branch_proof_before_commit"])
def test_drift_blocks(self):
report = self._report(
drift=detect_branch_drift(FEATURE, HEAD_1, "master", HEAD_1)
)
self.assertEqual(report["status"], "blocked")
self.assertTrue(report["drift_detected"])
def test_push_proof_failure_blocks(self):
report = self._report(
push_proof=verify_push_target(FEATURE, "feat/other", FEATURE)
)
self.assertEqual(report["status"], "blocked")
self.assertFalse(report["branch_proof_before_push"])
def test_accident_violations_block(self):
report = self._report(
accident=assess_protected_branch_commit(
"master", pushed=False, repair_reported=False
)
)
self.assertEqual(report["status"], "blocked")
self.assertTrue(report["violations"])
if __name__ == "__main__":
unittest.main()
-205
View File
@@ -1,205 +0,0 @@
"""Tests for capability stop terminal mode (#197)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import capability_stop_terminal
import gitea_config
import mcp_server
from review_proofs import assess_capability_stop_terminal_report
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestCapabilityStopTerminal(unittest.TestCase):
def setUp(self):
capability_stop_terminal.clear()
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.example.com",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
capability_stop_terminal.clear()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self):
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": "prgs-author",
"GITEA_TOKEN_AUTHOR": "author-pass",
}
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_review_pr_stop_enters_terminal_mode(self, _auth, _api):
with patch.dict(os.environ, self._env()):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="prgs"
)
self.assertTrue(res["stop_required"])
self.assertTrue(res.get("terminal_mode"))
self.assertTrue(capability_stop_terminal.is_active())
self.assertIn(
capability_stop_terminal.TERMINAL_REPORT_HEADING,
res["terminal_report"]["heading"],
)
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_list_prs_blocked_after_capability_stop(self, _auth, _api):
with patch.dict(os.environ, self._env()):
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_list_prs(remote="prgs")
self.assertIn("Cannot perform reviewer task", str(ctx.exception))
self.assertIn("review_pr", str(ctx.exception))
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_list_prs_allowed_after_author_task_clears_stale_denial(
self, _auth, _api, mock_fetch,
):
mock_fetch.return_value = ([], {
"page": 1, "per_page": 50, "returned_count": 0,
"has_more": False, "next_page": None, "is_final_page": True,
})
with patch.dict(os.environ, self._env()):
denied = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="prgs"
)
self.assertTrue(denied["stop_required"])
self.assertTrue(capability_stop_terminal.is_active())
cleared = mcp_server.gitea_resolve_task_capability(
task="claim_issue", remote="prgs"
)
self.assertTrue(cleared["allowed_in_current_session"])
self.assertTrue(cleared.get("cleared_stale_denial"))
self.assertFalse(capability_stop_terminal.is_active())
prs = mcp_server.gitea_list_prs(remote="prgs")
self.assertEqual(prs["prs"], [])
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_eligibility_check_blocked_after_stop(self, _auth, _api):
with patch.dict(os.environ, self._env()):
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
res = mcp_server.gitea_check_pr_eligibility(
pr_number=193, action="review", remote="prgs"
)
self.assertFalse(res["eligible"])
self.assertTrue(res.get("terminal_mode"))
def test_report_with_pr_selection_impure(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"Selected PR #193 for review anyway."
)
result = assess_capability_stop_terminal_report(report)
self.assertFalse(result["pure"])
def test_session_eligibility_wording_blocked(self):
ok, violations = capability_stop_terminal.validate_eligibility_wording(
"PR 193 is not authored by this session so it is eligible."
)
self.assertFalse(ok)
self.assertTrue(violations)
def test_rebase_fallback_blocked_in_report(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"Or have me rebase conflicted PR 193."
)
result = assess_capability_stop_terminal_report(report)
self.assertFalse(result["pure"])
self.assertTrue(
any("author fallback" in v for v in result["violations"])
)
def test_empty_queue_without_trusted_empty_blocked(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"The repo has 0 open PRs."
)
result = assess_capability_stop_terminal_report(
report, trust_gate_status="untrusted_empty"
)
self.assertFalse(result["pure"])
def test_empty_queue_with_parsed_trusted_status_passes_gate_check(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"Repository: Scaled-Tech-Consulting/Gitea-Tools\n"
"pr_inventory_trust_gate.status: trusted_empty\n"
"pr_inventory_trust_gate.corroborated: true\n"
"Inventory profile: prgs-reviewer\n"
"No open PRs in queue."
)
result = assess_capability_stop_terminal_report(report)
self.assertTrue(result["pure"])
def test_pure_terminal_report_passes(self):
report = (
"Cannot perform reviewer task under current profile. "
"No reviewer mutations performed.\n"
"Identity: jcwalker3 / prgs-author.\n"
"Required: prgs-reviewer.\n"
"No mutations performed."
)
result = assess_capability_stop_terminal_report(report)
self.assertTrue(result["pure"])
if __name__ == "__main__":
unittest.main()
-154
View File
@@ -1,154 +0,0 @@
"""Tests for the gated PR close path (Issue #216).
``gitea_edit_pr(state='closed')`` requires the distinct ``gitea.pr.close``
capability: without it the close fails closed (no auth lookup, no API call,
structured permission report). With it the explicit operator-directed
contaminated-PR closure path the close proceeds and is audited as a
distinct ``close_pr`` action carrying the required capability, so final
reports can prove exactly which mutation capability was exercised.
Ordinary edits (title/body/base) and reopening never require the close
capability: PR comment, PR edit, and PR close remain distinct capabilities.
"""
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import gitea_edit_pr
FAKE_AUTH = "token fake"
AUTHOR_NO_CLOSE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
"gitea.branch.push", "gitea.issue.comment",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author",
}
AUTHOR_WITH_CLOSE = {
"profile_name": "prgs-author-closer",
"allowed_operations": AUTHOR_NO_CLOSE["allowed_operations"] + ["gitea.pr.close"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author-closer",
}
class TestEditPrCloseGate(unittest.TestCase):
def setUp(self):
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
# Deterministic permission reports: no real operator config, no switching.
patch("gitea_config.load_config", return_value={}).start()
patch("gitea_config.is_runtime_switching_enabled", return_value=False).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
mcp_server._IDENTITY_CACHE.clear()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
def _set_profile(self, profile):
patch("mcp_server.get_profile", return_value=profile).start()
def test_close_blocked_without_close_capability(self):
# Author profile without gitea.pr.close: the broad edit path must not
# be usable as an untracked close fallback.
self._set_profile(AUTHOR_NO_CLOSE)
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["requested_state"], "closed")
self.assertEqual(res["required_permission"], "gitea.pr.close")
self.assertTrue(res["reasons"])
report = res["permission_report"]
self.assertEqual(report["required_permission"], "gitea.pr.close")
self.assertEqual(report["active_profile"], "prgs-author")
self.mock_api.assert_not_called()
self.mock_auth.assert_not_called()
def test_close_blocked_when_close_forbidden(self):
profile = dict(AUTHOR_WITH_CLOSE)
profile["forbidden_operations"] = ["gitea.pr.close"]
self._set_profile(profile)
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
self.assertFalse(res["success"])
self.assertIn("profile forbids 'gitea.pr.close'", res["reasons"])
self.mock_api.assert_not_called()
def test_close_blocked_when_profile_unresolvable(self):
patch("mcp_server.get_profile", side_effect=RuntimeError("no profile")).start()
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
self.assertFalse(res["success"])
self.assertTrue(any("fail closed" in r for r in res["reasons"]))
self.mock_api.assert_not_called()
def test_operator_directed_close_allowed_and_audited(self):
# Explicit operator-directed contaminated-PR closure: the operator
# granted gitea.pr.close, so the close proceeds and the audit trail
# records a distinct close_pr action with the capability proof.
self._set_profile(AUTHOR_WITH_CLOSE)
patch("gitea_audit.audit_enabled", return_value=True).start()
mock_write = patch("gitea_audit.write_event").start()
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/user" in url:
return {"login": "jcwalker3"}
if method == "PATCH" and "pulls/205" in url:
self.assertEqual(payload["state"], "closed")
return {
"number": 205,
"title": "Contaminated PR",
"state": "closed",
"html_url": "url",
"body": "No issue link",
"head": {"ref": "feat/invalid-provenance"},
}
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_edit_pr(pr_number=205, state="closed", remote="prgs")
self.assertTrue(res["success"])
self.assertEqual(res["state"], "closed")
mock_write.assert_called()
event = mock_write.call_args[0][0]
self.assertEqual(event["action"], "close_pr")
self.assertEqual(
event["request_metadata"]["required_permission"], "gitea.pr.close")
def test_title_edit_needs_no_close_capability(self):
# PR edit and PR close are distinct capabilities: retitling stays on
# the ordinary edit path.
self._set_profile(AUTHOR_NO_CLOSE)
self.mock_api.return_value = {
"number": 7, "title": "Renamed", "state": "open",
"body": "", "html_url": "u"}
res = gitea_edit_pr(pr_number=7, title="Renamed", remote="prgs")
self.assertTrue(res["success"])
def test_reopen_needs_no_close_capability(self):
self._set_profile(AUTHOR_NO_CLOSE)
self.mock_api.return_value = {
"number": 7, "title": "T", "state": "open",
"body": "", "html_url": "u"}
res = gitea_edit_pr(pr_number=7, state="open", remote="prgs")
self.assertTrue(res["success"])
def test_invalid_state_fails_closed_before_auth(self):
# A case-variant state can neither bypass the close gate nor reach
# the API: it is rejected as pure validation, before auth.
self._set_profile(AUTHOR_WITH_CLOSE)
with self.assertRaises(ValueError):
gitea_edit_pr(pr_number=7, state="CLOSED", remote="prgs")
self.mock_api.assert_not_called()
self.mock_auth.assert_not_called()
if __name__ == "__main__":
unittest.main()
-199
View File
@@ -1,199 +0,0 @@
"""Tests for commit_files task capability resolver mapping (#262)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"commit-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "commit-author",
},
"pr-only-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "pr-author",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": ["gitea.repo.commit"],
"execution_profile": "pr-only-author",
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.merge"],
"forbidden_operations": [
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
],
"execution_profile": "reviewer-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
class CommitFilesCapabilityBase(unittest.TestCase):
def setUp(self):
self._preflight_snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._preflight_snapshot
)
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
class TestCommitFilesResolver(CommitFilesCapabilityBase):
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_resolves_to_repo_commit(self, _auth, _api):
with patch.dict(os.environ, self._env("commit-author"), clear=True):
for task in ("commit_files", "gitea_commit_files"):
with self.subTest(task=task):
res = mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs")
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "pr-author"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_create_pr_does_not_satisfy_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("pr-only-author"), clear=True):
commit_res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
pr_res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs")
self.assertFalse(commit_res["allowed_in_current_session"])
self.assertTrue(pr_res["allowed_in_current_session"])
self.assertEqual(commit_res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(pr_res["required_operation_permission"],
"gitea.pr.create")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_denied_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertTrue(res["different_mcp_namespace_required"])
class TestCommitFilesToolGate(CommitFilesCapabilityBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc"},
"branch": {"name": "feat/x"},
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("commit-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
new_branch="feat/x",
remote="prgs",
)
self.assertTrue(res["success"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_reviewer_blocked_with_permission_report(self, _auth, _role):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(res["permission_report"]["missing_permission"],
"gitea.repo.commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
def test_preflight_blocks_before_api(self, _role):
env = {**self._env("commit-author"), "GITEA_TEST_FORCE_DIRTY": "1"}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertIn("gitea_whoami", str(ctx.exception))
class TestCommitFilesMapAlignment(unittest.TestCase):
def test_tool_gate_matches_resolver(self):
self.assertEqual(
task_capability_map.tool_required_permission("gitea_commit_files"),
task_capability_map.required_permission("commit_files"),
)
if __name__ == "__main__":
unittest.main()
-251
View File
@@ -1,251 +0,0 @@
"""Regression tests: gitea_commit_files tool gates match resolver and whoami verification.
Covers Issue #262 requirements.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
import gitea_config
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.repo.commit"
],
"forbidden_operations": [],
"execution_profile": "full-author",
},
"reviewer-no-commit": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.approve"
],
"forbidden_operations": [
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
],
"execution_profile": "reviewer-no-commit",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestCommitFilesGate(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc123commit"},
"branch": {"name": "some-branch"},
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertTrue(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "abc123commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_denied_reviewer_blocked(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("reviewer-no-commit"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertFalse(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(
res["permission_report"]["missing_permission"], "gitea.repo.commit"
)
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_unknown_profile_fails_closed(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("non-existent"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
class TestPreflightCommitFilesGate(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
self.orig_whoami_files = mcp_server._preflight_whoami_violation_files
self.orig_capability_files = mcp_server._preflight_capability_violation_files
self.orig_reviewer_files = mcp_server._preflight_reviewer_violation_files
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
mcp_server._preflight_reviewer_violation_files = []
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
mcp_server._preflight_whoami_violation_files = self.orig_whoami_files
mcp_server._preflight_capability_violation_files = self.orig_capability_files
mcp_server._preflight_reviewer_violation_files = self.orig_reviewer_files
self._dir.cleanup()
os.environ.pop("GITEA_TEST_FORCE_DIRTY", None)
os.environ.pop("GITEA_TEST_PORCELAIN", None)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_preflight_not_called_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_PORCELAIN"] = ""
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_dirty_workspace_before_whoami_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Workspace file edits occurred before gitea_whoami verification", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
-251
View File
@@ -1,251 +0,0 @@
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import gitea_config
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
],
"execution_profile": "full-author",
},
},
}
class TestCommitPayloads(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
# Setup temp directories and lock files
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.locked_worktree_dir = tempfile.TemporaryDirectory()
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
self.lock_file_path = "/tmp/gitea_issue_lock.json"
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
if os.path.exists(self.lock_file_path):
os.remove(self.lock_file_path)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_content_plain(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-123"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello World!",
}
],
message="Add hello.txt",
remote="prgs",
)
print("DEBUG RES IS:", res)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "sha-123")
self.assertEqual(res["content_source_proof"][0]["source"], "inline_plain")
# Verify posted base64 content
post_args = mock_api.call_args[0]
payload = post_args[3]
self.assertEqual(payload["files"][0]["content"], "SGVsbG8gV29ybGQh")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_workspace_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-456"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
# Create a workspace file
file_relative = "src/code.py"
file_abs = os.path.join(self.locked_worktree_path, file_relative)
os.makedirs(os.path.dirname(file_abs), exist_ok=True)
with open(file_abs, "wb") as fh:
fh.write(b"print('hello')")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "src/code.py",
"workspace_path": file_relative,
}
],
message="Add code.py",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "workspace_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# "print('hello')" in base64 is cHJpbnQoJ2hlbGxvJyk=
self.assertEqual(payload["files"][0]["content"], "cHJpbnQoJ2hlbGxvJyk=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_local_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-789"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
file_abs = os.path.join(self.locked_worktree_path, "local.bin")
with open(file_abs, "wb") as fh:
fh.write(b"\x00\x01\x02\x03\xff")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "local.bin",
"local_path": file_abs,
}
],
message="Add local.bin",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "local_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# b"\x00\x01\x02\x03\xff" in base64 is AAECA/8=
self.assertEqual(payload["files"][0]["content"], "AAECA/8=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_traversal_blocked(self, _auth, mock_api):
# Remove active lock file to ensure it fails on traversal/invalid locks
os.remove(self.lock_file_path)
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "any.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("Issue lock is missing", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "../outside.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("falls outside of locked worktree", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello!",
"content": "SGVsbG8=",
}
],
message="Add hello",
remote="prgs",
)
self.assertIn("Multiple content sources specified", str(ctx.exception))
-18
View File
@@ -107,24 +107,6 @@ class TestGetCredentials(unittest.TestCase):
class TestGetAuthHeader(unittest.TestCase):
"""Test the get_auth_header function."""
def setUp(self):
self._saved_env = os.environ.copy()
self._saved_configs = gitea_auth.DYNAMIC_CONFIGS.copy()
for key in (
"GITEA_MCP_CONFIG",
"GITEA_MCP_PROFILE",
"GITEA_TOKEN",
"GITEA_TOKEN_PRGS",
):
os.environ.pop(key, None)
gitea_auth.DYNAMIC_CONFIGS.clear()
def tearDown(self):
os.environ.clear()
os.environ.update(self._saved_env)
gitea_auth.DYNAMIC_CONFIGS.clear()
gitea_auth.DYNAMIC_CONFIGS.update(self._saved_configs)
@patch("gitea_auth.get_credentials", return_value=("user", "pass"))
def test_returns_basic_header(self, _cred):
header = gitea_auth.get_auth_header("example.com")
@@ -1,140 +0,0 @@
"""Documentation checks for external Jenkins/GlitchTip MCP registration (#151/#152)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DOC = REPO_ROOT / "docs" / "mcp-client-registration.md"
def _doc_text():
assert DOC.is_file(), "missing docs/mcp-client-registration.md"
return DOC.read_text(encoding="utf-8")
def test_registration_doc_names_canonical_servers():
text = _doc_text()
assert "jenkins-mcp" in text
assert "glitchtip-mcp" in text
assert "Historical names" in text
assert "jenkins-readonly" in text
assert "glitchtip-readonly" in text
def test_registration_doc_lists_expected_tool_visibility():
text = _doc_text()
for tool in (
"jenkins_whoami",
"jenkins_list_jobs",
"jenkins_latest_build",
"jenkins_build_status",
"jenkins_get_build",
"glitchtip_whoami",
"glitchtip_list_projects",
"glitchtip_list_unresolved",
"glitchtip_get_issue",
"glitchtip_recent_events",
"glitchtip_search",
):
assert tool in text
def test_registration_doc_requires_reload_and_negative_case():
text = _doc_text()
lower = text.lower()
assert "reconnect" in lower
assert "reload" in lower
assert "enabled but no usable tools" in lower
assert "SKIPPED" in text
def test_registration_doc_preserves_boundaries():
text = " ".join(_doc_text().split())
for phrase in (
"Do not add Jenkins or GlitchTip credentials to the Gitea MCP server",
"do not add Gitea write credentials to the GlitchTip server",
"must not expose build trigger tools",
"jenkins-write-mcp",
"jenkins_mcp.write_server",
"remains read-only",
):
assert phrase in text
def test_registration_doc_pins_glitchtip_filing_boundary():
text = " ".join(_doc_text().split())
for phrase in (
"GlitchTip-to-Gitea filing is a separate library-only orchestrator in "
"`mcp-control-plane`",
"real GlitchTip read path",
"dedup before any Gitea create action",
"create/link/skip decisions",
"fail closed on mutation-audit failure before any Gitea create, link, "
"or comment mutation",
"must use a separate write-boundary server/profile",
"must not be exposed by `glitchtip-mcp`",
):
assert phrase in text
def test_registration_doc_separates_jenkins_trigger_from_read_surface():
text = _doc_text()
assert "jenkins_trigger_build" in text
assert "must **not** appear on `jenkins-mcp`" in text
assert "not" in text.lower() and "registered by default" in text.lower()
def test_registration_doc_has_no_secret_material_or_live_urls():
text = _doc_text()
for marker in (
"https://",
"http://",
"Authorization:",
"BEGIN PRIVATE KEY",
"ghp_",
"token-value",
):
assert marker not in text
def test_related_docs_link_registration_doc():
for name in (
"README.md",
"docs/llm-workflow-runbooks.md",
"docs/architecture/jenkins-readonly-build-status-design.md",
"docs/architecture/glitchtip-readonly-tools-design.md",
):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert "mcp-client-registration.md" in text, (
f"{name} does not link docs/mcp-client-registration.md")
def test_related_docs_keep_jenkins_trigger_off_read_surface():
for name in (
"docs/safety-model.md",
"docs/architecture/jenkins-readonly-build-status-design.md",
):
text = (REPO_ROOT / name).read_text(encoding="utf-8")
assert "jenkins-write-mcp" in text
assert "jenkins_mcp.write_server" in text
assert "jenkins-mcp" in text
def test_glitchtip_filing_contract_doc_covers_issue_153_acceptance():
text = (
REPO_ROOT / "docs" / "architecture" /
"glitchtip-to-gitea-workflow-design.md"
).read_text(encoding="utf-8")
flattened = " ".join(text.split())
for phrase in (
"**Status:** Contract for the library-only implementation in "
"`Scaled-Tech-Consulting/mcp-control-plane` (#57)",
"**Issue:** #153",
"not exposed through `glitchtip-mcp`",
"real GlitchTip read path",
"must not use mocked GlitchTip issue data",
"separate write-boundary server/profile",
"fail-closed mutation audit before create/link/comment actions",
"Deduplication before create",
"Create, link, and skip decisions must be represented in tests",
"Mutation audit fails closed",
):
assert phrase in flattened
-273
View File
@@ -1,273 +0,0 @@
"""Tests for composable final-report validator (#327)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import ( # noqa: E402
FINAL_REPORT_TASK_KINDS,
assess_final_report_validator,
validator_finding,
)
def _review_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reviewer",
"- Identity: sysadmin / prgs-reviewer",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Mutations: review only",
"- Workspace mutations: none",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Current status: PR open",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Scratch worktree used: yes (branches/review-203)",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
"- Linked issue: #182",
"- Linked issue status: open",
"- Cleanup status: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
def _reconcile_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: reconcile already-landed PR #99",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reconciler",
"- Identity: sysadmin / prgs-author",
"- Selected PR: #99",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- Linked issue: #98",
"- Linked issue live status: not verified in this session",
"- File edits by reconciler: none",
"- Git ref mutations: git fetch prgs master",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Safe next action: none",
"- No review/merge confirmation: confirmed",
]
text = "\n".join(lines)
for key, value in overrides.items():
if f"- {key}:" in text:
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
class TestValidatorFinding(unittest.TestCase):
def test_finding_shape(self):
finding = validator_finding(
"reviewer.test",
"block",
"Validation",
"missing proof",
"repair validation proof",
)
self.assertEqual(finding["rule_id"], "reviewer.test")
self.assertEqual(finding["severity"], "block")
self.assertEqual(finding["field"], "Validation")
class TestReviewPrRules(unittest.TestCase):
def test_clean_reviewer_report_passes(self):
result = assess_final_report_validator(
_review_handoff(),
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertEqual(result["grade"], "A")
self.assertFalse(result["blocked"])
self.assertEqual(result["findings"], [])
def test_legacy_workspace_mutations_blocks(self):
report = _review_handoff() + "\n- Workspace mutations: none"
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertEqual(result["grade"], "blocked")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.legacy_workspace_mutations", rule_ids)
def test_vague_mutations_none_blocks(self):
report = _review_handoff().replace(
"- Mutations: review only",
"- Mutations: none",
)
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.vague_mutations_none" for f in result["findings"])
)
def test_bare_pytest_without_cwd_downgrades(self):
report = _review_handoff().replace(
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: pytest passed",
)
result = assess_final_report_validator(report, "review_pr")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reviewer.validation_command_proof" for f in result["findings"])
)
def test_linked_issue_mismatch_blocks(self):
report = _review_handoff().replace("- Linked issue: #182", "- Linked issue: #999")
result = assess_final_report_validator(
report,
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.linked_issue_mismatch" for f in result["findings"])
)
def test_approval_after_full_suite_failure_blocks_without_baseline(self):
report = (
_review_handoff()
.replace("- Review decision: request_changes", "- Review decision: approve")
+ "\nSubmitted 'approve' after full suite failed with 3 failed tests."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.baseline_on_full_suite_failure"
for f in result["findings"]
)
)
def test_same_as_master_without_baseline_blocks(self):
report = _review_handoff() + "\nFailures are same as master baseline."
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.main_checkout_baseline" for f in result["findings"])
)
def test_already_landed_eligible_wording_blocks(self):
report = (
_review_handoff()
+ "\nPR is already landed and eligible for review next."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_eligible_wording"
for f in result["findings"]
)
)
def test_git_fetch_must_not_be_readonly_only(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
+ "\n- Read-only diagnostics: git fetch prgs master"
)
result = assess_final_report_validator(
report,
"review_pr",
action_log=[{"command": "git fetch prgs master", "performed": True}],
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.git_fetch_readonly" for f in result["findings"])
)
class TestReconciliationRules(unittest.TestCase):
def test_clean_reconcile_report_passes(self):
result = assess_final_report_validator(
_reconcile_handoff(),
"reconcile_already_landed",
)
self.assertEqual(result["grade"], "A")
def test_stale_author_field_blocks(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
for f in result["findings"]
)
)
def test_eligible_wording_for_landed_pr_blocks(self):
report = _reconcile_handoff() + "\nPR already landed and eligible for merge."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
def test_linked_issue_status_without_live_proof_downgrades(self):
report = _reconcile_handoff().replace(
"- Linked issue live status: not verified in this session",
"- Linked issue live status: closed",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.linked_issue_live_status" for f in result["findings"])
)
def test_inventory_complete_requires_pagination_proof(self):
report = _reconcile_handoff() + "\nInventory complete for open PRs."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.inventory_pagination_proof" for f in result["findings"])
)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
self.assertTrue(result["blocked"])
self.assertEqual(result["findings"][0]["rule_id"], "shared.unknown_task_kind")
def test_review_proofs_reexport(self):
from review_proofs import assess_final_report_validator as exported
self.assertTrue(callable(exported))
result = exported(_review_handoff(), "review_pr")
self.assertIn("grade", result)
def test_supported_task_kinds_include_review_and_reconcile(self):
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
if __name__ == "__main__":
unittest.main()
-101
View File
@@ -1,101 +0,0 @@
"""Git ref mutations must be reported, never hidden as diagnostics (#297).
``git fetch`` updates remote-tracking refs even though it edits no files.
Reports that ran fetch (or any ref-updating command) must carry a
``Git ref mutations`` entry and may not claim ``Git/worktree mutations:
None`` or classify the fetch as read-only diagnostics.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
def _assess(report, commands):
return review_proofs.assess_git_ref_mutation_report(
report, command_log=commands)
class TestGitRefMutationDetection(unittest.TestCase):
def test_fetch_only_review_with_proper_ledger_passes(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Review decision: APPROVE",
])
res = _assess(report, ["git fetch prgs master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], ["git fetch prgs master"])
def test_fetch_without_ref_mutation_line_is_blocked(self):
report = "Review decision: APPROVE\nMutations: None\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git ref mutations" in r.lower() for r in res["reasons"]))
def test_fetch_with_remote_branch_requires_fetched_target_in_report(self):
report = "Git ref mutations: some refs updated\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("prgs/master" in r for r in res["reasons"]))
def test_git_worktree_mutations_none_rejected_when_fetch_occurred(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Git/worktree mutations: None",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git/worktree mutations" in r.lower() for r in res["reasons"]))
def test_fetch_classified_as_read_only_diagnostics_rejected(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Read-only diagnostics: git fetch prgs master, git status",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("read-only" in r.lower() for r in res["reasons"]))
def test_worktree_creation_is_not_a_ref_mutation(self):
report = "Worktree mutations: created branches/review-pr9\n"
res = _assess(report, ["git worktree add branches/review-pr9 prgs/master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_merge_command_counts_as_ref_mutation(self):
report = "Review decision: APPROVE\n"
res = _assess(report, ["git merge --no-ff feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git merge --no-ff feat/x", res["ref_mutating_commands"])
def test_cleanup_branch_delete_counts_as_ref_mutation(self):
report = "Cleanup mutations: deleted branch feat/x\n"
res = _assess(report, ["git branch -d feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git branch -d feat/x", res["ref_mutating_commands"])
def test_no_mutation_blocked_handoff_passes(self):
report = "\n".join([
"Git/worktree mutations: None",
"Current status: blocked handoff, no mutations performed",
])
res = _assess(report, ["git status --porcelain", "git log --oneline -1"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_command_log_dict_entries_supported(self):
report = "Git ref mutations: fetched prgs/master\n"
res = _assess(report, [{"command": "git fetch prgs master"}])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(
res["ref_mutating_commands"], ["git fetch prgs master"])
if __name__ == "__main__":
unittest.main()
-235
View File
@@ -1,235 +0,0 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import capability_stop_terminal
import role_session_router
from role_session_router import python_bytes_have_conflict_markers
from mcp_server import gitea_route_task_session, gitea_resolve_task_capability
_HEALTH_SUBPROCESS_TIMEOUT_SEC = 30
def _health_test_python():
"""Portable interpreter for subprocess health checks (#245).
Prefer the active pytest interpreter, then ``GITEA_TOOLS_TEST_PYTHON``,
else skip never hard-code ``<repo>/venv/bin/python``.
"""
if (
sys.executable
and os.path.isfile(sys.executable)
and os.access(sys.executable, os.X_OK)
):
return sys.executable
override = (os.environ.get("GITEA_TOOLS_TEST_PYTHON") or "").strip()
if override and os.path.isfile(override) and os.access(override, os.X_OK):
return override
raise unittest.SkipTest(
"repo venv not available; run under a python interpreter or set "
"GITEA_TOOLS_TEST_PYTHON"
)
def _run_python_script(cwd, script_path, *, timeout=_HEALTH_SUBPROCESS_TIMEOUT_SEC):
"""Run a script in a child process with timeout and guaranteed teardown."""
python = _health_test_python()
proc = subprocess.Popen(
[python, script_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
raise
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
return proc.returncode, stdout, stderr, proc
class TestMCPHealth(unittest.TestCase):
def setUp(self):
self.project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.temp_file = os.path.join(self.project_root, "test_temp_conflict.py")
if os.path.exists(self.temp_file):
os.remove(self.temp_file)
def tearDown(self):
if os.path.exists(self.temp_file):
os.remove(self.temp_file)
def test_health_test_python_prefers_sys_executable(self):
resolved = _health_test_python()
self.assertEqual(resolved, sys.executable)
def test_health_test_python_skips_when_no_interpreter(self):
with patch.object(sys, "executable", ""), patch.dict(
os.environ, {}, clear=True
):
with self.assertRaises(unittest.SkipTest):
_health_test_python()
def test_conflict_marker_helper_ignores_decorative_equals_border(self):
sample = b'banner = """\n===========================================\n"""\n'
self.assertFalse(python_bytes_have_conflict_markers(sample))
def test_conflict_marker_helper_detects_real_markers(self):
sample = (
b"<" * 7 + b" HEAD\n"
b"print('hello')\n"
b"=" * 7 + b"\n"
b"print('world')\n"
b">" * 7 + b" main\n"
)
self.assertTrue(python_bytes_have_conflict_markers(sample))
def test_startup_conflict_detection(self):
# Create a Python file with conflict markers constructed dynamically
with open(self.temp_file, "w") as f:
f.write("<" * 7 + " HEAD\n")
f.write("print('hello')\n")
f.write("=" * 7 + "\n")
f.write("print('world')\n")
f.write(">" * 7 + " main\n")
script_path = os.path.join(self.project_root, "mcp_server.py")
returncode, _stdout, stderr, _proc = _run_python_script(
self.project_root, script_path
)
self.assertEqual(returncode, 1)
stderr_text = stderr.decode()
self.assertIn("infra_stop", stderr_text)
self.assertIn(
"Unresolved merge conflict detected in test_temp_conflict.py",
stderr_text,
)
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review"],
},
)
def test_route_task_session_blocks_during_merge(self, mock_profile, mock_assess):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": True,
"infra_stop_reasons": [
"mid-merge markers detected in /repo/.git/MERGE_HEAD (project_root=/repo)"
],
}
res = gitea_route_task_session("review_pr")
self.assertEqual(res["route_result"], "infra_stop")
self.assertIn("infra_stop", res["message"])
self.assertTrue(res.get("infra_stop_assessment", {}).get("infra_stop"))
mock_assess.assert_called()
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review"],
},
)
def test_resolve_task_capability_blocks_during_merge(
self, mock_profile, mock_assess
):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": "/repo/gitea_mcp_server.py",
"mid_merge": False,
"infra_stop_reasons": [
"conflict markers detected in /repo/gitea_mcp_server.py (project_root=/repo)"
],
}
res = gitea_resolve_task_capability("review_pr")
self.assertTrue(res["infra_stop"])
self.assertFalse(res["allowed_in_current_session"])
self.assertIn("infra_stop", res["exact_safe_next_action"])
self.assertEqual(
res["infra_stop_assessment"]["conflict_file"],
"/repo/gitea_mcp_server.py",
)
@patch("role_session_router.assess_infra_stop")
@patch("mcp_server._authenticated_username", return_value="reviewer-user")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review", "gitea.pr.approve"],
},
)
def test_resolve_task_capability_clears_stale_terminal_when_infra_clean(
self, mock_profile, _username, mock_assess
):
mock_assess.return_value = {
"infra_stop": False,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": False,
"infra_stop_reasons": [],
}
capability_stop_terminal.enter_from_capability_result({
"requested_task": "review_pr",
"required_role_kind": "reviewer",
"stop_required": True,
"active_profile": "prgs-reviewer",
"active_identity": "reviewer-user",
"exact_safe_next_action": "blocked",
})
self.assertTrue(capability_stop_terminal.is_active())
res = gitea_resolve_task_capability("review_pr", remote="prgs")
self.assertFalse(capability_stop_terminal.is_active())
self.assertTrue(res["allowed_in_current_session"])
class TestInfraStopLiveAssessment(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tempdir, ignore_errors=True)
def test_clean_project_root_is_not_infra_stop(self):
infra = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(infra["infra_stop"])
self.assertIsNone(infra["conflict_file"])
def test_conflict_present_then_resolved_recomputes_allowed(self):
conflict_path = os.path.join(self.tempdir, "conflicted.py")
with open(conflict_path, "wb") as handle:
handle.write(b"<<<<<<< HEAD\n")
blocked = role_session_router.assess_infra_stop(self.tempdir)
self.assertTrue(blocked["infra_stop"])
self.assertEqual(
os.path.realpath(blocked["conflict_file"]),
os.path.realpath(conflict_path),
)
os.remove(conflict_path)
cleared = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(cleared["infra_stop"])
self.assertIsNone(cleared["conflict_file"])
-102
View File
@@ -1,102 +0,0 @@
"""Identity-disclosure checks for workflow reports (#305).
Workflow reports sometimes included the authenticated user's personal
email even though username/profile identity is sufficient (observed:
``Identity: jcwalker3 / jcwalker3@yahoo.com`` in a reconciliation
handoff). These tests pin the no-email identity summary helper and the
final-report validator that flags unnecessary email disclosure.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
class TestIdentitySummary(unittest.TestCase):
def test_author_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author")
self.assertEqual(summary, "jcwalker3 / prgs-author")
self.assertNotIn("@", summary)
def test_reviewer_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"sysadmin", "prgs-reviewer")
self.assertEqual(summary, "sysadmin / prgs-reviewer")
def test_summary_appends_role_and_remote_without_email(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author", role="author", remote="prgs")
self.assertIn("jcwalker3 / prgs-author", summary)
self.assertIn("author", summary)
self.assertIn("prgs", summary)
self.assertNotIn("@", summary)
def test_summary_never_leaks_email_passed_as_username(self):
"""Defense in depth: an email in the username slot is reduced."""
summary = review_proofs.format_identity_summary(
"[email protected]", "prgs-author")
self.assertNotIn("@", summary)
self.assertIn("jcwalker3", summary)
class TestEmailDisclosureAssessment(unittest.TestCase):
def test_report_without_email_passes(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / prgs-author",
"Role: author",
])
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["proven"])
self.assertFalse(res["flagged"])
self.assertEqual(res["emails"], [])
self.assertEqual(res["reasons"], [])
def test_unnecessary_email_is_flagged(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / [email protected]",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["proven"])
self.assertTrue(res["flagged"])
self.assertIn("[email protected]", res["emails"])
self.assertTrue(res["reasons"])
self.assertFalse(res["justified"])
def test_multiple_emails_all_reported(self):
report = (
"Identity: [email protected] author\n"
"Reviewer: [email protected]\n"
)
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertEqual(
sorted(res["emails"]),
["[email protected]", "[email protected]"],
)
def test_justified_email_with_explanation_is_not_flagged(self):
report = "\n".join([
"Identity: jcwalker3 / prgs-author",
"Contact email: [email protected]",
"Email required because two accounts share the username and the",
"address is necessary to disambiguate identity.",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["flagged"])
self.assertTrue(res["justified"])
self.assertIn("[email protected]", res["emails"])
def test_email_without_justification_language_is_flagged(self):
report = "Contact email: [email protected] just in case.\n"
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertFalse(res["justified"])
if __name__ == "__main__":
unittest.main()
-179
View File
@@ -1,179 +0,0 @@
"""Tests for pre-create issue duplicate gate (Issue #207)."""
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from issue_duplicate_gate import ( # noqa: E402
VERDICT_AMBIGUOUS,
VERDICT_DUPLICATE,
VERDICT_NO_DUPLICATE,
assess_duplicate_search_proof,
assess_pre_create_duplicate,
normalize_issue_title,
pre_create_issue_duplicate_gate,
titles_near_duplicate,
)
from mcp_server import gitea_create_issue # noqa: E402
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
}
CANONICAL_TITLE = (
"Add hard queue-target resolution wall before PR inventory "
"or empty-queue claims"
)
class TestNormalizeAndNearDuplicate(unittest.TestCase):
def test_normalize_strips_punctuation_and_case(self):
norm = normalize_issue_title(" Hard-Wall: Queue Target! ")
self.assertEqual(norm, "hard wall queue target")
def test_exact_title_match(self):
self.assertTrue(titles_near_duplicate(CANONICAL_TITLE, CANONICAL_TITLE))
def test_punctuation_and_capitalization_only_differs(self):
proposed = CANONICAL_TITLE.upper().replace("-", "")
self.assertTrue(titles_near_duplicate(proposed, CANONICAL_TITLE))
def test_hard_wall_vs_wall_wording(self):
self.assertTrue(
titles_near_duplicate(
"Add hard wall before PR inventory",
"Add wall before PR inventory",
)
)
class TestPreCreateGate(unittest.TestCase):
def test_blocks_exact_duplicate_title(self):
existing = [{"number": 201, "title": CANONICAL_TITLE, "state": "open"}]
gate = assess_pre_create_duplicate(CANONICAL_TITLE, existing)
self.assertEqual(gate["verdict"], VERDICT_DUPLICATE)
self.assertFalse(gate["performed"])
self.assertEqual(gate["matches"][0]["number"], 201)
def test_final_pre_create_check_blocks_even_if_llm_search_missed(self):
"""TOCTOU: gate re-queries at create time with the live issue list."""
stale_report = (
"Searched 20 open issues (#194, #196, PR #195); no duplicate found."
)
live_existing = [{"number": 201, "title": CANONICAL_TITLE, "state": "open"}]
proof = assess_duplicate_search_proof(stale_report, live_existing)
self.assertFalse(proof["valid"])
gate = assess_pre_create_duplicate(CANONICAL_TITLE, live_existing)
self.assertFalse(gate["performed"])
def test_invalid_proof_when_summary_omits_exact_duplicate(self):
report = "Reviewed nearby issues #194, #196, and PR #195; no duplicate."
matches = [{"number": 201, "title": CANONICAL_TITLE}]
result = assess_duplicate_search_proof(report, matches)
self.assertFalse(result["valid"])
self.assertTrue(any("201" in r for r in result["reasons"]))
def test_operator_split_override_allowed_with_relationship(self):
existing = [{"number": 201, "title": CANONICAL_TITLE, "state": "open"}]
gate = pre_create_issue_duplicate_gate(
CANONICAL_TITLE,
existing,
allow_override=True,
split_from_issue=201,
)
self.assertEqual(gate["verdict"], VERDICT_NO_DUPLICATE)
self.assertTrue(gate["performed"])
self.assertTrue(gate.get("override_applied"))
def test_ambiguous_when_too_many_near_matches(self):
base = "Add hard wall before PR inventory"
existing = [
{"number": n, "title": f"{base} variant {n}", "state": "open"}
for n in range(1, 6)
]
gate = assess_pre_create_duplicate(base, existing)
self.assertEqual(gate["verdict"], VERDICT_AMBIGUOUS)
self.assertFalse(gate["performed"])
def test_no_duplicate_when_unique_title(self):
gate = assess_pre_create_duplicate(
"Brand new unique issue title",
[{"number": 1, "title": "Unrelated backlog cleanup", "state": "open"}],
)
self.assertEqual(gate["verdict"], VERDICT_NO_DUPLICATE)
self.assertTrue(gate["performed"])
class TestCreateIssueMCPGate(unittest.TestCase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_blocks_normalized_title_match_without_override(
self, _auth, mock_get_all, mock_api, mock_role_check
):
mock_role_check.return_value = (True, [])
mock_get_all.return_value = [
{"number": 201, "title": CANONICAL_TITLE, "state": "open"},
]
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(title=CANONICAL_TITLE.upper(), remote="prgs")
self.assertFalse(result["performed"])
self.assertEqual(result["duplicate_gate"], VERDICT_DUPLICATE)
mock_api.assert_not_called()
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_override_creates_with_split_relationship_in_body(
self, _auth, mock_get_all, mock_api, mock_role_check
):
mock_role_check.return_value = (True, [])
mock_get_all.return_value = [
{"number": 201, "title": CANONICAL_TITLE, "state": "open"},
]
mock_api.return_value = {"number": 210, "html_url": "https://gitea.prgs.cc/issues/210"}
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(
title=CANONICAL_TITLE,
body="Follow-up scope",
remote="prgs",
allow_duplicate_override=True,
split_from_issue=201,
)
self.assertEqual(result["number"], 210)
payload = mock_api.call_args[0][3]
self.assertIn("Operator-approved split from #201.", payload["body"])
self.assertIn("Follow-up scope", payload["body"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop")
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_when_no_duplicate(
self, _auth, _get_all, mock_api, mock_role_check
):
mock_role_check.return_value = (True, [])
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(title="Unique new issue", body="body text")
self.assertEqual(result["number"], 1)
class TestReviewProofsWrapper(unittest.TestCase):
def test_review_proofs_reexports_duplicate_search_validator(self):
report = "Checked open issues; nothing similar."
matches = [{"number": 200, "title": CANONICAL_TITLE}]
result = proof_assess(report, matches)
self.assertFalse(result["valid"])
if __name__ == "__main__":
unittest.main()
-124
View File
@@ -1,124 +0,0 @@
"""Tests for issue-lock worktree validation (#249)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import issue_lock_worktree # noqa: E402
class TestIssueLockWorktreeAssessment(unittest.TestCase):
def test_clean_base_branch_passes(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="master",
porcelain_status="",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_dirty_tracked_files_fail(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="master",
porcelain_status=" M gitea_mcp_server.py\n",
)
self.assertFalse(result["proven"])
self.assertIn("tracked file edits exist before issue lock", result["reasons"][0])
def test_feature_branch_fails(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="feat/issue-243-forbidden-git-gaps",
porcelain_status="",
)
self.assertFalse(result["proven"])
self.assertIn("base-equivalence could not be proven", result["reasons"][0])
def test_base_equivalent_feature_branch_passes(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=True,
inspected_git_root="/repo/branches/issue-275",
base_branch="origin/master",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
self.assertEqual(result["base_branch"], "origin/master")
def test_non_base_equivalent_branch_fails(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=False,
inspected_git_root="/repo/branches/issue-275",
base_branch=None,
)
self.assertFalse(result["proven"])
self.assertIn("must be base-equivalent", result["reasons"][0])
def test_untracked_files_do_not_block(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/scratch/wt",
current_branch="main",
porcelain_status="?? notes.txt\n",
)
self.assertTrue(result["proven"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
"/tmp/scratch/wt", "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/tmp/scratch/wt"))
def test_env_var_when_explicit_missing(self):
with patch.dict(os.environ, {"GITEA_AUTHOR_WORKTREE": "/tmp/from-env"}):
resolved = issue_lock_worktree.resolve_author_worktree_path(
None, "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/tmp/from-env"))
def test_project_root_fallback(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
None, "/shared/dev"
)
self.assertEqual(resolved, os.path.realpath("/shared/dev"))
class TestPrWorktreeMatch(unittest.TestCase):
def test_matching_paths_pass(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
"/tmp/scratch/wt",
"/tmp/scratch/wt",
"/shared/dev",
)
self.assertTrue(result["proven"])
def test_mismatch_fails(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
"/tmp/scratch/wt",
"/shared/dev",
"/shared/dev",
)
self.assertFalse(result["proven"])
self.assertIn("does not match locked worktree", result["reasons"][0])
def test_missing_locked_path_skips_check(self):
result = issue_lock_worktree.verify_pr_worktree_matches_lock(
None,
"/any/path",
"/shared/dev",
)
self.assertTrue(result["proven"])
if __name__ == "__main__":
unittest.main()
-210
View File
@@ -1,210 +0,0 @@
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"comment-only": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "commenter",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": [
"gitea.issue.create", "gitea.issue.close"],
"execution_profile": "comment-only",
},
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.close",
"gitea.issue.comment"],
"forbidden_operations": [],
"execution_profile": "full-author",
},
},
"rules": {"allow_runtime_switching": False},
}
ISSUE_WRITE_TASKS = (
"create_issue", "close_issue", "comment_issue", "mark_issue",
"set_issue_labels",
)
TOOL_CALLS = {
"create_issue": lambda: mcp_server.gitea_create_issue(
title="New issue", remote="prgs"),
"close_issue": lambda: mcp_server.gitea_close_issue(
issue_number=9, remote="prgs"),
"comment_issue": lambda: mcp_server.gitea_create_issue_comment(
issue_number=9, body="hello", remote="prgs"),
"mark_issue": lambda: mcp_server.gitea_mark_issue(
issue_number=9, action="start", remote="prgs"),
"set_issue_labels": lambda: mcp_server.gitea_set_issue_labels(
issue_number=9, labels=["bug"], remote="prgs"),
}
class IssueWriteGateBase(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
class TestResolverToolAlignment(unittest.TestCase):
def test_issue_mutation_tools_match_resolver_permissions(self):
for tool_name, task_key in (
task_capability_map.ISSUE_MUTATION_TOOL_TASKS.items()
):
self.assertEqual(
task_capability_map.tool_required_permission(tool_name),
task_capability_map.required_permission(task_key),
msg=f"{tool_name} gate drift from resolver task {task_key}",
)
class TestDeniedIssueWritesFailClosed(IssueWriteGateBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolver_denied_tasks_block_raw_tools(
self, _auth, mock_api, _get_all, _role
):
mock_api.return_value = {"number": 1}
with patch.dict(os.environ, self._env("comment-only"), clear=True):
for task in ISSUE_WRITE_TASKS:
with self.subTest(task=task):
resolve = mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs")
if resolve["allowed_in_current_session"]:
continue
result = TOOL_CALLS[task]()
self.assertFalse(result.get("success", True), task)
self.assertFalse(result.get("performed", True), task)
self.assertTrue(result.get("reasons"), task)
self.assertIn("permission_report", result, task)
self.assertEqual(
result["permission_report"]["missing_permission"],
resolve["required_operation_permission"],
)
class TestAllowedIssueWritesProceed(IssueWriteGateBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolver_allowed_create_issue_proceeds(
self, _auth, mock_api, _get_all, _role
):
mock_api.return_value = {"number": 42, "html_url": "https://x/42"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs")
self.assertTrue(resolve["allowed_in_current_session"])
result = mcp_server.gitea_create_issue(title="Allowed", remote="prgs")
self.assertEqual(result.get("number"), 42)
post_calls = [
c for c in mock_api.call_args_list if c.args[0] == "POST"
]
self.assertTrue(post_calls)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolver_allowed_close_issue_proceeds(self, _auth, mock_api):
mock_api.return_value = {"state": "closed"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="close_issue", remote="prgs")
self.assertTrue(resolve["allowed_in_current_session"])
result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs")
self.assertTrue(result.get("success"))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and url.endswith("/labels?limit=100"):
return [{"id": 10, "name": "status:in-progress"}]
if method == "POST" and "/issues/9/labels" in url:
return [{"name": "status:in-progress"}]
if method == "GET" and url.endswith("/user"):
return {"login": "author-user"}
return {}
mock_api.side_effect = api_side_effect
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="mark_issue", remote="prgs")
self.assertTrue(resolve["allowed_in_current_session"])
result = mcp_server.gitea_mark_issue(
issue_number=9, action="start", remote="prgs")
self.assertTrue(result.get("success"))
class TestCreateIssueMissingPermissionReport(IssueWriteGateBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_create_issue_without_create_permission_blocked(
self, _auth, mock_api, _get_all, _role
):
with patch.dict(os.environ, self._env("comment-only"), clear=True):
result = mcp_server.gitea_create_issue(title="Blocked", remote="prgs")
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIsNone(result["number"])
self.assertEqual(
result["permission_report"]["missing_permission"],
"gitea.issue.create",
)
mock_api.assert_not_called()
if __name__ == "__main__":
unittest.main()
-118
View File
@@ -1,118 +0,0 @@
"""Tests for linked-issue consistency verifier (#314).
Reviewer reports must verify the linked issue live for the selected PR;
stale issue numbers from a previous PR must not leak into the final
report, and linked-issue status must never be claimed without live proof.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_linked_issue_consistency # noqa: E402
class TestCorrectLinkedIssue(unittest.TestCase):
def test_matching_linked_issue_passes(self):
report = (
"Selected PR: 280\n"
"Linked issue status: Issue #275 open, closes on merge\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
self.assertFalse(result["downgraded"])
self.assertEqual(result["reasons"], [])
def test_multiple_linked_issues_all_reported_passes(self):
report = (
"Linked issue status: Issue #275 and Issue #276 both open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertTrue(result["complete"])
def test_multiple_linked_issues_one_missing_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("276" in r for r in result["reasons"])
)
class TestStaleIssueLeak(unittest.TestCase):
def test_stale_issue_in_status_field_fails(self):
# #314 observed behavior: PR #280 links Issue #275 but the report
# carries Issue #260 from the previous PR.
report = "Linked issue status: Issue #260 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(any("260" in r for r in result["reasons"]))
def test_stale_issue_in_diagnostics_fails(self):
report = (
"Linked issue status: Issue #275 open\n"
"Read-only diagnostics: viewed Issue #260 status\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("stale" in r.lower() and "260" in r for r in result["reasons"])
)
def test_selected_pr_number_mention_is_not_stale(self):
# "PR #280" mentions must not be confused with issue mentions.
report = (
"Selected PR: 280\n"
"Validation: ran tests at PR #280 head\n"
"Linked issue status: Issue #275 open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
class TestMissingProof(unittest.TestCase):
def test_status_claim_without_live_proof_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertFalse(result["complete"])
self.assertTrue(
any("live proof" in r.lower() for r in result["reasons"])
)
def test_unverified_wording_without_proof_passes(self):
report = (
"Linked issue status: not verified in this session\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertTrue(result["complete"])
def test_missing_field_fails(self):
report = "Selected PR: 280\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("linked issue status" in r.lower() for r in result["reasons"])
)
if __name__ == "__main__":
unittest.main()
-153
View File
@@ -1,153 +0,0 @@
"""Tests for gitea_list_prs pagination metadata (#340)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_auth # noqa: E402
from mcp_server import gitea_list_prs # noqa: E402
from review_proofs import assess_list_prs_pagination_proof # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
URL = "https://gitea.example.com/api/v1/repos/o/r/pulls?state=open"
SAMPLE_PR = {
"number": 1,
"title": "PR 1",
"state": "open",
"head": {"ref": "branch1"},
"base": {"ref": "master"},
"mergeable": True,
"updated_at": "2026-07-05T12:00:00Z",
}
class TestApiFetchPage(unittest.TestCase):
@patch("gitea_auth.api_request")
def test_final_page_metadata(self, mock_req):
mock_req.return_value = [SAMPLE_PR]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=50)
self.assertEqual(len(items), 1)
self.assertFalse(meta["has_more"])
self.assertTrue(meta["is_final_page"])
self.assertIsNone(meta["next_page"])
@patch("gitea_auth.api_request")
def test_full_page_has_more(self, mock_req):
mock_req.return_value = [{"id": i} for i in range(2)]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=2)
self.assertEqual(len(items), 2)
self.assertTrue(meta["has_more"])
self.assertFalse(meta["is_final_page"])
self.assertEqual(meta["next_page"], 2)
class TestGiteaListPrsPagination(unittest.TestCase):
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_explicit_page_returns_has_more(self, _auth, mock_fetch):
mock_fetch.return_value = (
[SAMPLE_PR],
{
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
)
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(page=1, per_page=50)
self.assertEqual(len(result["prs"]), 1)
self.assertFalse(result["pagination"]["inventory_complete"])
self.assertTrue(result["pagination"]["is_final_page"])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_multi_page_fetch_all_marks_inventory_complete(self, _auth, mock_fetch):
mock_fetch.side_effect = [
(
[dict(SAMPLE_PR, number=i) for i in (1, 2)],
{
"page": 1,
"per_page": 2,
"returned_count": 2,
"has_more": True,
"next_page": 2,
"is_final_page": False,
},
),
(
[dict(SAMPLE_PR, number=3)],
{
"page": 2,
"per_page": 2,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
),
]
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(per_page=2)
self.assertEqual([p["number"] for p in result["prs"]], [1, 2, 3])
self.assertTrue(result["pagination"]["inventory_complete"])
self.assertEqual(result["pagination"]["pages_fetched"], 2)
self.assertEqual(result["pagination"]["total_count"], 3)
class TestListPrsPaginationProof(unittest.TestCase):
def test_wrapped_final_page_passes_completeness_claim(self):
response = {
"prs": [],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
"inventory_complete": True,
"pages_fetched": 1,
"total_count": 0,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertTrue(result["proven"])
def test_bare_list_blocks_completeness_claim(self):
result = assess_list_prs_pagination_proof(
[{"number": 1}], inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
self.assertIn("pagination metadata", " ".join(result["reasons"]))
def test_single_page_without_complete_blocks_claim(self):
response = {
"prs": [{"number": 1}],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": True,
"next_page": 2,
"is_final_page": False,
"inventory_complete": False,
"pages_fetched": 1,
"total_count": None,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
if __name__ == "__main__":
unittest.main()
+3 -6
View File
@@ -125,17 +125,14 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
mock_api.side_effect = [
{"login": "jcwalker3"}, # /user (inventory)
{"login": "jcwalker3"}, # /user (submit eligibility)
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
{"login": "jcwalker3"}, # /user (eligibility)
{"state": "open", "head": {"sha": "abc1234"}, "mergeable": True, "user": {"login": "jcwalker3"}} # /pulls/9 (eligibility)
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(9, "approve", remote="prgs")
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
with patch.dict(os.environ, env, clear=True):
r = gitea_review_pr(
pr_number=9, event="APPROVE", body="self approve", merge=False,
remote="prgs", final_review_decision_ready=True)
remote="prgs")
self.assertFalse(r["success"])
self.assertIn("authenticated user is PR author", r["message"])
for call in mock_api.call_args_list:
-148
View File
@@ -1,148 +0,0 @@
"""Doc-contract checks for task-specific LLM workflow split (#333, #334)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_DIR = REPO_ROOT / "skills" / "llm-project-workflow"
SKILL = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
def test_skill_md_exists():
assert SKILL_DIR.joinpath("SKILL.md").is_file()
def test_all_workflow_files_exist():
for name in (
"review-merge-pr.md",
"reconcile-landed-pr.md",
"create-issue.md",
"work-issue.md",
):
assert (SKILL_DIR / "workflows" / name).is_file(), name
def test_skill_references_all_workflow_files():
for name in (
"workflows/review-merge-pr.md",
"workflows/reconcile-landed-pr.md",
"workflows/create-issue.md",
"workflows/work-issue.md",
):
assert name in SKILL
def test_skill_contains_mode_isolation_language():
assert "## Mode isolation" in SKILL
assert "review-merge-pr" in SKILL
assert "reconcile-landed-pr" in SKILL
assert "create-issue" in SKILL
assert "work-issue" in SKILL
assert "Do not mix modes" in SKILL or "do not mix modes" in SKILL.lower()
def test_skill_is_router_not_monolithic_review_body():
assert "This skill is a **router**" in SKILL or "router" in SKILL.lower()
assert "## F. Review workflow" not in SKILL
assert "gitea_mark_final_review_decision" not in SKILL
assert "gitea_submit_pr_review" not in SKILL
def test_skill_still_declares_controller_handoff_contract():
assert "## Controller Handoff" in SKILL
assert "assess_controller_handoff" in SKILL
assert "## Global LLM Worktree Rule" in SKILL
def test_review_merge_workflow_contract():
text = (SKILL_DIR / "workflows" / "review-merge-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 0. Load the canonical workflow first" in text
assert "## 26A. Terminal review mutation hard-stop" in text
assert "## 11A. Skipped PRs are read-only" in text
assert "## 35. Duplicate request-changes prevention" in text
assert "## 37. Controller handoff schema" in text
assert "INVENTORY_PAGINATION_UNPROVEN" in text
assert "ALREADY_LANDED_RECONCILE_REQUIRED" in text
def test_review_merge_final_report_schema_exists():
path = SKILL_DIR / "schemas" / "review-merge-final-report.md"
text = path.read_text(encoding="utf-8")
assert "Controller Handoff" in text
assert "Candidate head SHA:" in text
assert "Terminal review mutation:" in text
assert "Workspace mutations" in text
def test_reconcile_landed_workflow_contract():
text = (SKILL_DIR / "workflows" / "reconcile-landed-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "Do not review or merge normal PRs" in text
assert "Already-landed proof" in text
def test_create_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "create-issue.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 9. Duplicate search before mutation" in text
def test_work_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "work-issue.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "Do not merge your own PR" in text
assert "## 21. PR creation rules" in text
def test_final_report_schemas_exist():
for name in (
"review-merge-final-report.md",
"reconcile-landed-final-report.md",
"create-issue-final-report.md",
"work-issue-final-report.md",
):
assert (SKILL_DIR / "schemas" / name).is_file(), name
def test_review_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "review-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_merge_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "merge-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_start_issue_template_references_work_issue_workflow():
text = (SKILL_DIR / "templates" / "start-issue.md").read_text(encoding="utf-8")
assert "workflows/work-issue.md" in text
def test_reviewer_fallback_verifier_exported():
from review_proofs import assess_reviewer_fallback_report
assert callable(assess_reviewer_fallback_report)
def test_final_report_validator_exported():
from review_proofs import assess_final_report_validator
assert callable(assess_final_report_validator)
def test_create_issue_final_report_verifier_exported():
from review_proofs import assess_create_issue_final_report
assert callable(assess_create_issue_final_report)
def test_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
assert callable(assess_prior_blocker_skip_proof)
def test_non_mergeable_skip_verifier_exported():
from review_proofs import assess_non_mergeable_skip_proof
assert callable(assess_non_mergeable_skip_proof)
-260
View File
@@ -1,260 +0,0 @@
"""Tests for MCP discoverability validation (Issue #155)."""
import os
import sys
import json
import tempfile
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_discoverability import (
validate_mcp_client_config,
RELOAD_INSTRUCTIONS,
EXPECTED_JENKINS_TOOLS,
EXPECTED_GLITCHTIP_TOOLS,
)
class TestMcpDiscoverability(unittest.TestCase):
def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.gitea_config_path = os.path.join(self.tmp_dir.name, "gitea-mcp.json")
self.client_config_path = os.path.join(self.tmp_dir.name, "claude_desktop_config.json")
def tearDown(self):
self.tmp_dir.cleanup()
def _write_json(self, path, data):
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh)
def _v2_gitea_config(self, jenkins_enabled=True, glitchtip_enabled=False):
return {
"version": 2,
"contexts": {
"prod": {
"enabled": True,
"services": {
"jenkins": {
"enabled": jenkins_enabled,
"kind": "jenkins",
"capabilities": ["read"]
},
"glitchtip": {
"enabled": glitchtip_enabled,
"kind": "glitchtip",
"capabilities": ["read"]
}
}
}
}
}
def test_static_validation_success(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True, glitchtip_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
},
"glitchtip-mcp": {
"command": "python3",
"args": ["-m", "glitchtip_mcp"],
"env": {
"GLITCHTIP_MCP_PROFILE": "glitchtip-readonly",
"GLITCHTIP_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertTrue(res)
def test_stale_server_name_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Uses stale key in client config
c_cfg = {
"mcpServers": {
"jenkins-readonly": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
def test_incorrect_arguments_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Missing -m or wrong module
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["wrong_runner.py"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
def test_missing_required_env_rejected(self):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
# Missing JENKINS_MCP_PROFILE env variable
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=False
)
self.assertFalse(res)
@patch("subprocess.Popen")
def test_live_check_verification_success(self, mock_popen):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
# Mock stdout lines for JSON-RPC
mock_proc = MagicMock()
mock_popen.return_value = mock_proc
init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2024-11-05"}})
tools_resp = json.dumps({
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [{"name": tool} for tool in EXPECTED_JENKINS_TOOLS]
}
})
mock_proc.stdout.readline.side_effect = [
init_resp + "\n",
tools_resp + "\n",
""
]
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=True
)
self.assertTrue(res)
@patch("subprocess.Popen")
def test_live_check_empty_toolset_rejected(self, mock_popen):
g_cfg = self._v2_gitea_config(jenkins_enabled=True)
self._write_json(self.gitea_config_path, g_cfg)
c_cfg = {
"mcpServers": {
"jenkins-mcp": {
"command": "python3",
"args": ["-m", "jenkins_mcp"],
"env": {
"JENKINS_MCP_PROFILE": "jenkins-readonly",
"JENKINS_MCP_CONFIG": "/path/to/config.json"
}
}
}
}
self._write_json(self.client_config_path, c_cfg)
mock_proc = MagicMock()
mock_popen.return_value = mock_proc
init_resp = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}})
tools_resp = json.dumps({
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [] # empty tool list
}
})
mock_proc.stdout.readline.side_effect = [
init_resp + "\n",
tools_resp + "\n",
""
]
# Should return False (coverage fails) when no tools are exposed
res = validate_mcp_client_config(
client_config_path=self.client_config_path,
gitea_config_path=self.gitea_config_path,
live=True
)
self.assertFalse(res)
def test_runbook_instructions_content(self):
self.assertIn("MCP CLIENT RELOAD/RECONNECT RUNBOOK", RELOAD_INSTRUCTIONS)
self.assertIn("Codex", RELOAD_INSTRUCTIONS)
self.assertIn("Gemini", RELOAD_INSTRUCTIONS)
self.assertIn("Claude Desktop", RELOAD_INSTRUCTIONS)
if __name__ == "__main__":
unittest.main()
+164 -1107
View File
File diff suppressed because it is too large Load Diff
-159
View File
@@ -1,159 +0,0 @@
"""Tests for merged PR cleanup reconciliation (#269)."""
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import merged_cleanup_reconcile as mcr # noqa: E402
class TestMergedCleanupAssessment(unittest.TestCase):
def test_extract_linked_issue_from_closes(self):
issue = mcr.extract_linked_issue(
"feat: cleanup (Closes #269)",
"No other refs",
)
self.assertEqual(issue, 269)
def test_merged_remote_branch_safe_when_gates_pass(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-269-merged-pr-cleanup-reconcile",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertTrue(result["safe_to_delete_remote"])
self.assertEqual(result["block_reasons"], [])
def test_open_pr_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads={"feat/issue-9-example"},
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("open PR", result["block_reasons"][0])
def test_protected_branch_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=1,
head_branch="master",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("protected", result["block_reasons"][0])
def test_active_lock_blocks_remote_and_worktree_cleanup(self):
remote = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=True,
)
local = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=True,
)
self.assertFalse(remote["safe_to_delete_remote"])
self.assertFalse(local["safe_to_remove_worktree"])
self.assertIn("active issue lock", remote["block_reasons"][0])
def test_dirty_worktree_blocks_local_cleanup(self):
result = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": False,
"dirty_files": ["gitea_mcp_server.py"],
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=False,
)
self.assertFalse(result["safe_to_remove_worktree"])
self.assertIn("tracked edits", result["block_reasons"][0])
class TestMergedCleanupReport(unittest.TestCase):
def test_build_report_skips_unmerged_closed_prs(self):
with tempfile.TemporaryDirectory() as tmp:
report = mcr.build_reconciliation_report(
project_root=tmp,
closed_prs=[
{
"number": 10,
"title": "closed not merged",
"body": "Closes #10",
"head": {"ref": "feat/issue-10-x"},
"merged_at": None,
},
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
},
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=False,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
self.assertEqual(entry["pr_number"], 11)
self.assertEqual(entry["issue_number"], 11)
self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"])
self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0])
def test_has_active_issue_lock_reads_lock_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
handle.write('{"branch_name": "feat/issue-9-example"}')
lock_path = handle.name
try:
self.assertTrue(
mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path)
)
self.assertFalse(
mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path)
)
finally:
os.remove(lock_path)
if __name__ == "__main__":
unittest.main()
-102
View File
@@ -1,102 +0,0 @@
"""Tests for final-report mutation ledger verifier (#331)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_mutation_ledger_report # noqa: E402
class TestMutationLedgerReport(unittest.TestCase):
def test_none_claim_with_performed_edit_blocks(self):
report = (
"Review decision: request_changes.\n"
"File edits by reviewer: none\n"
)
action_log = [
{"action": "Edited", "path": "walkthrough.md", "tracked": False},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertTrue(result["file_edits_claimed_none"])
def test_reported_edit_passes(self):
report = (
"File edits by reviewer: Edited walkthrough.md (untracked)\n"
"Mutation ledger: Edited walkthrough.md untracked in review worktree\n"
)
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"tracked": False,
"in_repo": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
walkthrough_explicitly_requested=True,
)
self.assertTrue(result["proven"])
def test_unreported_mutation_blocks(self):
report = "File edits by reviewer: none\n"
action_log = [{"action": "Wrote", "path": "/tmp/review-notes.txt"}]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("/tmp/review-notes.txt", result["unreported_paths"])
def test_outside_repo_requires_label(self):
report = (
"File edits by reviewer: Wrote /tmp/review-notes.txt\n"
)
action_log = [
{
"action": "Wrote",
"path": "/tmp/review-notes.txt",
"outside_repo": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("outside repo", " ".join(result["reasons"]).lower())
def test_gated_rejection_excluded_from_performed(self):
report = "File edits by reviewer: none\nRejected gated calls: submit_pr_review blocked\n"
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"performed": False,
"gated_rejected": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertTrue(result["proven"])
self.assertEqual(result["performed_mutations"], [])
def test_post_status_artifact_requires_final_git_status(self):
report = (
"File edits by reviewer: Created scratch/notes.md (untracked)\n"
)
action_log = [
{
"action": "Created",
"path": "scratch/notes.md",
"tracked": False,
"after_git_status": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
final_git_status_reported=False,
)
self.assertFalse(result["proven"])
self.assertIn("final git status", " ".join(result["reasons"]).lower())
if __name__ == "__main__":
unittest.main()
-181
View File
@@ -1,181 +0,0 @@
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
import os
import sys
import json
import tempfile
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import gitea_auth
import mcp_server
from reviewer_worktree import assess_author_worktree_continuity
CONFIG_TEST = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {
"enabled": True,
"base_url": "https://gitea.example.com"
}
}
},
"profiles": {
"author-profile": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"execution_profile": "author-profile"
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"execution_profile": "reviewer-profile"
}
}
}
class TestOperationScopedRoles(unittest.TestCase):
def setUp(self):
self._remotes_patch = patch.dict(mcp_server.REMOTES, {
"dadeschools": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
"prgs": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"}
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
self._write_config(CONFIG_TEST)
def tearDown(self):
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
mcp_server._MUTATION_AUTHORITY = None
self._dir.cleanup()
def _write_config(self, obj):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(obj))
def _env(self, profile="author-profile", with_reviewer_token=True):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
if with_reviewer_token:
env["GITEA_TOKEN_REVIEWER"] = "reviewer-pass"
return env
@patch("mcp_server.api_request")
def test_auto_switch_to_reviewer(self, mock_api):
# mock identity resolution to return username matching profile
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# initially we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it automatically switched to reviewer-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "reviewer-profile")
self.assertEqual(res["active_identity"], "reviewer-user")
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
@patch("mcp_server.api_request")
def test_auto_switch_to_author(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("reviewer-profile")):
# initially we are reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
# resolve an author task (create_issue)
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
# verify it automatically switched to author-profile
self.assertTrue(res["allowed_in_current_session"])
self.assertTrue(res["available_in_session"])
self.assertEqual(res["active_profile"], "author-profile")
self.assertEqual(res["active_identity"], "author-user")
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
@patch("mcp_server.api_request")
def test_restart_required_when_unattached(self, mock_api):
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
# launch without reviewer token in env
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# resolve a reviewer task (review_pr)
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
# verify it did NOT switch and reports restart_required
self.assertFalse(res["allowed_in_current_session"])
self.assertFalse(res["available_in_session"])
self.assertTrue(res["configured"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["stop_required"])
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
def test_author_continuity_dirty_worktree(self):
# author is allowed to keep dirty worktree
res = assess_author_worktree_continuity({
"task_role": "author",
"dirty_files": ["review_proofs.py"],
})
self.assertTrue(res["allowed"])
# reviewer is blocked by reviewer worktree proof
res2 = assess_author_worktree_continuity({
"task_role": "reviewer",
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
})
self.assertFalse(res2["proven"])
@patch("mcp_server.api_request")
def test_mutating_actions_auto_switch(self, mock_api):
mock_api.side_effect = lambda method, url, header: (
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
)
with patch.dict(os.environ, self._env("author-profile")):
# verify we are author-profile
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
# verify we dynamically switched to reviewer-profile
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
if __name__ == "__main__":
unittest.main()
+5 -32
View File
@@ -46,8 +46,8 @@ EXPECTED_SKILLS = [
"gitea-resolve-task-capability",
"profile-switching",
"redaction-security-review",
"jenkins-mcp",
"glitchtip-mcp",
"jenkins-readonly",
"glitchtip-readonly",
"release-operator",
]
@@ -132,10 +132,7 @@ class TestControlPlaneGuide(GuideTestBase):
rules = g["rules"]
for key in ("hard_stops", "fail_closed", "head_sha_pinning",
"merge_confirmation", "redaction", "separation",
"profile_switching", "identity_verification",
"work_selection", "global_worktree",
"shell_spawn_hard_stop", "subagent_tool_budget",
"subagent_delegation"):
"profile_switching", "identity_verification"):
self.assertIn(key, rules)
self.assertIn("MERGE PR", json.dumps(rules["merge_confirmation"]))
self.assertTrue(rules["hard_stops"])
@@ -237,8 +234,8 @@ class TestProjectSkills(GuideTestBase):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
r = mcp_list_project_skills()
by_name = {s["name"]: s for s in r["skills"]}
self.assertEqual(by_name["jenkins-mcp"]["status"], "external-mcp")
self.assertEqual(by_name["glitchtip-mcp"]["status"], "external-mcp")
self.assertNotEqual(by_name["jenkins-readonly"]["status"], "available")
self.assertNotEqual(by_name["glitchtip-readonly"]["status"], "available")
def test_no_urls_in_registry(self):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
@@ -248,30 +245,6 @@ class TestProjectSkills(GuideTestBase):
self.assertNotIn("http://", blob)
self.assertNotIn("keychain:", blob)
def test_enabled_but_no_usable_tools_negative_assertion(self):
"""Negative assertion for 'enabled but no usable tools' (per issue #151)."""
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
r = mcp_list_project_skills()
by_name = {s["name"]: s for s in r["skills"]}
# jenkins-mcp is an external boundary; Gitea profile discovery must not
# treat a local config entry as a usable Jenkins tool surface.
self.assertIn("jenkins-mcp", by_name)
self.assertEqual(by_name["jenkins-mcp"]["status"], "external-mcp")
self.assertFalse(by_name["jenkins-mcp"].get("available_to_current_profile", False))
def test_external_mcp_skill_guides_name_expected_tools(self):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
jenkins = mcp_get_skill_guide("jenkins-mcp")
glitchtip = mcp_get_skill_guide("glitchtip-mcp")
self.assertEqual(jenkins["skill"]["status"], "external-mcp")
self.assertIn("jenkins_whoami", " ".join(jenkins["steps"]))
self.assertIn("jenkins_get_build", " ".join(jenkins["steps"]))
self.assertIn("SKIPPED", " ".join(jenkins["steps"]))
self.assertEqual(glitchtip["skill"]["status"], "external-mcp")
self.assertIn("glitchtip_whoami", " ".join(glitchtip["steps"]))
self.assertIn("glitchtip_search", " ".join(glitchtip["steps"]))
self.assertIn("SKIPPED", " ".join(glitchtip["steps"]))
# ---------------------------------------------------------------------------
# mcp_get_skill_guide
+2 -8
View File
@@ -229,12 +229,9 @@ class TestEligibilityDenialReport(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="approve", body="lgtm", remote="prgs",
final_review_decision_ready=True)
pr_number=42, action="approve", body="lgtm", remote="prgs")
self.assertFalse(res["performed"])
self.assertIn("permission_report", res)
self.assertEqual(res["permission_report"]["missing_permission"],
@@ -271,12 +268,9 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
return {"login": "author-user"}
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="comment", body="finding", remote="prgs",
final_review_decision_ready=True)
pr_number=42, action="comment", body="finding", remote="prgs")
self.assertFalse(res["performed"])
report = res["permission_report"]
self._assert_report_safe(report)
+55 -153
View File
@@ -16,50 +16,38 @@ import gitea_config
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
def _final_page_fetch(items):
return (items, {
"page": 1,
"per_page": 50,
"returned_count": len(items),
"has_more": False,
"next_page": None,
"is_final_page": True,
})
class TestPRQueueInventory(unittest.TestCase):
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch):
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_get_all):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read", "gitea.pr.comment"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([
mock_get_all.return_value = [
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True}
])
]
result = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(result["prs"]), 1)
self.assertEqual(result["prs"][0]["number"], 1)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["number"], 1)
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=True)
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([
mock_get_all.return_value = [
{
"number": 1,
"title": "PR 1",
@@ -84,7 +72,7 @@ class TestPRQueueInventory(unittest.TestCase):
"body": "",
"updated_at": "2026-07-05T11:00:00Z"
}
])
]
mock_api.return_value = {"login": "jcwalker3"} # whoami
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -98,18 +86,18 @@ class TestPRQueueInventory(unittest.TestCase):
# updated_at surfaced for reconciliation (#166)
self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_get_all.return_value = []
mock_api.return_value = {"login": "jcwalker3"}
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -117,66 +105,47 @@ class TestPRQueueInventory(unittest.TestCase):
method = call.args[0]
self.assertEqual(method, "GET")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
mock_get_profile.return_value = {
"profile_name": "gitea-reviewer",
"allowed_operations": ["read", "approve", "review"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([
mock_get_all.return_value = [
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
])
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
# POST review (#244: state + visible-verdict GET reviews).
]
# mock_api: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility), 4) /pulls/1/reviews (POST review)
mock_api.side_effect = [
{"login": "reviewer1"},
{"login": "reviewer1"},
{
"user": {"login": "other_user"},
"state": "open",
"head": {"sha": "abc1"},
"mergeable": True,
},
{"id": 100, "state": "APPROVED"},
[
{
"id": 100,
"user": {"login": "reviewer1"},
"state": "APPROVED",
"commit_id": "abc1",
"submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False,
}
],
{"login": "reviewer1"}, # inventory whoami
{"login": "reviewer1"}, # eligibility whoami
{"state": "open", "head": {"sha": "abc1"}, "mergeable": True, "user": {"login": "other_user"}}, # eligibility PR details
{"id": 100} # POST review
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(1, "approve", remote="prgs")
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
self.assertTrue(result["success"])
self.assertIn("=== PR Queue Inventory ===", result["message"])
self.assertIn("Repository:", result["message"])
self.assertIn("Successfully submitted review", result["message"])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=False)
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_get_all.return_value = []
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -212,11 +181,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertFalse(result["eligible"])
self.assertEqual(mock_api.call_count, 0)
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Inventory failure path must use redaction and not leak raw exception text."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -225,7 +194,7 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# Exception contains something that should be redacted or not leaked raw
mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_get_all.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -240,11 +209,11 @@ class TestPRQueueInventory(unittest.TestCase):
# uses project's redaction pattern (token prefix -> [REDACTED])
# even if url leaks, the exception is redacted per pattern
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Failure paths must fully redact real service hostnames and URLs from inventory output."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -252,7 +221,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_get_all.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -262,11 +231,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("[REDACTED_URL]", msg)
self.assertNotIn("gitea.prgs.cc", msg)
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Author profiles with read can perform read-only PR inventory (report produced, no mutations)."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -274,9 +243,9 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([
mock_get_all.return_value = [
{"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""}
])
]
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs")
@@ -289,79 +258,12 @@ class TestPRQueueInventory(unittest.TestCase):
for call in mock_api.call_args_list:
self.assertEqual(call.args[0], "GET")
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = None
result = gitea_review_pr(
pr_number=1,
event="APPROVE",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["success"])
msg = result["message"]
self.assertIn("pr_inventory_trust_gate.status: untrusted_empty", msg)
self.assertIn("Empty-queue claim blocked", msg)
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_empty_inventory_trusted_empty_when_gate_passes(
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
result = gitea_review_pr(
pr_number=1,
event="APPROVE",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(result["success"])
msg = result["message"]
self.assertIn("pr_inventory_trust_gate.status: trusted_empty", msg)
self.assertIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url):
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory."""
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
)
for event in ["APPROVE", "REQUEST_CHANGES"]:
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -369,7 +271,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([])
mock_get_all.return_value = []
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event=event, remote="prgs")
@@ -389,10 +291,10 @@ class TestPRQueueInventory(unittest.TestCase):
# Tests use direct list/view calls + inventory path to ensure fields and
# live data are available for detection without trusting prior handoffs.
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch):
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_get_all):
"""Scenario 1: stale prior handoff says merged but live PR list says open."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -400,17 +302,17 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_fetch.return_value = _final_page_fetch([{
mock_get_all.return_value = [{
"number": 99, "title": "Stale merge claim", "state": "open",
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "other"},
"labels": [], "body": "Fixes #200",
"updated_at": "2026-07-05T22:00:00Z"
}])
}]
prs = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(prs["prs"]), 1)
self.assertEqual(prs["prs"][0]["state"], "open")
self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged"
self.assertEqual(len(prs), 1)
self.assertEqual(prs[0]["state"], "open")
self.assertIn("updated_at", prs[0]) # enables staleness check vs prior "merged"
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -436,11 +338,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNotNone(pr.get("closed_at"))
# live closed would contradict prior "open" handoff
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -449,12 +351,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# list says open
mock_fetch.return_value = _final_page_fetch([{
mock_get_all.return_value = [{
"number": 77, "title": "Disagree", "state": "open",
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "x"},
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
}])
}]
# view says closed (simulating inconsistency)
mock_api.return_value = {
"number": 77, "title": "Disagree", "state": "closed",
@@ -465,7 +367,7 @@ class TestPRQueueInventory(unittest.TestCase):
}
listed = gitea_list_prs(state="open", remote="prgs")
viewed = gitea_view_pr(pr_number=77, remote="prgs")
self.assertEqual(listed["prs"][0]["state"], "open")
self.assertEqual(listed[0]["state"], "open")
self.assertEqual(viewed["state"], "closed")
# caller must reconcile; mismatch is hard blocker per rules
@@ -495,11 +397,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNone(pr.get("merge_commit_sha"))
# post-merge view must surface missing commit for detection
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch):
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_get_all):
"""Scenario 5: linked issue remains open after claimed merge."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -508,12 +410,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# PR claims merge, links #300
mock_fetch.return_value = _final_page_fetch([{
mock_get_all.return_value = [{
"number": 300, "title": "PR for #300", "state": "closed",
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "u"},
"labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z"
}])
}]
# But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link
# For this test we assert the PR data shows link, caller must check issue state live
mock_api.return_value = {
-93
View File
@@ -1,93 +0,0 @@
"""Doc-contract checks for proof wording enforcement (#330)."""
import unittest
from review_proofs import assess_proof_wording, build_final_report
class TestProofWording(unittest.TestCase):
def test_rejects_inventory_complete_without_pagination_proof(self):
result = assess_proof_wording(
"Open PR inventory is complete because only 3 PRs were returned."
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_rejects_default_page_size_assumption(self):
result = assess_proof_wording(
"Inventory exhaustive: fewer than the default Gitea page size returned."
)
self.assertFalse(result["proven"])
self.assertIn("default_page_size_assumption", result["flagged_rules"])
def test_allows_inventory_complete_with_finality_metadata(self):
result = assess_proof_wording(
"Inventory complete. pagination_complete: true, is_final_page: true"
)
self.assertTrue(result["proven"])
def test_allows_inventory_complete_with_session_evidence(self):
result = assess_proof_wording(
"Queue inventory complete.",
session_evidence={"pagination_complete": True},
)
self.assertTrue(result["proven"])
def test_rejects_live_proof_without_session_evidence(self):
result = assess_proof_wording("Live proof confirms mergeable state.")
self.assertFalse(result["proven"])
self.assertIn("live_proof", result["flagged_rules"])
def test_allows_prior_blocker_reuse_wording(self):
result = assess_proof_wording(
"Prior blocker reused; head SHA unchanged since blocking review. "
"Live proof not claimed for this skip."
)
self.assertTrue(result["proven"])
def test_allows_live_proof_with_current_session_evidence(self):
result = assess_proof_wording(
"Live proof: gitea_view_pr revalidated in the current session."
)
self.assertTrue(result["proven"])
def test_rejects_same_as_master_without_baseline_proof(self):
result = assess_proof_wording("Diff is same as master.")
self.assertFalse(result["proven"])
self.assertIn("same_as_master", result["flagged_rules"])
def test_allows_same_as_master_with_baseline_wording(self):
result = assess_proof_wording(
"Clean baseline worktree proof: diff base matches master."
)
self.assertTrue(result["proven"])
def test_rejects_file_edits_none_without_ledger(self):
result = assess_proof_wording("Workspace mutations: file edits none.")
self.assertFalse(result["proven"])
self.assertIn("no_file_edits", result["flagged_rules"])
def test_allows_file_edits_none_with_mutation_ledger(self):
result = assess_proof_wording(
"Mutation ledger: tracked file edits: none",
session_evidence={"mutation_ledger_clean": True},
)
self.assertTrue(result["proven"])
def test_build_final_report_downgrades_on_proof_wording_violation(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text="Inventory complete after listing 4 open PRs.",
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["proof_wording_proven"])
self.assertTrue(report["proof_wording_violations"])
if __name__ == "__main__":
unittest.main()
-91
View File
@@ -1,91 +0,0 @@
"""Doc-contract checks for queue-status report verifier (#339)."""
import unittest
from review_proofs import assess_queue_status_report, build_final_report
BAD_QUEUE_STATUS_REPORT = """
## PR Queue Status
Loaded workflows/review-merge-pr.md. Inventory complete because gitea_list_prs
returned 6 open PRs, fewer than the default Gitea page size.
PR #286 skipped: prior diagnostic worktree simulation showed merge conflicts.
Live blocker proof for all skipped PRs.
All open PRs are conflicted or blocked. Blockers: none.
## Controller Handoff
- Task: review queue status
- Repo: Scaled-Tech-Consulting/Gitea-Tools
- Role: reviewer
- Identity: reviewer1 / prgs-reviewer
- Selected PR: none
- Inventory pagination proof: assumed complete (6 < 50)
- Already-landed gate: passed
- Author-safety result: passed
- Merge preflight: passed
- Review worktree used: false
- Review worktree dirty before validation: false
- Blockers: none
- Current status: queue inspected; no PR selected
"""
class TestQueueStatusReport(unittest.TestCase):
def test_bad_fixture_fails_closed(self):
result = assess_queue_status_report(BAD_QUEUE_STATUS_REPORT)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(result["queue_status_only"])
joined = " ".join(result["violations"]).lower()
self.assertIn("already-landed gate", joined)
self.assertIn("author-safety", joined)
self.assertIn("merge preflight", joined)
self.assertIn("prior diagnostic", joined)
self.assertIn("blockers", joined)
def test_passes_with_not_applicable_gates(self):
report = """
## Controller Handoff
- Selected PR: none
- Inventory pagination proof: is_final_page: true, has_more: false
- Already-landed gate: not applicable
- Author-safety result: not run
- Merge preflight: not applicable
- Review worktree used: false
- Review worktree dirty before validation: not applicable
- Blockers: all PRs skipped pending reviewer
"""
result = assess_queue_status_report(report)
self.assertTrue(result["proven"])
def test_rejects_passed_gates_without_selected_pr(self):
report = """
## Controller Handoff
- Selected PR: none
- Already-landed gate: passed
- Blockers: PR #1 conflicted
"""
result = assess_queue_status_report(report)
self.assertFalse(result["proven"])
self.assertIn("already-landed gate", " ".join(result["violations"]).lower())
def test_build_final_report_downgrades_bad_queue_status(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text=BAD_QUEUE_STATUS_REPORT,
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["queue_status_report_proven"])
self.assertTrue(report["queue_status_violations"])
if __name__ == "__main__":
unittest.main()
@@ -1,150 +0,0 @@
"""Tests for reconciliation PR-inventory pagination proof (#308).
Already-landed reconciliation reports may not claim a complete queue
scan (or that all already-landed PRs were found) unless the report
carries pagination proof fields and the per-page evidence proves the
final page was reached or the page limit was not hit.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_reconciliation_inventory_proof # noqa: E402
def _report(claim=True, fields=True, pages="1", counts="12",
final_proof="is_final_page=true, has_more=false"):
lines = []
if claim:
lines.append(
"Inventory scope: complete queue scan; all already-landed "
"PRs were found."
)
if fields:
lines += [
"- Requested page size: 50",
f"- Pages fetched: {pages}",
f"- Returned PR count per page: {counts}",
f"- Final page proof: {final_proof}",
]
return "\n".join(lines) + "\n"
def _page(page, returned, per_page=50, has_more=False, final=True,
inventory_complete=None):
d = {
"page": page,
"per_page": per_page,
"returned_count": returned,
"has_more": has_more,
"is_final_page": final,
}
if inventory_complete is not None:
d["inventory_complete"] = inventory_complete
return d
class TestCompleteScanProven(unittest.TestCase):
def test_first_page_below_limit_passes(self):
result = assess_reconciliation_inventory_proof(
_report(),
pagination_evidence=[_page(1, 12)],
)
self.assertTrue(result["complete"])
self.assertEqual(result["reasons"], [])
def test_multiple_pages_to_final_passes(self):
report = _report(
pages="3", counts="50, 50, 12",
final_proof="has_more=false on page 3",
)
evidence = [
_page(1, 50, has_more=True, final=False),
_page(2, 50, has_more=True, final=False),
_page(3, 12),
]
result = assess_reconciliation_inventory_proof(
report, pagination_evidence=evidence
)
self.assertTrue(result["complete"])
def test_no_completeness_claim_needs_no_proof(self):
result = assess_reconciliation_inventory_proof(
_report(claim=False, fields=False),
pagination_evidence=None,
)
self.assertTrue(result["complete"])
class TestUnprovenScanFails(unittest.TestCase):
def test_first_page_exactly_at_limit_fails(self):
# 50 returned with per_page 50 and no final-page signal: the page
# limit was hit, so completeness is unproven.
evidence = [_page(1, 50, has_more=True, final=False)]
result = assess_reconciliation_inventory_proof(
_report(counts="50", final_proof="none"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("final page" in r.lower() for r in result["reasons"])
)
def test_missing_pagination_evidence_fails(self):
result = assess_reconciliation_inventory_proof(
_report(), pagination_evidence=None
)
self.assertFalse(result["complete"])
self.assertTrue(
any("pagination" in r.lower() for r in result["reasons"])
)
def test_missing_report_fields_fails(self):
result = assess_reconciliation_inventory_proof(
_report(fields=False),
pagination_evidence=[_page(1, 12)],
)
self.assertFalse(result["complete"])
self.assertTrue(
any("requested page size" in r.lower()
for r in result["reasons"])
)
def test_page_count_mismatch_fails(self):
# Report claims 1 page; evidence shows 2 were fetched.
evidence = [
_page(1, 50, has_more=True, final=False),
_page(2, 3),
]
result = assess_reconciliation_inventory_proof(
_report(pages="1", counts="50"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("pages fetched" in r.lower() for r in result["reasons"])
)
def test_per_page_count_mismatch_fails(self):
evidence = [_page(1, 12)]
result = assess_reconciliation_inventory_proof(
_report(counts="11"),
pagination_evidence=evidence,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("count per page" in r.lower() for r in result["reasons"])
)
def test_evidence_page_missing_metadata_fails(self):
result = assess_reconciliation_inventory_proof(
_report(),
pagination_evidence=[{"page": 1}],
)
self.assertFalse(result["complete"])
if __name__ == "__main__":
unittest.main()
@@ -1,104 +0,0 @@
"""Tests for REQUEST_CHANGES override proof before approval (#326)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_request_changes_approval_proof # noqa: E402
HEAD_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
HEAD_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def _feedback(**overrides):
base = {
"success": True,
"current_head_sha": HEAD_A,
"has_blocking_change_requests": False,
"author_pushed_after_request_changes": False,
"reviews": [],
}
base.update(overrides)
return base
def _blocking_review(**overrides):
entry = {
"reviewer": "reviewer-a",
"verdict": "REQUEST_CHANGES",
"body": "Tests fail on pinned head; fix test_commit_files_gate.",
"submitted_at": "2026-07-07T01:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
"stale": False,
}
entry.update(overrides)
return entry
class TestRequestChangesApprovalProof(unittest.TestCase):
def test_missing_feedback_blocks_approval(self):
result = assess_request_changes_approval_proof(None)
self.assertFalse(result["approve_allowed"])
self.assertTrue(result["block"])
def test_no_prior_request_changes_allows_approval(self):
feedback = _feedback(
reviews=[{
"reviewer": "sysadmin",
"verdict": "APPROVED",
"body": "LGTM",
"submitted_at": "2026-07-07T02:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
}]
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertIsNone(result["blocking_review"])
def test_changed_head_since_blocker_allows_approval(self):
feedback = _feedback(
current_head_sha=HEAD_B,
author_pushed_after_request_changes=True,
has_blocking_change_requests=True,
reviews=[_blocking_review(reviewed_head_sha=HEAD_A)],
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertTrue(result["head_changed_since_blocker"])
def test_unchanged_head_without_override_blocks_approval(self):
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[_blocking_review()],
)
result = assess_request_changes_approval_proof(feedback)
self.assertFalse(result["approve_allowed"])
self.assertIn("override_reason", " ".join(result["reasons"]))
def test_unchanged_head_with_valid_override_allows_approval(self):
blocker = _blocking_review()
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[blocker],
)
report = (
"Blocker text: Tests fail on pinned head; fix test_commit_files_gate.\n"
"Override: wrong_validation_environment — CI used stale worktree."
)
result = assess_request_changes_approval_proof(
feedback,
override_reason="wrong_validation_environment",
override_explanation="CI used stale worktree; local rerun passed.",
report_text=report,
)
self.assertTrue(result["approve_allowed"])
self.assertEqual(
result["blocking_review"]["blocking_reviewer"], "reviewer-a"
)
if __name__ == "__main__":
unittest.main()
-84
View File
@@ -130,28 +130,6 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertIn("author-profile", res["matching_configured_profile"])
self.assertIn("reviewer-profile", res["matching_configured_profile"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_mark_issue_author_profile_allowed(self, _auth, _api):
# #183: mark_issue must map to gitea.issue.comment (prgs-author allowed op).
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="mark_issue", remote="prgs")
self.assertEqual(res["requested_task"], "mark_issue")
self.assertEqual(res["required_operation_permission"], "gitea.issue.comment")
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_lock_issue_author_profile_allowed(self, _auth, _api):
# #275: lock_issue must be an exact resolver task for claim/lock gates.
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="lock_issue", remote="prgs")
self.assertEqual(res["requested_task"], "lock_issue")
self.assertEqual(res["required_operation_permission"], "gitea.issue.comment")
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
def test_resolve_unknown_task_fails_closed(self):
with patch.dict(os.environ, self._env("author-profile")):
with self.assertRaises(ValueError):
@@ -225,67 +203,5 @@ class TestResolveTaskCapability(unittest.TestCase):
reasons = res.get("reasons", [])
self.assertTrue(any("author" in str(r).lower() for r in reasons) or len(reasons) > 0)
# Issue #216: close_pr is a first-class resolver task gated on gitea.pr.close.
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_close_pr_author_profile_without_close_blocked(self, _auth, _api):
# Default author profile has PR create/comment but not close: the
# close capability must never be implied by broader author ops.
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertEqual(res["requested_task"], "close_pr")
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
self.assertEqual(res["required_role_kind"], "author")
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
self.assertEqual(res["matching_configured_profile"], [])
self.assertTrue(res["different_mcp_namespace_required"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_close_pr_author_profile_with_close_allowed(self, _auth, _api):
# Operator-granted close capability resolves cleanly under an author profile.
config = json.loads(json.dumps(CONFIG_RESOLVER))
config["profiles"]["author-closer"] = {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.close"],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"execution_profile": "author-closer",
}
self._write_config(config)
with patch.dict(os.environ, self._env("author-closer")):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertTrue(res["allowed_in_current_session"])
self.assertFalse(res["stop_required"])
self.assertIn("author-closer", res["matching_configured_profile"])
self.assertIn("ready for operations", res["exact_safe_next_action"])
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_close_pr_reviewer_profile_blocked(self, _auth, _api):
# close_pr is author-side: a reviewer profile must be told to stop.
with patch.dict(os.environ, self._env("reviewer-profile")):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
self.assertEqual(res["required_role_kind"], "author")
self.assertIn("author", res["exact_safe_next_action"].lower())
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_close_pr_known_and_lookalike_tasks_still_fail_closed(self, _auth, _api):
# close_pr resolves (no Unknown-task error); near-miss spellings keep
# failing closed so no untracked close fallback can be rationalized.
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
for unknown in ("close_pull_request", "close", "pr_close"):
with self.assertRaises(ValueError):
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
if __name__ == "__main__":
unittest.main()
+101 -39
View File
@@ -34,7 +34,8 @@ class TestArgParsing(unittest.TestCase):
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
def test_missing_pr_number_exits(self):
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_missing_pr_number_exits(self, _auth):
with self.assertRaises(SystemExit):
review_pr.main([])
@@ -46,21 +47,37 @@ class TestAPIPayload(unittest.TestCase):
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
def test_review_submission_fails_closed_without_api_call(self):
# #211: live review submission must use gated MCP tools, not CLI POST.
import io
buf = io.StringIO()
with patch.object(sys, "stderr", buf):
rc = review_pr.main([
"--pr-number", "81",
"--event", "APPROVE",
"--body", "Approved and ready to merge",
])
self.assertEqual(rc, 2)
self.assertIn("disabled", buf.getvalue().lower())
self.assertIn("gitea_submit_pr_review", buf.getvalue())
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_payload_fields_and_workflow(self, _auth, mock_api):
# Setup mock api_request to return PR details, then review response
mock_api.side_effect = [FAKE_PR_DATA, {}]
def test_merge_flag_fails_closed_without_api_call(self):
rc = review_pr.main([
"--pr-number", "81",
"--event", "APPROVE",
"--body", "Approved and ready to merge",
])
self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2)
# Verify first call: GET PR
first_call_args = mock_api.call_args_list[0]
self.assertEqual(first_call_args[0][0], "GET")
self.assertEqual(first_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81")
# Verify second call: POST review
second_call_args = mock_api.call_args_list[1]
self.assertEqual(second_call_args[0][0], "POST")
self.assertEqual(second_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81/reviews")
payload = second_call_args[0][3]
self.assertEqual(payload["event"], "APPROVE")
self.assertEqual(payload["body"], "Approved and ready to merge")
self.assertEqual(payload["commit_id"], "abcdef1234567890")
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_merge_flag_fails_closed_without_api_call(self, _auth, mock_api):
# --merge is an ungated bypass and is disabled (#16). It must fail
# closed BEFORE any API call — no review, no merge.
rc = review_pr.main([
@@ -71,47 +88,92 @@ class TestAPIPayload(unittest.TestCase):
"--merge-method", "squash",
])
self.assertEqual(rc, 2)
self.assertEqual(mock_api.call_count, 0)
def test_merge_flag_message_points_to_gated_workflow(self):
from _pytest.monkeypatch import MonkeyPatch
import io
buf = io.StringIO()
with patch.object(sys, "stderr", buf):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE", "--merge",
])
with patch("review_pr.get_auth_header", return_value=FAKE_CREDS), \
patch("review_pr.api_request") as mock_api:
buf = io.StringIO()
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE", "--merge",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 2)
self.assertEqual(mock_api.call_count, 0)
msg = buf.getvalue().lower()
self.assertIn("disabled", msg)
self.assertIn("gitea_merge_pr", msg)
class TestMutationAuthorityLock(unittest.TestCase):
"""#199 (refs #194): the CLI refuses to run under active MCP sessions."""
"""Issue #194: verify that the CLI tool rejects profile overrides when mismatched with lock."""
def test_cli_disabled_on_session_lock(self):
# When running inside an MCP session, direct review submission via CLI is disabled entirely.
@patch("review_pr.get_profile")
def test_cli_blocked_on_profile_mismatch(self, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-author"}')
return original_open(file, *args, **kwargs)
from _pytest.monkeypatch import MonkeyPatch
import io
buf = io.StringIO()
with patch.dict(os.environ,
{"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}), \
patch.object(sys, "stderr", buf):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
self.assertEqual(rc, 2)
msg = buf.getvalue().lower()
self.assertIn("disabled", msg)
self.assertIn("gitea_submit_pr_review", msg)
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
def test_cli_disabled_even_without_session_lock(self):
# #211: CLI review submission is fail-closed regardless of session lock.
env = {k: v for k, v in os.environ.items()
if k != "GITEA_SESSION_PROFILE_LOCK"}
with patch.dict(os.environ, env, clear=True):
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 3)
msg = buf.getvalue().lower()
self.assertIn("cli override rejected", msg)
@patch("review_pr.get_profile")
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_cli_allowed_on_profile_match(self, _auth, mock_api, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
mock_api.side_effect = [FAKE_PR_DATA, {}]
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-reviewer"}')
return original_open(file, *args, **kwargs)
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
self.assertEqual(rc, 2)
self.assertEqual(rc, 0)
if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
-139
View File
@@ -1,139 +0,0 @@
"""Tests for prior-blocker skip proof verifier (#318)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_blocker_skip import assess_prior_blocker_skip_proof # noqa: E402
HEAD_CURRENT = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
HEAD_BLOCKER = "1111111111111111111111111111111111111111"
def _good_blocker_skip_report(pr_number: int) -> str:
return "\n".join([
f"Skipped PR #{pr_number} due to prior REQUEST_CHANGES.",
f"- PR number: #{pr_number}",
f"- Current head SHA: {HEAD_CURRENT}",
"- Blocking review decision: request_changes",
f"- Blocking review head SHA: {HEAD_CURRENT}",
"- Head unchanged since blocker",
"- Reason remains blocked: unresolved review feedback at same head",
"- Blocker revalidated live in this session via gitea_get_pr_review_feedback",
])
class TestPriorBlockerSkipProof(unittest.TestCase):
def test_no_skips_passes(self):
result = assess_prior_blocker_skip_proof(
"Selected PR #281 for review.",
skipped_prs=[],
)
self.assertTrue(result["proven"])
def test_blocked_unchanged_with_live_proof_passes(self):
report = _good_blocker_skip_report(280)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertTrue(result["proven"])
def test_head_changed_after_blocker_blocks_skip(self):
result = assess_prior_blocker_skip_proof(
_good_blocker_skip_report(280),
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_BLOCKER,
"head_changed_since_blocker": True,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("head changed" in r for r in result["reasons"]))
def test_missing_live_proof_blocks(self):
report = _good_blocker_skip_report(280).replace(
"- Blocker revalidated live in this session via gitea_get_pr_review_feedback",
"",
)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("live blocker proof" in r for r in result["reasons"]))
def test_blocker_unverified_classification_passes(self):
report = "\n".join([
"Skipped PR #280.",
"Classification: BLOCKER_STATUS_UNVERIFIED",
"Review feedback could not be fetched live in this session.",
])
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"blocking_decision": "request_changes",
"blocker_verified": False,
}],
)
self.assertTrue(result["proven"])
def test_unverified_without_classification_blocks(self):
result = assess_prior_blocker_skip_proof(
"Skipped PR #280 based on memory from last session.",
skipped_prs=[{
"pr_number": 280,
"blocking_decision": "request_changes",
"blocker_verified": False,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("BLOCKER_STATUS_UNVERIFIED" in r for r in result["reasons"]))
def test_missing_blocking_head_sha_blocks(self):
report = _good_blocker_skip_report(280).replace(
f"- Blocking review head SHA: {HEAD_CURRENT}",
"",
)
result = assess_prior_blocker_skip_proof(
report,
skipped_prs=[{
"pr_number": 280,
"head_sha": HEAD_CURRENT,
"blocking_decision": "request_changes",
"blocking_review_head_sha": HEAD_CURRENT,
"head_changed_since_blocker": False,
"blocker_verified": True,
}],
)
self.assertFalse(result["proven"])
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_prior_blocker_skip_proof as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
-112
View File
@@ -1,112 +0,0 @@
"""Tests for reviewer local-fallback verifier (#324)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_fallback import assess_reviewer_fallback_report # noqa: E402
class TestReviewerFallbackNormalMode(unittest.TestCase):
def test_clean_mcp_report_passes(self):
report = (
"Used gitea_list_prs and gitea_view_pr via MCP reviewer namespace.\n"
"Capability evidence: gitea_resolve_task_capability(review_pr)."
)
result = assess_reviewer_fallback_report(report, mcp_available=True)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_profiles_json_access_blocks(self):
report = (
"Inspected ~/.config/gitea-tools/profiles.json for reviewer credentials.\n"
"Then used gitea_view_pr."
)
result = assess_reviewer_fallback_report(report, mcp_available=True)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(any("profiles.json" in r for r in result["reasons"]))
def test_local_list_prs_script_blocks_when_mcp_available(self):
report = (
"Ran `python list_prs.py --remote prgs` with GITEA_MCP_CONFIG set.\n"
"MCP tools were also available."
)
result = assess_reviewer_fallback_report(
report,
mcp_available=True,
action_log=[{"command": "GITEA_MCP_PROFILE=prgs-reviewer python list_prs.py"}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(result["script_violations"])
def test_action_log_profile_access_blocks(self):
result = assess_reviewer_fallback_report(
"Review complete via MCP.",
action_log=[{"path": "/Users/me/.config/gitea-tools/profiles.json", "action": "read"}],
mcp_available=True,
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
class TestReviewerFallbackRecoveryMode(unittest.TestCase):
def _recovery_report(self, **extra):
base = "\n".join([
"Recovery mode: MCP tools unavailable in this session.",
"Local fallback: ran python view_pr.py after capability proof.",
"Identity proof: sysadmin / prgs-reviewer",
"Profile proof: prgs-reviewer",
"Repo proof: Scaled-Tech-Consulting/Gitea-Tools",
"Capability proof: gitea.pr.review allowed for recovery command",
"Why MCP was unavailable: reviewer MCP namespace failed to start",
])
return base + ("\n" + extra.get("append", "") if extra.get("append") else "")
def test_recovery_fallback_with_proof_passes(self):
result = assess_reviewer_fallback_report(
self._recovery_report(),
action_log=[{"command": "python view_pr.py --remote prgs"}],
mcp_available=False,
recovery_mode=True,
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_recovery_fallback_without_proof_blocks(self):
report = (
"Recovery mode engaged.\n"
"Local fallback: python list_prs.py\n"
)
result = assess_reviewer_fallback_report(
report,
mcp_available=False,
recovery_mode=True,
action_log=[{"command": "python list_prs.py"}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(
any("recovery" in r.lower() for r in result["reasons"])
)
def test_mcp_unavailable_allows_recovery_attempt(self):
result = assess_reviewer_fallback_report(
self._recovery_report(),
mcp_available=False,
recovery_mode=True,
)
self.assertTrue(result["proven"])
class TestReviewerFallbackExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_reviewer_fallback_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
-150
View File
@@ -1,150 +0,0 @@
"""Tests for non-mergeable skip conflict-proof verifier (#322)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_mergeability_skip import assess_non_mergeable_skip_proof # noqa: E402
HEAD_A = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
HEAD_B = "1111111111111111111111111111111111111111"
def _good_skip_report(pr_number: int, head: str, files: str = "review_proofs.py") -> str:
return "\n".join([
f"Skipped PR #{pr_number} (non-mergeable).",
f"- PR number: #{pr_number}",
f"- Current head SHA: {head}",
"- Mergeability result: false",
"- Conflict proof command: git merge-tree $(git merge-base prgs/master prgs/feat) prgs/master prgs/feat",
f"- Conflicting files: {files}",
"- Head unchanged since prior blocker",
"- Blocking category: merge conflict",
])
class TestNonMergeableSkipProof(unittest.TestCase):
def test_no_skips_passes(self):
report = "Selected PR #203 for review. No earlier PRs skipped."
result = assess_non_mergeable_skip_proof(report, skipped_prs=[])
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_documented_non_mergeable_skip_passes(self):
report = _good_skip_report(282, HEAD_A)
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
"conflicting_files": ["review_proofs.py"],
"head_changed_since_prior_blocker": False,
}],
)
self.assertTrue(result["proven"])
def test_skip_without_conflict_proof_blocks(self):
report = "\n".join([
"Skipped PR #284 because it was not mergeable.",
f"Head SHA: {HEAD_B}",
])
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 284,
"mergeable": False,
"head_sha": HEAD_B,
"mergeability_verified": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(any("conflict proof" in r for r in result["reasons"]))
def test_missing_head_sha_blocks(self):
report = _good_skip_report(282, HEAD_A).replace(HEAD_A, "")
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
}],
)
self.assertFalse(result["proven"])
def test_mergeability_unverified_classification_passes(self):
report = "\n".join([
"Skipped PR #282.",
"Classification: MERGEABILITY_UNVERIFIED",
"Conflict proof could not be retrieved in this session.",
])
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"mergeability_verified": False,
}],
)
self.assertTrue(result["proven"])
def test_unverified_without_classification_blocks(self):
report = "Skipped PR #282 due to conflicts."
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"mergeability_verified": False,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("MERGEABILITY_UNVERIFIED" in r for r in result["reasons"]))
def test_non_mergeable_without_file_details_still_passes_with_command(self):
report = _good_skip_report(284, HEAD_B, files="not available")
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 284,
"mergeable": False,
"head_sha": HEAD_B,
"mergeability_verified": True,
"conflicting_files": [],
}],
)
self.assertTrue(result["proven"])
def test_stale_head_requires_head_change_field(self):
report = _good_skip_report(282, HEAD_A).replace(
"- Head unchanged since prior blocker",
"",
)
result = assess_non_mergeable_skip_proof(
report,
skipped_prs=[{
"pr_number": 282,
"mergeable": False,
"head_sha": HEAD_A,
"mergeability_verified": True,
"head_changed_since_prior_blocker": True,
}],
)
self.assertFalse(result["proven"])
self.assertTrue(any("head-changed" in r for r in result["reasons"]))
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_non_mergeable_skip_proof as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
-183
View File
@@ -1,183 +0,0 @@
"""Tests for reviewer worktree safety proofs (Issue #233)."""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from reviewer_worktree import ( # noqa: E402
assess_author_worktree_continuity,
assess_reviewer_git_command_log,
assess_reviewer_worktree_proof,
files_outside_pr_scope,
is_forbidden_reviewer_git_command,
is_readonly_reviewer_git_command,
parse_dirty_tracked_files,
)
class TestParseDirtyTrackedFiles(unittest.TestCase):
def test_ignores_untracked_files(self):
porcelain = "?? untracked.txt\n M tracked.py\n"
self.assertEqual(parse_dirty_tracked_files(porcelain), ["tracked.py"])
def test_parses_renamed_paths(self):
porcelain = "R old.py -> new.py\n"
self.assertEqual(parse_dirty_tracked_files(porcelain), ["new.py"])
class TestFilesOutsidePrScope(unittest.TestCase):
def test_all_dirty_in_scope_is_clean(self):
self.assertEqual(
files_outside_pr_scope(
["docs/wiki/Repositories.md"],
["docs/wiki/Repositories.md"],
),
[],
)
def test_unrelated_dirty_files_detected(self):
self.assertEqual(
files_outside_pr_scope(
["review_proofs.py", "docs/wiki/Repositories.md"],
["docs/wiki/Repositories.md"],
),
["review_proofs.py"],
)
class TestForbiddenGitCommands(unittest.TestCase):
def test_blocks_stash_operations(self):
self.assertTrue(is_forbidden_reviewer_git_command("git stash"))
self.assertTrue(
is_forbidden_reviewer_git_command(
'git stash push -m "reviewer-temp-stash" -- tests/test_mcp_server.py'
)
)
self.assertTrue(is_forbidden_reviewer_git_command("git stash pop"))
self.assertTrue(is_forbidden_reviewer_git_command("git stash drop"))
def test_blocks_checkout_reset_and_clean(self):
self.assertTrue(
is_forbidden_reviewer_git_command(
"git checkout -- review_proofs.py tests/test_mcp_server.py"
)
)
self.assertTrue(is_forbidden_reviewer_git_command("git reset --hard"))
self.assertTrue(is_forbidden_reviewer_git_command("git clean -fd"))
def test_blocks_checkout_restore_and_switch_bypasses(self):
"""Issue #243: blocklist gaps closed via readonly allowlist model."""
blocked = (
"git checkout HEAD -- review_proofs.py",
"git checkout prgs/master -- review_proofs.py",
"git checkout .",
"git switch -",
"git switch -C feat/other-branch",
"git switch master",
"git stash store",
"git stash branch wip-stash",
)
for cmd in blocked:
with self.subTest(cmd=cmd):
self.assertTrue(is_forbidden_reviewer_git_command(cmd))
self.assertFalse(is_readonly_reviewer_git_command(cmd))
def test_allows_readonly_commands(self):
for cmd in (
"git fetch prgs master",
"git status --porcelain",
"git diff prgs/master...HEAD",
"git rev-parse HEAD",
"git -C /repo log -1",
):
with self.subTest(cmd=cmd):
self.assertFalse(is_forbidden_reviewer_git_command(cmd))
self.assertTrue(is_readonly_reviewer_git_command(cmd))
class TestAssessReviewerWorktreeProof(unittest.TestCase):
def test_clean_worktree_proceeds(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo/branches/review-pr-231",
"porcelain_status": "",
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
"git_commands": ["git fetch prgs master", "git diff prgs/master...HEAD"],
})
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_dirty_unrelated_without_scratch_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py", "tests/test_review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": False,
})
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertIn("review_proofs.py", result["unrelated_dirty_files"][0])
def test_scratch_worktree_allows_dirty_main_repo(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"dirty_files": ["review_proofs.py"],
"pr_scope_files": ["docs/wiki/Repositories.md"],
"scratch_used": True,
"scratch_path": "/repo/branches/review-feat-issue-224",
})
self.assertTrue(result["proven"])
def test_forbidden_command_history_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo/branches/review-pr-231",
"porcelain_status": "",
"git_commands": ["git stash push -m temp -- review_proofs.py"],
})
self.assertFalse(result["proven"])
self.assertTrue(result["forbidden_commands"])
def test_unrelated_mutations_claimed_blocks(self):
result = assess_reviewer_worktree_proof({
"worktree_path": "/repo",
"porcelain_status": "",
"unrelated_mutations_claimed": True,
})
self.assertFalse(result["proven"])
class TestAuthorContinuity(unittest.TestCase):
def test_author_may_keep_dirty_worktree(self):
result = assess_author_worktree_continuity({
"task_role": "author",
"dirty_files": ["feat.py"],
})
self.assertTrue(result["allowed"])
def test_reviewer_dirty_worktree_uses_reviewer_gate(self):
result = assess_author_worktree_continuity({
"task_role": "reviewer",
"worktree_path": "/repo",
"dirty_files": ["other.py"],
"pr_scope_files": ["docs/a.md"],
"scratch_used": False,
})
self.assertFalse(result["proven"])
class TestAssessReviewerGitCommandLog(unittest.TestCase):
def test_empty_log_is_clean(self):
result = assess_reviewer_git_command_log([])
self.assertTrue(result["proven"])
def test_mixed_log_blocks_on_forbidden(self):
result = assess_reviewer_git_command_log([
"git fetch prgs",
"git stash",
])
self.assertFalse(result["proven"])
self.assertEqual(len(result["forbidden_commands"]), 1)
if __name__ == "__main__":
unittest.main()
-215
View File
@@ -1,215 +0,0 @@
"""Tests for reviewer/author namespace mismatch walls (#209)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_audit
import mcp_server
import role_namespace_gate
from review_proofs import assess_mutation_namespace_handoff
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.pr.create",
"gitea.branch.push", "gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
},
"prgs-reviewer": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "sysadmin",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.approve",
"gitea.pr.merge", "gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
],
"execution_profile": "prgs-reviewer",
},
"prgs-reviewer-issue-writer": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "sysadmin",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.issue.create",
],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "prgs-reviewer-issue-writer",
},
},
"rules": {"allow_runtime_switching": False},
}
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
class NamespaceGateBase(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.example.com",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.audit_path = os.path.join(self._dir.name, "audit.log")
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _env(self, profile, *, audit=False):
env = {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
if audit:
env["GITEA_AUDIT_LOG"] = self.audit_path
return env
class TestNamespaceInference(unittest.TestCase):
def test_reviewer_and_author_namespaces(self):
self.assertEqual(
role_namespace_gate.infer_mcp_namespace("prgs-reviewer"),
"gitea-reviewer",
)
self.assertEqual(
role_namespace_gate.infer_mcp_namespace("prgs-author"),
"gitea-author",
)
class TestReviewerAuthorMutationBlocks(NamespaceGateBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_namespace_create_issue_blocked(
self, _auth, mock_api, _get_all, _role
):
with patch.dict(os.environ, self._env("prgs-reviewer"), clear=True):
result = mcp_server.gitea_create_issue(
title="should not post", remote="prgs")
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertTrue(result.get("namespace_block"))
mock_api.assert_not_called()
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_namespace_create_pr_blocked(
self, _auth, mock_api, _get_all, _role
):
with patch.dict(os.environ, self._env("prgs-reviewer"), clear=True):
result = mcp_server.gitea_create_pr(
title="feat: x Closes #1",
head="feat/issue-1-x",
body="Closes #1",
remote="prgs",
)
self.assertFalse(result.get("success", True))
self.assertFalse(result.get("performed", True))
self.assertTrue(result.get("namespace_block"))
mock_api.assert_not_called()
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 12})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_with_explicit_issue_create_allowed(
self, _auth, mock_api, _get_all, _role
):
with patch.dict(os.environ, self._env("prgs-reviewer-issue-writer"),
clear=True):
result = mcp_server.gitea_create_issue(
title="escalation issue", remote="prgs")
self.assertEqual(result.get("number"), 12)
mock_api.assert_called()
class TestHandoffNamespaceParity(unittest.TestCase):
def test_author_handoff_with_reviewer_namespace_invalid(self):
result = assess_mutation_namespace_handoff(
declared_role="author",
namespaces_used=["gitea-reviewer"],
)
self.assertFalse(result["valid"])
self.assertTrue(result["blocked"])
def test_capability_author_profile_with_reviewer_namespace_blocked(self):
result = assess_mutation_namespace_handoff(
declared_role="author",
namespaces_used=["gitea-reviewer"],
capability_profile="prgs-author",
)
self.assertFalse(result["valid"])
blob = " ".join(result["reasons"])
self.assertIn("capability proof profile", blob)
class TestMutationAuditFields(NamespaceGateBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 3})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_successful_create_issue_audit_includes_namespace_fields(
self, _auth, mock_api, _get_all, _role
):
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_create_issue(title="audited", remote="prgs")
with open(self.audit_path, encoding="utf-8") as fh:
record = json.loads(fh.readline())
self.assertEqual(record["mcp_namespace"], "gitea-author")
self.assertEqual(record["task_role"], "author")
self.assertEqual(record["operation"], "create_issue")
self.assertEqual(record["profile_name"], "prgs-author")
self.assertEqual(record["remote"], "prgs")
if __name__ == "__main__":
unittest.main()
-259
View File
@@ -1,259 +0,0 @@
"""Tests for pre-task role/session router (#206)."""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import mcp_server
import role_session_router
from review_proofs import assess_role_route_handoff
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "jcwalker3",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.pr.create",
"gitea.branch.push", "gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
],
"execution_profile": "prgs-author",
},
"prgs-reviewer": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "sysadmin",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.approve",
"gitea.pr.merge", "gitea.issue.comment",
],
"forbidden_operations": [
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
],
"execution_profile": "prgs-reviewer",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestRoleSessionRouter(unittest.TestCase):
def setUp(self):
role_session_router.clear_route_state()
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {
"host": "gitea.example.com",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
def tearDown(self):
self._remotes.stop()
role_session_router.clear_route_state()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile):
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
def test_reviewer_task_under_author_profile_wrong_role_stop(self):
with patch.dict(os.environ, self._env("prgs-author")):
route = mcp_server.gitea_route_task_session(
task_type="review_pr", remote="prgs"
)
self.assertEqual(route["route_result"], role_session_router.ROUTE_WRONG_ROLE)
self.assertFalse(route["downstream_allowed"])
self.assertIn("Wrong role/session for reviewer task", route["message"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_issue_creation_blocked_after_reviewer_wrong_role_stop(
self, _auth, mock_api
):
with patch.dict(os.environ, self._env("prgs-author")):
mcp_server.gitea_route_task_session(task_type="review_pr", remote="prgs")
result = mcp_server.gitea_create_issue(
title="process issue fallback",
body="should not post",
remote="prgs",
)
self.assertFalse(result.get("success", True))
self.assertFalse(result.get("performed", True))
issue_posts = [
c for c in mock_api.call_args_list
if c.args[0] == "POST" and str(c.args[1]).endswith("/issues")
]
self.assertEqual(issue_posts, [])
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 888})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_author_task_allowed_after_capability_resolve(
self, _auth, _api, _get_all
):
"""#228: explicit create_issue capability clears reviewer wrong_role_stop."""
with patch.dict(os.environ, self._env("prgs-author")):
mcp_server.gitea_route_task_session(task_type="review_pr", remote="prgs")
cap = mcp_server.gitea_resolve_task_capability(
task="create_issue", remote="prgs"
)
self.assertTrue(cap["allowed_in_current_session"])
result = mcp_server.gitea_create_issue(
title="operation-scoped author recovery",
remote="prgs",
)
self.assertEqual(result.get("number"), 888)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request", return_value={"number": 999})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_author_task_allowed_after_explicit_author_route(
self, _auth, _api, _get_all
):
with patch.dict(os.environ, self._env("prgs-author")):
route = mcp_server.gitea_route_task_session(
task_type="create_issue", remote="prgs"
)
self.assertEqual(
route["route_result"], role_session_router.ROUTE_ALLOWED
)
result = mcp_server.gitea_create_issue(
title="legit author task",
remote="prgs",
)
self.assertEqual(result.get("number"), 999)
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_task_under_reviewer_profile_allowed(self, _auth, _api):
with patch.dict(os.environ, self._env("prgs-reviewer")):
route = mcp_server.gitea_route_task_session(
task_type="review_pr", remote="prgs"
)
self.assertEqual(route["route_result"], role_session_router.ROUTE_ALLOWED)
self.assertTrue(route["downstream_allowed"])
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_author_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
with patch.dict(os.environ, self._env("prgs-reviewer")):
route = mcp_server.gitea_route_task_session(
task_type="create_issue", remote="prgs"
)
self.assertEqual(
route["route_result"], role_session_router.ROUTE_TO_AUTHOR
)
self.assertFalse(route["downstream_allowed"])
def test_activate_profile_blocked_in_static_mode(self):
with patch.dict(os.environ, self._env("prgs-author")):
result = mcp_server.gitea_activate_profile(
profile_name="prgs-reviewer", remote="prgs"
)
self.assertFalse(result["success"])
self.assertIn("switching is disabled", result["message"])
def test_handoff_requires_route_fields(self):
incomplete = assess_role_route_handoff("Task done.")
self.assertFalse(incomplete["complete"])
self.assertIn("Task type", incomplete["missing_fields"])
complete = assess_role_route_handoff(
"\n".join([
"Task type: review_pr",
"Required role: reviewer",
"Active role: author",
"Route result: wrong_role_stop",
]),
route_result={"route_result": "wrong_role_stop"},
)
self.assertTrue(complete["complete"])
class TestCheckMidMerge(unittest.TestCase):
def test_skip_scan_walk_root_skips_sibling_worktrees_only(self):
# Hermetic <main>/branches/<worktree> layout (#283): deriving these
# paths from __file__ made the test pass only when the suite ran from
# a branches/ worktree, because skip_python_scan_walk_root skips a
# branches/ walk root only when <project_root>/branches exists.
with tempfile.TemporaryDirectory() as tmp:
main_root = os.path.join(tmp, "main")
worktree_root = os.path.join(main_root, "branches", "fix-issue-1")
os.makedirs(os.path.join(worktree_root, "tests"))
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
main_root, main_root
)
)
self.assertTrue(
role_session_router.skip_python_scan_walk_root(
main_root, os.path.join(main_root, "branches", "fix-issue-1")
)
)
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
worktree_root, worktree_root
)
)
self.assertFalse(
role_session_router.skip_python_scan_walk_root(
worktree_root, os.path.join(worktree_root, "tests")
)
)
def test_decorative_equals_banner_is_not_mid_merge(self):
self.assertFalse(role_session_router.check_mid_merge())
def test_python_bytes_have_conflict_markers_rejects_decorative_equals(self):
banner = b"===========================================\n"
self.assertFalse(role_session_router.python_bytes_have_conflict_markers(banner))
def test_python_bytes_have_conflict_markers_detects_real_markers(self):
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b"<<<<<<< HEAD\n")
)
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b"=======\n")
)
self.assertTrue(
role_session_router.python_bytes_have_conflict_markers(b">>>>>>> topic\n")
)
if __name__ == "__main__":
unittest.main()
+2 -31
View File
@@ -95,13 +95,9 @@ class TestRuntimeClarity(unittest.TestCase):
# -------------------------------------------------------------------------
# gitea_get_runtime_context
# -------------------------------------------------------------------------
@patch(
"mcp_server.assess_preflight_status",
return_value={"preflight_ready": True, "preflight_block_reasons": []},
)
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_get_runtime_context_author(self, _auth, _api, _preflight):
def test_get_runtime_context_author(self, _auth, _api):
with patch.dict(os.environ, self._env("author-profile"), clear=True):
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(ctx["active_profile"], "author-profile")
@@ -114,26 +110,10 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertEqual(ctx["suggested_fix"], "reviewer namespace")
self.assertIn("does not permit review or merge", ctx["review_merge_blocked_reasons"][0])
self.assertIn("Switch to the reviewer MCP session", ctx["safe_next_action"])
caps = ctx["session_capabilities"]
self.assertTrue(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertFalse(caps["can_comment_on_issues"])
self.assertFalse(caps["can_review_prs"])
self.assertFalse(caps["can_merge_prs"])
self.assertTrue(caps["issue_comment_not_implied_by_pr_comment"])
merge_entry = next(
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertFalse(merge_entry["allowed_in_current_session"])
self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"])
@patch(
"mcp_server.assess_preflight_status",
return_value={"preflight_ready": True, "preflight_block_reasons": []},
)
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_get_runtime_context_reviewer(self, _auth, _api, _preflight):
def test_get_runtime_context_reviewer(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(ctx["active_profile"], "reviewer-profile")
@@ -141,15 +121,6 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertTrue(ctx["review_merge_allowed"])
self.assertEqual(ctx["suggested_fix"], "none")
self.assertEqual(ctx["safe_next_action"], "None; ready for operations.")
caps = ctx["session_capabilities"]
self.assertFalse(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertFalse(caps["can_review_prs"])
self.assertTrue(caps["can_merge_prs"])
merge_entry = next(
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertTrue(merge_entry["allowed_in_current_session"])
# -------------------------------------------------------------------------
# gitea_list_profiles

Some files were not shown because too many files have changed in this diff Show More