Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f11be9569 | ||
|
|
c1e47a5692 | ||
|
|
95d01b07fe | ||
|
|
183e9f08b8 | ||
|
|
eb3560d949 | ||
|
|
216fa5cf46 | ||
|
|
7a28d09b5d | ||
|
|
2111c84e7d | ||
|
|
0716157fa5 | ||
|
|
5798871cc2 | ||
|
|
84ed137f66 | ||
|
|
b4e04f4dfb | ||
|
|
4faf839dab | ||
|
|
7dde2f5405 | ||
|
|
75ed0632b4 | ||
|
|
1fd929040c | ||
|
|
f2c8a8d5c1 | ||
|
|
5965904c60 | ||
|
|
70c868962a | ||
|
|
08ed5a82d2 | ||
|
|
a887da1f8f | ||
|
|
f94cb80fc9 | ||
|
|
6e27911733 | ||
|
|
9d8ab0a7b5 | ||
|
|
c502ae30d6 | ||
|
|
ff435ea13c | ||
|
|
a1ba69eebb | ||
|
|
df1104d3e7 | ||
|
|
e441b81d3b | ||
|
|
d422bc0978 | ||
|
|
ec8f6abf5b | ||
|
|
dc41b685d0 | ||
|
|
63a7ba8287 | ||
|
|
bab803ff3d | ||
|
|
4bc02a8c7d | ||
|
|
d4e89f7863 | ||
|
|
ec879df4c2 | ||
|
|
056a232ef8 | ||
|
|
8fa94a07a8 | ||
|
|
ca3de3da53 | ||
|
|
be6feabf70 | ||
|
|
1071619532 | ||
|
|
1033a22407 | ||
|
|
7966e70db6 | ||
|
|
4f466550ca | ||
|
|
6ac6b9528c | ||
|
|
4dd32bb9f7 | ||
|
|
d5d3331498 | ||
|
|
09a766be5d | ||
|
|
342974f840 | ||
|
|
f58317d079 | ||
|
|
125f52a059 | ||
|
|
3320a5b3e6 | ||
|
|
8436276991 | ||
|
|
6971a75818 | ||
|
|
25c19b26d3 | ||
|
|
d747e0a2c9 | ||
|
|
e727210aad | ||
|
|
2f5e0408fd | ||
|
|
2cd61a90fe | ||
|
|
cb974d659f | ||
|
|
1948eae184 | ||
|
|
cbce6cb2e7 | ||
|
|
00394d43b7 | ||
|
|
c89ae2eb5c | ||
|
|
380ed5fde1 | ||
|
|
5229dedb03 | ||
|
|
57ac56fcb3 | ||
|
|
7a7e9c0cbc | ||
|
|
392266c553 | ||
|
|
ab95f1b253 | ||
|
|
8dd1b2ee34 | ||
|
|
adb35f12b5 | ||
|
|
16f977fde0 | ||
|
|
7489107768 | ||
|
|
5d0845df90 | ||
|
|
880fca81da | ||
|
|
8ec69cdd35 | ||
|
|
5b9fcaefbf | ||
|
|
75c176991e | ||
|
|
34a26d1c14 | ||
|
|
5a1db875bc | ||
|
|
44a19e21d1 | ||
|
|
484873ed73 | ||
|
|
87397230e8 | ||
|
|
a256caaae7 | ||
|
|
30cff19a41 | ||
|
|
e2247fab85 | ||
|
|
871d9d8590 |
@@ -0,0 +1,20 @@
|
||||
## 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.
|
||||
@@ -172,6 +172,14 @@ 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
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""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 (namespace pending)
|
||||
## 2. Boundary placement
|
||||
|
||||
These tools belong to the GlitchTip observability boundary of the MCP Control
|
||||
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.
|
||||
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.
|
||||
|
||||
Fixed regardless of the name (per `tool-boundaries.md`,
|
||||
`credential-isolation.md`):
|
||||
@@ -160,11 +160,13 @@ Mocked-GlitchTip unit tests only, per `docs/developer-testing-guidelines.md`:
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement once:
|
||||
Ready to operate once:
|
||||
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
- **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 server boundary now contains gated trigger (see #56), but read tools remain as designed.
|
||||
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).
|
||||
|
||||
## 1. Purpose and scope
|
||||
|
||||
@@ -16,7 +18,9 @@ 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 gated behind dedicated profile + exact confirmation (landed in #4, boundary correction in #56).
|
||||
- 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).
|
||||
- **Excluded: deploy triggers.**
|
||||
- **Excluded: parameterized job launches.**
|
||||
- Excluded: job creation/deletion/config changes, queue manipulation, node
|
||||
@@ -107,7 +111,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 is read-only, no confirmation gates are needed — but
|
||||
- Since every tool on `jenkins-mcp` 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.
|
||||
|
||||
@@ -141,12 +145,16 @@ repo's conventions (`docs/developer-testing-guidelines.md`):
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement in `jenkins-mcp` once:
|
||||
Ready to operate through `jenkins-mcp` once:
|
||||
|
||||
1. ADR-0001 owner decision #1 (where `jenkins-mcp` lives) is made.
|
||||
2. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
|
||||
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
|
||||
hand-rolled to the same rules).
|
||||
3. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||
4. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||
deferred).
|
||||
|
||||
Explicitly **not** unlocked by this document: build triggers, deploys,
|
||||
|
||||
@@ -203,6 +203,29 @@ 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:
|
||||
@@ -275,6 +298,17 @@ 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,6 +30,11 @@ 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,
|
||||
@@ -224,6 +229,62 @@ 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).
|
||||
|
||||
## Branch worktree isolation
|
||||
|
||||
All LLM implementation and review work happens in an isolated branch worktree
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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 orchestrator that composes GlitchTip
|
||||
read tools with Gitea issue-write tools.
|
||||
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
|
||||
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
|
||||
@@ -21,5 +21,10 @@ 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** 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.
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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.
|
||||
+10
-2
@@ -163,12 +163,13 @@ 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):
|
||||
head_sha=None, reason=None, request_metadata=None, now=None,
|
||||
mcp_namespace=None, task_role=None, operation=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()
|
||||
return {
|
||||
event = {
|
||||
"timestamp": ts,
|
||||
"action": action,
|
||||
"action_type": "mutating",
|
||||
@@ -186,6 +187,13 @@ 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):
|
||||
|
||||
+9
-1
@@ -205,7 +205,7 @@ def selected_profile_name():
|
||||
|
||||
|
||||
def is_runtime_switching_enabled(path=None):
|
||||
"""Check if runtime profile switching is explicitly enabled in config."""
|
||||
"""Check if runtime profile switching is enabled in config."""
|
||||
try:
|
||||
config = load_config(path)
|
||||
except Exception:
|
||||
@@ -213,10 +213,18 @@ 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
|
||||
|
||||
|
||||
|
||||
+5147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
"""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": []}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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"})
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
}
|
||||
|
||||
|
||||
def assess_issue_lock_worktree(
|
||||
*,
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
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; "
|
||||
"lock must precede implementation work"
|
||||
)
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"current branch unknown (detached HEAD?); issue lock must be taken "
|
||||
f"from base branch ({_base_list(bases)})"
|
||||
)
|
||||
elif branch not in bases:
|
||||
reasons.append(
|
||||
f"issue lock must be taken from base branch ({_base_list(bases)}), "
|
||||
f"not '{branch}'"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(proven, reasons, path, branch or None, dirty_files)
|
||||
|
||||
|
||||
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],
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"worktree_path": worktree_path or None,
|
||||
"current_branch": current_branch,
|
||||
"dirty_files": dirty_files,
|
||||
}
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
#!/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())
|
||||
+41
-3482
File diff suppressed because it is too large
Load Diff
+16
-59
@@ -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.
|
||||
|
||||
Usage (review only):
|
||||
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):
|
||||
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 get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url
|
||||
from gitea_auth import add_remote_args
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -42,69 +42,26 @@ 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 "
|
||||
"submit a review only.",
|
||||
"see the review-submission guard message.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
host, org, repo = resolve_remote(args)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
+1445
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
"""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": []}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"""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" and check_mid_merge():
|
||||
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": [
|
||||
"infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
|
||||
"Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry."
|
||||
],
|
||||
"message": "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
|
||||
}
|
||||
_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 check_mid_merge() -> bool:
|
||||
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
git_dir = os.path.join(project_root, ".git")
|
||||
if os.path.exists(git_dir):
|
||||
if (os.path.exists(os.path.join(git_dir, "MERGE_HEAD"))
|
||||
or os.path.exists(os.path.join(git_dir, "rebase-merge"))
|
||||
or os.path.exists(os.path.join(git_dir, "rebase-apply"))):
|
||||
return True
|
||||
|
||||
return first_conflict_marker_path(project_root) is not None
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/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
|
||||
@@ -40,6 +40,16 @@ start_ref="${2:-prgs/master}"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
|
||||
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
|
||||
exit 2
|
||||
fi
|
||||
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
|
||||
if [[ "$branch" != "$locked_branch" ]]; then
|
||||
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|
||||
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
|
||||
:
|
||||
|
||||
@@ -53,10 +53,73 @@ Additional issue-first rules:
|
||||
owner decision.** Do not create a new repository or a new tracker unless
|
||||
explicitly approved by the owner.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
Use `review_proofs.classify_issue_for_selection` when reporting fresh issue
|
||||
selection (#188).
|
||||
|
||||
## 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.
|
||||
|
||||
## B. Isolated worktree rule
|
||||
|
||||
**Never implement or review in the main checkout.** The main checkout is for
|
||||
orchestration and status only (issue creation, `git status`, creating worktrees).
|
||||
**Never implement or review in the main checkout** (Global LLM Worktree Rule).
|
||||
The main checkout is for orchestration and status only (issue creation,
|
||||
`git status`, creating worktrees) and must remain on the stable branch.
|
||||
|
||||
- Each issue gets its own branch worktree under an ignored `branches/` directory.
|
||||
- Review work uses a **separate** review worktree, never the author's folder.
|
||||
@@ -142,10 +205,24 @@ Worktree folder = branch with `/` replaced by `-`
|
||||
|
||||
## E. Start-work workflow
|
||||
|
||||
1. Verify the orchestration checkout (right repo, clean tree).
|
||||
0. Acquire or verify a work lease (Work Selection Rule) — complete all seven
|
||||
checks before any claim, branch, or PR work.
|
||||
0b. Global LLM Worktree Rule — prove project root, `cwd`, branch, main-checkout
|
||||
stable branch, and session-owned `branches/` worktree path. If `cwd` is not
|
||||
under `branches/`, stop before any mutation (no exceptions).
|
||||
1. Verify the orchestration checkout (right repo, clean tree, on stable branch).
|
||||
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).
|
||||
4b. **Issue lock from your scratch clone (#249):** when using
|
||||
`gitea_lock_issue`, pass `worktree_path` pointing at your own clean
|
||||
scratch clone (or set `GITEA_AUTHOR_WORKTREE`). The lock gate validates
|
||||
*that* path — clean tree on `master`/`main`, no tracked edits yet —
|
||||
not the shared MCP/orchestration checkout. Another session's dirty
|
||||
feature branch in the shared dev worktree must not block your lock.
|
||||
Never stash, reset, or checkout files in the shared worktree to satisfy
|
||||
the gate. Pass the same `worktree_path` to `gitea_create_pr` so the PR
|
||||
gate matches the lock record.
|
||||
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.
|
||||
@@ -216,7 +293,10 @@ Worktree folder = branch with `/` replaced by `-`
|
||||
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`).
|
||||
`resolve_repos_from_user_reference`). Before inventory, reconcile the
|
||||
operator-supplied PR backlog against the target repo
|
||||
(`review_proofs.reconcile_queue_target`); never report `trusted_empty`
|
||||
for one repo while ignoring contradictory supplied PR numbers in another.
|
||||
7. **Role-boundary proof (#175):** a reviewer queue task must not silently
|
||||
become author implementation. If no eligible PR exists, stop with the
|
||||
queue report. Do not claim issues, create branches, commit, push, or open
|
||||
@@ -231,20 +311,48 @@ Worktree folder = branch with `/` replaced by `-`
|
||||
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`).
|
||||
During validation, review work is **read-only**: use
|
||||
`gitea_dry_run_pr_review` to prove submission mechanics — never post live
|
||||
APPROVE, REQUEST_CHANGES, or review comments to probe tool paths. After
|
||||
validation completes, call `gitea_mark_final_review_decision`, then submit
|
||||
exactly one live review via
|
||||
`gitea_submit_pr_review(..., final_review_decision_ready=True)`.
|
||||
After submitting, re-read `gitea_get_pr_review_feedback` and confirm the
|
||||
verdict is visible (`approval_visible` true for APPROVE; PENDING drafts do
|
||||
not count — #244). Do not merge until a visible APPROVED review exists.
|
||||
Final reports must list exactly one review mutation
|
||||
(`review_proofs.assess_review_mutation_final_report`) unless an
|
||||
operator-approved correction flow was invoked and explained.
|
||||
10. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
|
||||
11. The final report must distinguish (`review_proofs.build_final_report`):
|
||||
11. **#179 A-bar proofs** (all fail closed when missing —
|
||||
`review_proofs.assess_capability_evidence`, `assess_sweep_evidence`,
|
||||
`assess_live_state_recheck`, `assess_role_boundary`):
|
||||
- Capability claims must cite exact `gitea_resolve_task_capability`
|
||||
output (or runtime context); a bare "capability checks passed" is
|
||||
downgraded.
|
||||
- The secret/provenance sweep must state the exact command/script/
|
||||
pattern/named method and the scope scanned.
|
||||
- Immediately before submitting a review verdict (and again before any
|
||||
merge), re-read live PR state and prove: still open, live head ==
|
||||
pinned head, base unchanged, no unresolved blocking review state.
|
||||
- Reviewer runs stay in the reviewer namespace; any author-namespace
|
||||
call requires an explicit justification in the report.
|
||||
12. The final report must distinguish (`review_proofs.build_final_report`):
|
||||
identity eligible; PR author different from reviewer; session
|
||||
contamination absent (with evidence); role boundary clean; 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.
|
||||
contamination absent (with evidence); validation performed on the pinned
|
||||
head; capability evidence; sweep verdict; live-state recheck; role
|
||||
boundary; 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
|
||||
the authenticated identity **and** the PR author; cite exact capability
|
||||
evidence for merge_pr (#179); respect runtime profile gates; run independent
|
||||
validation (do not trust the author's reported results); perform the **final
|
||||
live-state recheck** (#179 — PR still open, live head == pinned head, base
|
||||
unchanged, no unresolved blocking review state) immediately before the merge
|
||||
mutation; 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:
|
||||
|
||||
@@ -283,7 +391,9 @@ When in doubt, stop and surface the discrepancy; do not guess or work around a g
|
||||
## 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.
|
||||
own new worktree; unrelated dirty work must not block you. For Gitea-Tools
|
||||
author flows, lock the issue from your scratch clone (`worktree_path` on
|
||||
`gitea_lock_issue`) — do not manipulate the shared dev checkout.
|
||||
- **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
|
||||
@@ -316,11 +426,13 @@ Ready-to-copy templates live in [`templates/`](templates/):
|
||||
|
||||
## K. Controller Handoff (required, every task)
|
||||
|
||||
Every LLM task **must end with a `Controller Handoff`** — whether the
|
||||
Every LLM task **must end with a `Controller Handoff`** (exact title) — 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 section title must be exactly "Controller Handoff" (or "Controller Handoff Summary" for long form). Reports without it are downgraded (see review_proofs.assess_controller_handoff).
|
||||
|
||||
**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.
|
||||
@@ -352,6 +464,12 @@ Role-specific fields (append to the compact block):
|
||||
`Linked issue status:`, `Cleanup status:`
|
||||
- author tasks: `Selected issue:`, `Claim/comment status:`,
|
||||
`PR number opened:`, `No review/merge:` (explicit confirmation)
|
||||
- continuation tasks (#188): `Continuation mode:`, `Existing PR:`,
|
||||
`PR author:`, `Branch:`, `Old PR head:`, `New PR head:`,
|
||||
`Session authored PR:`, `Why continuation allowed:` — issues with open
|
||||
PRs are excluded from fresh selection unless operator explicitly requests
|
||||
continuation (`review_proofs.classify_issue_for_selection`,
|
||||
`assess_issue_selection_final_report`)
|
||||
- queue/inventory tasks: `Repositories checked:`, `Open PR counts:`,
|
||||
`Selected PR or reason none selected:`, `Inventory completeness:`
|
||||
|
||||
|
||||
@@ -20,10 +20,21 @@ 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. 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).
|
||||
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).
|
||||
*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,9 +17,32 @@ 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.
|
||||
|
||||
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).
|
||||
@@ -36,6 +59,11 @@ 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).
|
||||
@@ -58,12 +86,23 @@ 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. 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. 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.
|
||||
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||
|
||||
Review Metadata:
|
||||
|
||||
@@ -13,6 +13,21 @@ 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>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
# 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])
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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
|
||||
+44
-12
@@ -162,7 +162,8 @@ 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_ALLOWED_OPERATIONS": (
|
||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
@@ -175,9 +176,12 @@ 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, mock_api):
|
||||
def test_create_issue_success_audited(self, _auth, _get_all, mock_api, _role):
|
||||
# 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"},
|
||||
@@ -196,9 +200,12 @@ 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, mock_api):
|
||||
def test_create_issue_failure_audited(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.side_effect = [
|
||||
RuntimeError("HTTP 500: boom"),
|
||||
{"login": "author-bot"}, # identity lookup for the audit record
|
||||
@@ -223,19 +230,31 @@ 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, mock_api):
|
||||
# No GITEA_AUDIT_LOG -> audit is a no-op: exactly one API call, no file.
|
||||
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.
|
||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||
with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-author"}, clear=True):
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||
}, clear=True):
|
||||
gitea_create_issue(title="x", remote="prgs")
|
||||
mock_api.assert_called_once()
|
||||
issue_posts = [
|
||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||
]
|
||||
self.assertEqual(len(issue_posts), 1)
|
||||
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, mock_api):
|
||||
def test_secrets_never_written(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.side_effect = [
|
||||
{"number": 3, "html_url": "http://x/3"},
|
||||
{"login": "author-bot"},
|
||||
@@ -248,9 +267,12 @@ 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, mock_api):
|
||||
def test_audit_failure_never_breaks_action(self, _auth, _get_all, mock_api, _role):
|
||||
mock_api.side_effect = [
|
||||
{"number": 9, "html_url": "http://x/9"},
|
||||
{"login": "author-bot"},
|
||||
@@ -271,9 +293,12 @@ 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, merge POST, readback — no extra identity call (uses result).
|
||||
# user, pr, feedback pr+reviews, merge POST, readback.
|
||||
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",
|
||||
@@ -310,13 +335,20 @@ 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},
|
||||
{"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}],
|
||||
]
|
||||
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")
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
self.assertTrue(r["performed"])
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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_get_all", return_value=[])
|
||||
@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, _get_all,
|
||||
):
|
||||
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, [])
|
||||
|
||||
@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()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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()
|
||||
@@ -107,6 +107,24 @@ 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")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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_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
|
||||
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
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.check_mid_merge", return_value=True)
|
||||
@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_check):
|
||||
res = gitea_route_task_session("review_pr")
|
||||
self.assertEqual(res["route_result"], "infra_stop")
|
||||
self.assertIn("infra_stop", res["message"])
|
||||
|
||||
@patch("role_session_router.check_mid_merge", return_value=True)
|
||||
@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_check
|
||||
):
|
||||
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"])
|
||||
@@ -0,0 +1,179 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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("issue lock must be taken from base branch", 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()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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()
|
||||
@@ -125,14 +125,17 @@ 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 (eligibility)
|
||||
{"state": "open", "head": {"sha": "abc1234"}, "mergeable": True, "user": {"login": "jcwalker3"}} # /pulls/9 (eligibility)
|
||||
{"login": "jcwalker3"}, # /user (submit eligibility)
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||
]
|
||||
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")
|
||||
remote="prgs", final_review_decision_ready=True)
|
||||
self.assertFalse(r["success"])
|
||||
self.assertIn("authenticated user is PR author", r["message"])
|
||||
for call in mock_api.call_args_list:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""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()
|
||||
+1049
-56
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
"""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()
|
||||
@@ -132,7 +132,8 @@ class TestControlPlaneGuide(GuideTestBase):
|
||||
rules = g["rules"]
|
||||
for key in ("hard_stops", "fail_closed", "head_sha_pinning",
|
||||
"merge_confirmation", "redaction", "separation",
|
||||
"profile_switching", "identity_verification"):
|
||||
"profile_switching", "identity_verification",
|
||||
"work_selection", "global_worktree"):
|
||||
self.assertIn(key, rules)
|
||||
self.assertIn("MERGE PR", json.dumps(rules["merge_confirmation"]))
|
||||
self.assertTrue(rules["hard_stops"])
|
||||
@@ -234,8 +235,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.assertNotEqual(by_name["jenkins-mcp"]["status"], "available")
|
||||
self.assertNotEqual(by_name["glitchtip-mcp"]["status"], "available")
|
||||
self.assertEqual(by_name["jenkins-mcp"]["status"], "external-mcp")
|
||||
self.assertEqual(by_name["glitchtip-mcp"]["status"], "external-mcp")
|
||||
|
||||
def test_no_urls_in_registry(self):
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
@@ -246,16 +247,29 @@ class TestProjectSkills(GuideTestBase):
|
||||
self.assertNotIn("keychain:", blob)
|
||||
|
||||
def test_enabled_but_no_usable_tools_negative_assertion(self):
|
||||
"""Negative assertion for 'enabled but no usable tools' (per issue #146)."""
|
||||
"""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 designed-not-implemented; even if "enabled" in config,
|
||||
# it should not be usable/available to current profile without tools.
|
||||
# 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"], "designed-not-implemented")
|
||||
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,9 +229,12 @@ 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")
|
||||
pr_number=42, action="approve", body="lgtm", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
self.assertFalse(res["performed"])
|
||||
self.assertIn("permission_report", res)
|
||||
self.assertEqual(res["permission_report"]["missing_permission"],
|
||||
@@ -268,9 +271,12 @@ 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")
|
||||
pr_number=42, action="comment", body="finding", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
self.assertFalse(res["performed"])
|
||||
report = res["permission_report"]
|
||||
self._assert_report_safe(report)
|
||||
|
||||
@@ -119,15 +119,34 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
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: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility), 4) /pulls/1/reviews (POST review)
|
||||
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
|
||||
# POST review (#244: state + visible-verdict GET reviews).
|
||||
mock_api.side_effect = [
|
||||
{"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
|
||||
{"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,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
||||
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)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
||||
self.assertIn("Repository:", result["message"])
|
||||
@@ -258,12 +277,79 @@ 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_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_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
|
||||
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
|
||||
self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url
|
||||
):
|
||||
mock_get_profile.return_value = {
|
||||
"profile_name": "gitea-author",
|
||||
"allowed_operations": ["read"],
|
||||
"forbidden_operations": [],
|
||||
"base_url": None,
|
||||
}
|
||||
mock_get_all.return_value = []
|
||||
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_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_trusted_empty_when_gate_passes(
|
||||
self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url
|
||||
):
|
||||
mock_get_profile.return_value = {
|
||||
"profile_name": "gitea-author",
|
||||
"allowed_operations": ["read"],
|
||||
"forbidden_operations": [],
|
||||
"base_url": None,
|
||||
}
|
||||
mock_get_all.return_value = []
|
||||
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_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_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url):
|
||||
"""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",
|
||||
|
||||
@@ -130,6 +130,17 @@ 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"])
|
||||
|
||||
def test_resolve_unknown_task_fails_closed(self):
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -203,5 +214,67 @@ 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()
|
||||
|
||||
+60
-45
@@ -2,6 +2,8 @@
|
||||
|
||||
Mocks api_request and credentials.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -27,45 +29,38 @@ FAKE_PR_DATA = {
|
||||
|
||||
class TestArgParsing(unittest.TestCase):
|
||||
|
||||
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
|
||||
def test_missing_pr_number_exits(self, _auth):
|
||||
def setUp(self):
|
||||
self.exists_patcher = patch("os.path.exists", return_value=False)
|
||||
self.exists_patcher.start()
|
||||
self.addCleanup(self.exists_patcher.stop)
|
||||
|
||||
def test_missing_pr_number_exits(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
review_pr.main([])
|
||||
|
||||
|
||||
class TestAPIPayload(unittest.TestCase):
|
||||
|
||||
@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 setUp(self):
|
||||
self.exists_patcher = patch("os.path.exists", return_value=False)
|
||||
self.exists_patcher.start()
|
||||
self.addCleanup(self.exists_patcher.stop)
|
||||
|
||||
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")
|
||||
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())
|
||||
|
||||
# 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):
|
||||
def test_merge_flag_fails_closed_without_api_call(self):
|
||||
# --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([
|
||||
@@ -76,28 +71,48 @@ 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
|
||||
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()
|
||||
buf = io.StringIO()
|
||||
with patch.object(sys, "stderr", buf):
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81", "--event", "APPROVE", "--merge",
|
||||
])
|
||||
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."""
|
||||
|
||||
def test_cli_disabled_on_session_lock(self):
|
||||
# When running inside an MCP session, direct review submission via CLI is disabled entirely.
|
||||
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)
|
||||
|
||||
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):
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81", "--event", "APPROVE",
|
||||
])
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+1055
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""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):
|
||||
worktree_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
main_root = os.path.dirname(os.path.dirname(worktree_root))
|
||||
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,9 +95,13 @@ 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):
|
||||
def test_get_runtime_context_author(self, _auth, _api, _preflight):
|
||||
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")
|
||||
@@ -110,10 +114,26 @@ 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):
|
||||
def test_get_runtime_context_reviewer(self, _auth, _api, _preflight):
|
||||
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")
|
||||
@@ -121,6 +141,15 @@ 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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Checks for the Gitea Wiki sync helper (issue #224).
|
||||
|
||||
The repo-tracked ``docs/wiki/`` stays the source of truth; the helper
|
||||
mirrors it into the Gitea native wiki only when explicitly invoked.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "scripts" / "sync-gitea-wiki.sh"
|
||||
|
||||
REQUIRED_PAGES = [
|
||||
"Home.md",
|
||||
"Operator-Guide.md",
|
||||
"Repositories.md",
|
||||
"Identity-and-Profiles.md",
|
||||
"Workflow.md",
|
||||
"Safety-and-Gates.md",
|
||||
"MCP-Tools.md",
|
||||
"Runbooks.md",
|
||||
"Open-Decisions.md",
|
||||
"History.md",
|
||||
]
|
||||
|
||||
|
||||
def _run(*args, env_extra=None):
|
||||
env = dict(os.environ)
|
||||
env.pop("GITEA_WIKI_SYNC_CONFIRM", None)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
[str(SCRIPT), *args], capture_output=True, text=True, env=env,
|
||||
cwd=str(REPO_ROOT), timeout=30)
|
||||
|
||||
|
||||
def test_script_exists_and_is_executable():
|
||||
assert SCRIPT.is_file(), "missing scripts/sync-gitea-wiki.sh"
|
||||
assert os.access(SCRIPT, os.X_OK), "sync script is not executable"
|
||||
|
||||
|
||||
def test_dry_run_is_default_and_lists_every_page():
|
||||
result = _run()
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = result.stdout
|
||||
assert "dry-run" in out.lower()
|
||||
for page in REQUIRED_PAGES:
|
||||
assert page in out, f"dry-run plan does not list {page}"
|
||||
|
||||
|
||||
def test_dry_run_does_not_push_or_clone():
|
||||
result = _run()
|
||||
blob = (result.stdout + result.stderr).lower()
|
||||
assert "pushing" not in blob
|
||||
assert "cloning" not in blob
|
||||
|
||||
|
||||
def test_push_refused_without_exact_confirmation():
|
||||
result = _run("--push")
|
||||
assert result.returncode != 0
|
||||
blob = result.stdout + result.stderr
|
||||
assert "GITEA_WIKI_SYNC_CONFIRM" in blob
|
||||
assert "cloning" not in blob.lower()
|
||||
|
||||
|
||||
def test_push_refused_with_wrong_confirmation():
|
||||
result = _run("--push", env_extra={"GITEA_WIKI_SYNC_CONFIRM": "yes please"})
|
||||
assert result.returncode != 0
|
||||
assert "cloning" not in (result.stdout + result.stderr).lower()
|
||||
|
||||
|
||||
def test_script_embeds_no_service_urls_or_secrets():
|
||||
text = SCRIPT.read_text(encoding="utf-8")
|
||||
for marker in ("http://", "https://", "token ", "Authorization", "ghp_", "keychain"):
|
||||
assert marker not in text, f"script embeds {marker!r}"
|
||||
|
||||
|
||||
def test_runbook_documents_wiki_sync():
|
||||
runbooks = (REPO_ROOT / "docs" / "wiki" / "Runbooks.md").read_text(encoding="utf-8")
|
||||
assert "sync-gitea-wiki" in runbooks
|
||||
home = (REPO_ROOT / "docs" / "wiki" / "Home.md").read_text(encoding="utf-8")
|
||||
assert "source of truth" in home
|
||||
template = (REPO_ROOT / ".gitea" / "pull_request_template.md").read_text(encoding="utf-8")
|
||||
assert "sync-gitea-wiki" in template
|
||||
|
||||
|
||||
def test_wiki_pages_have_no_local_file_links():
|
||||
for path in (REPO_ROOT / "docs" / "wiki").glob("*.md"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "file://" not in text, f"{path.name} contains a file:// link"
|
||||
|
||||
|
||||
def test_pr_template_carries_wiki_publication_gate():
|
||||
template = (REPO_ROOT / ".gitea" / "pull_request_template.md").read_text(encoding="utf-8")
|
||||
assert "Wiki tab" in template
|
||||
assert "#224" in template
|
||||
assert "not sufficient to close a wiki issue" in template
|
||||
|
||||
|
||||
def test_runbooks_document_publication_gate_and_closure_rule():
|
||||
runbooks = (REPO_ROOT / "docs" / "wiki" / "Runbooks.md").read_text(encoding="utf-8")
|
||||
assert "Wiki Publication Readiness Gate (#224)" in runbooks or "Wiki publication readiness gate (#224)" in runbooks.lower()
|
||||
assert "Closure prevention" in runbooks
|
||||
assert "GITEA_WIKI_SYNC_CONFIRM" in runbooks
|
||||
|
||||
|
||||
def test_repositories_map_tracks_wiki_publication_status():
|
||||
repositories = (REPO_ROOT / "docs" / "wiki" / "Repositories.md").read_text(encoding="utf-8")
|
||||
assert "Wiki publication status" in repositories or "Wiki Publication Status" in repositories
|
||||
for repo in ("mcp-control-plane", "Gitea-Tools"):
|
||||
assert repo in repositories
|
||||
assert "Gitea Wiki published" in repositories
|
||||
+33
-5
@@ -14,11 +14,39 @@ BRANCHES = REPO / "branches"
|
||||
|
||||
|
||||
def run(script, *args):
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPTS / script), *args],
|
||||
capture_output=True, text=True, cwd=str(REPO),
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
branch = None
|
||||
for arg in args:
|
||||
if not arg.startswith("-"):
|
||||
branch = arg
|
||||
break
|
||||
|
||||
lock_file = Path("/tmp/gitea_issue_lock.json")
|
||||
created_lock = False
|
||||
if script == "worktree-start" and branch:
|
||||
import re
|
||||
import json
|
||||
m = re.search(r"issue-(\d+)", branch)
|
||||
if not m:
|
||||
m = re.search(r"pr-(\d+)", branch)
|
||||
issue_num = int(m.group(1)) if m else 999
|
||||
lock_file.write_text(json.dumps({
|
||||
"issue_number": issue_num,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools"
|
||||
}), encoding="utf-8")
|
||||
created_lock = True
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPTS / script), *args],
|
||||
capture_output=True, text=True, cwd=str(REPO),
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
finally:
|
||||
if created_lock and lock_file.exists():
|
||||
lock_file.unlink()
|
||||
|
||||
|
||||
class TestWorktreeStart(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user