Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88deed4e69 | ||
|
|
10d2644790 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 2–3: 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", []))
|
||||
),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 100–122 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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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": []}
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
),
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
@@ -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": []}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 project’s `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 project’s 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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
+8
-1834
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user