Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d2087b4a | ||
|
|
61c0d73cd1 | ||
|
|
6a0d7bbef4 | ||
|
|
29d96c8946 | ||
|
|
8d8d2d8f81 | ||
|
|
0d8a2c2b1d | ||
|
|
11d1d2e99f | ||
|
|
3990fc684f | ||
|
|
daf60266d0 | ||
|
|
7e5eca08b3 | ||
|
|
39e27dc63f | ||
|
|
7eb4884658 | ||
|
|
3e761206e5 | ||
|
|
0801e2455b | ||
|
|
ec2433f13b | ||
|
|
899ef8ec7f | ||
|
|
58d6b5844f | ||
|
|
c1c61e9b15 | ||
|
|
fca3296883 | ||
|
|
d7b0b0e772 | ||
|
|
83bc7246ff | ||
|
|
29c2cf2ef6 | ||
|
|
4a160e0e0c | ||
|
|
14d885b2d2 | ||
|
|
a3dbf668fd | ||
|
|
fb9191e559 | ||
|
|
d867acd9db | ||
|
|
98fbfb3de7 | ||
|
|
8814c04e3a | ||
|
|
f34ec86b90 | ||
|
|
7cb65028fd | ||
|
|
4f894009f6 | ||
|
|
99640f90d0 | ||
|
|
1421fa8568 | ||
|
|
c780ded653 | ||
|
|
dc899d23c8 | ||
|
|
943d40270e | ||
|
|
d936da5c87 | ||
|
|
29ffe1407f | ||
|
|
632c568865 | ||
|
|
23e366d9d6 |
@@ -0,0 +1,267 @@
|
|||||||
|
"""Immutable canonical repository root for cross-repository namespaces (#706).
|
||||||
|
|
||||||
|
The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from
|
||||||
|
the *install checkout* the server script lives in
|
||||||
|
(``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace
|
||||||
|
that runs the same server script against an *external* repository (e.g.
|
||||||
|
``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the
|
||||||
|
branches-only / worktree-membership guards (#274) compared the task workspace
|
||||||
|
against the Gitea-Tools ``.git`` directory, which it can never belong to.
|
||||||
|
|
||||||
|
This module separates two distinct concepts:
|
||||||
|
|
||||||
|
* the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and
|
||||||
|
* the namespace-scoped **canonical repository root** — the working root of the
|
||||||
|
repository whose issues/PRs the namespace mutates.
|
||||||
|
|
||||||
|
The canonical repository root is configured per namespace (profile field or an
|
||||||
|
environment variable, typically set alongside the namespace ``cwd`` in the MCP
|
||||||
|
config). It is validated (existence, git identity, git common-directory
|
||||||
|
membership) and pinned immutably into the session context so a later call cannot
|
||||||
|
forge or swap it. When *no* binding is configured the single-repo default is
|
||||||
|
preserved unchanged: the canonical root is derived from the process checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
import remote_repo_guard
|
||||||
|
|
||||||
|
# Namespace-scoped override, typically exported next to the server ``cwd`` in the
|
||||||
|
# MCP config for a cross-repository namespace.
|
||||||
|
CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT"
|
||||||
|
|
||||||
|
# Candidate git remote names probed when deriving repository identity.
|
||||||
|
_IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps")
|
||||||
|
|
||||||
|
|
||||||
|
def configured_canonical_root(
|
||||||
|
profile: Mapping | None,
|
||||||
|
env: Mapping | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Return ``(value, source)`` for the declared canonical repository root.
|
||||||
|
|
||||||
|
Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable
|
||||||
|
(namespace-scoped) overrides the profile ``canonical_repository_root``
|
||||||
|
field. Blank values are treated as unset. Returns ``(None, None)`` when no
|
||||||
|
binding is declared (the single-repo default).
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip()
|
||||||
|
if env_val:
|
||||||
|
return env_val, f"{CANONICAL_ROOT_ENV} environment variable"
|
||||||
|
if profile:
|
||||||
|
prof_val = (profile.get("canonical_repository_root") or "").strip()
|
||||||
|
if prof_val:
|
||||||
|
return prof_val, "profile canonical_repository_root"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_toplevel(path: str) -> str | None:
|
||||||
|
"""Realpath of the git working-tree top level for *path*, or None."""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", text, "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
top = (res.stdout or "").strip()
|
||||||
|
return os.path.realpath(top) if top else None
|
||||||
|
|
||||||
|
|
||||||
|
def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None:
|
||||||
|
"""``owner/repository`` derived from a git remote configured at *path*.
|
||||||
|
|
||||||
|
Tries the caller-named remote first, then a small set of known remote names,
|
||||||
|
then whatever remote the repository actually has. Returns None when no remote
|
||||||
|
URL is parseable (identity cannot be proven).
|
||||||
|
"""
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ordered: list[str] = []
|
||||||
|
for name in (remote, *_IDENTITY_REMOTE_CANDIDATES):
|
||||||
|
clean = (name or "").strip()
|
||||||
|
if clean and clean not in ordered:
|
||||||
|
ordered.append(clean)
|
||||||
|
|
||||||
|
try:
|
||||||
|
listed = subprocess.run(
|
||||||
|
["git", "-C", text, "remote"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.split()
|
||||||
|
except Exception:
|
||||||
|
listed = []
|
||||||
|
for name in listed:
|
||||||
|
if name and name not in ordered:
|
||||||
|
ordered.append(name)
|
||||||
|
|
||||||
|
for name in ordered:
|
||||||
|
try:
|
||||||
|
url = subprocess.run(
|
||||||
|
["git", "-C", text, "remote", "get-url", name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
|
if parsed:
|
||||||
|
return f"{parsed[0]}/{parsed[1]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_canonical_repository_root(
|
||||||
|
*,
|
||||||
|
configured_value: str | None,
|
||||||
|
source: str | None,
|
||||||
|
expected_slug: str | None,
|
||||||
|
process_project_root: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
require_binding: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Validate the canonical repository root binding, failing closed on forgery.
|
||||||
|
|
||||||
|
Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved
|
||||||
|
``canonical_repo_root`` (the value downstream guards must use),
|
||||||
|
``configured`` (whether a cross-repo binding was declared),
|
||||||
|
``resolved_slug`` and ``source``.
|
||||||
|
|
||||||
|
Without a configured binding the single-repo default is preserved: the
|
||||||
|
canonical root is derived from *process_project_root* and never blocks
|
||||||
|
(unless *require_binding* explicitly demands one).
|
||||||
|
|
||||||
|
With a configured binding the path must exist, be a git repository, and —
|
||||||
|
when *expected_slug* is known — carry a matching repository identity. A
|
||||||
|
mismatched or (when *require_binding*) unprovable identity is a forged or
|
||||||
|
conflicting binding and fails closed.
|
||||||
|
"""
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
declared = (configured_value or "").strip()
|
||||||
|
|
||||||
|
if not declared:
|
||||||
|
if require_binding:
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
"no canonical_repository_root configured for a cross-repository "
|
||||||
|
f"namespace; set {CANONICAL_ROOT_ENV} or the profile "
|
||||||
|
"canonical_repository_root field (fail closed)"
|
||||||
|
],
|
||||||
|
configured=False,
|
||||||
|
canonical_repo_root=process_root,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
# Single-repo default: canonical root follows the install checkout.
|
||||||
|
derived = resolve_repo_toplevel(process_root) or process_root
|
||||||
|
return _assessment(
|
||||||
|
proven=True,
|
||||||
|
reasons=[],
|
||||||
|
configured=False,
|
||||||
|
canonical_repo_root=derived,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
real = os.path.realpath(os.path.abspath(declared))
|
||||||
|
if not os.path.isdir(real):
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"configured canonical repository root '{real}' does not exist "
|
||||||
|
"or is not a directory (fail closed)"
|
||||||
|
],
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=real,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
toplevel = resolve_repo_toplevel(real)
|
||||||
|
if not toplevel:
|
||||||
|
return _assessment(
|
||||||
|
proven=False,
|
||||||
|
reasons=[
|
||||||
|
f"configured canonical repository root '{real}' is not a git "
|
||||||
|
"repository (fail closed)"
|
||||||
|
],
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=real,
|
||||||
|
resolved_slug=None,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_slug = repository_identity_slug(toplevel, remote=remote)
|
||||||
|
reasons: list[str] = []
|
||||||
|
expected = (expected_slug or "").strip() or None
|
||||||
|
if expected:
|
||||||
|
if resolved_slug and resolved_slug.lower() != expected.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root identity mismatch: '{toplevel}' resolves "
|
||||||
|
f"to repository '{resolved_slug}' but the session is authorized for "
|
||||||
|
f"'{expected}' (forged or conflicting binding, fail closed)"
|
||||||
|
)
|
||||||
|
elif not resolved_slug and require_binding:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root '{toplevel}' has no resolvable git "
|
||||||
|
f"remote identity to confirm authorization for '{expected}' "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assessment(
|
||||||
|
proven=not reasons,
|
||||||
|
reasons=reasons,
|
||||||
|
configured=True,
|
||||||
|
canonical_repo_root=toplevel,
|
||||||
|
resolved_slug=resolved_slug,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_canonical_repository_root_error(assessment: Mapping) -> str:
|
||||||
|
"""Single RuntimeError message for MCP preflight gates."""
|
||||||
|
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||||
|
source = assessment.get("source") or "(unconfigured)"
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons") or ["unknown canonical repository root violation"]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Canonical repository root guard (#706): {reasons}. "
|
||||||
|
f"binding source: {source}; canonical repository root: {root}. "
|
||||||
|
"Configure a valid canonical_repository_root for the target repository "
|
||||||
|
"and relaunch; do not point it at the Gitea-Tools install checkout."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
*,
|
||||||
|
proven: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
configured: bool,
|
||||||
|
canonical_repo_root: str,
|
||||||
|
resolved_slug: str | None,
|
||||||
|
source: str | None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"configured": configured,
|
||||||
|
"canonical_repo_root": canonical_repo_root,
|
||||||
|
"resolved_slug": resolved_slug,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||||
|
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
name), never the token itself. Token values are never part of a profile object,
|
name), never the token itself. Token values are never part of a profile object,
|
||||||
never logged, never returned by a tool, and never committed.
|
never logged, never returned by a tool, and never committed.
|
||||||
|
|
||||||
|
## Repository scope (#714)
|
||||||
|
|
||||||
|
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||||
|
may operate on:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"prgs-author": {
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is an **authorization boundary, not the session binding**. The binding is
|
||||||
|
derived and enforced like this:
|
||||||
|
|
||||||
|
1. The session repository is derived from the **verified, workspace-aligned git
|
||||||
|
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||||
|
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||||
|
`Timesheet`, which is not this project).
|
||||||
|
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||||
|
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||||
|
organization is derived from the slug; there is no independent,
|
||||||
|
caller-controlled organization value.
|
||||||
|
4. If a profile authorizes several repositories, the verified workspace still
|
||||||
|
selects exactly one. A session never binds to the whole list and never
|
||||||
|
switches between entries.
|
||||||
|
|
||||||
|
Fail-closed rules:
|
||||||
|
|
||||||
|
- Activation is rejected when the workspace repository is absent from the
|
||||||
|
allowlist.
|
||||||
|
- A mutation is rejected when no verified workspace repository can be
|
||||||
|
established.
|
||||||
|
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||||
|
before the mutation runs.
|
||||||
|
- A mutation request can never establish, complete, or replace the binding.
|
||||||
|
|
||||||
|
The field is **config-only**: no environment variable can widen or forge it. It
|
||||||
|
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||||
|
static-profile namespaces stay functional until an operator provisions the
|
||||||
|
scope. Provisioning it is what activates enforcement for that profile.
|
||||||
|
|
||||||
## Example profiles
|
## Example profiles
|
||||||
|
|
||||||
The following are the reference profiles. Booleans express intended capability
|
The following are the reference profiles. Booleans express intended capability
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"execution_profile": "example-author",
|
"execution_profile": "example-author",
|
||||||
"audit_label": "example-author",
|
"audit_label": "example-author",
|
||||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||||
},
|
},
|
||||||
|
|||||||
+51
-3
@@ -182,11 +182,51 @@ def get_auth_header(host):
|
|||||||
|
|
||||||
def resolve_remote(args):
|
def resolve_remote(args):
|
||||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||||
return (host, org, repo) with overrides applied."""
|
return (host, org, repo) with overrides applied.
|
||||||
|
|
||||||
|
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||||
|
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||||
|
``--remote prgs`` must not force Timesheet when the checkout is
|
||||||
|
Gitea-Tools). Explicit --org/--repo always win.
|
||||||
|
"""
|
||||||
profile = REMOTES[args.remote]
|
profile = REMOTES[args.remote]
|
||||||
host = args.host or profile["host"]
|
host = args.host or profile["host"]
|
||||||
org = args.org or profile["org"]
|
org_explicit = getattr(args, "org", None) is not None
|
||||||
repo = args.repo or profile["repo"]
|
repo_explicit = getattr(args, "repo", None) is not None
|
||||||
|
org = args.org if org_explicit else profile["org"]
|
||||||
|
repo = args.repo if repo_explicit else profile["repo"]
|
||||||
|
if not org_explicit or not repo_explicit:
|
||||||
|
try:
|
||||||
|
import remote_repo_guard
|
||||||
|
import subprocess
|
||||||
|
# Prefer the named remote URL when present; fall back to origin.
|
||||||
|
url = None
|
||||||
|
for remote_name in (args.remote, "origin"):
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "remote", "get-url", remote_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||||
|
url = proc.stdout.strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Allow tests to inject a deterministic remote URL without Git.
|
||||||
|
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||||
|
if env_url:
|
||||||
|
url = env_url
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
|
if parsed:
|
||||||
|
if not org_explicit:
|
||||||
|
org = parsed[0]
|
||||||
|
if not repo_explicit:
|
||||||
|
repo = parsed[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return host, org, repo
|
return host, org, repo
|
||||||
|
|
||||||
|
|
||||||
@@ -758,6 +798,14 @@ def get_profile():
|
|||||||
"profile_name": name,
|
"profile_name": name,
|
||||||
"allowed_operations": ops,
|
"allowed_operations": ops,
|
||||||
"forbidden_operations": forbidden,
|
"forbidden_operations": forbidden,
|
||||||
|
# #714 repository authorization boundary. Config-only on purpose: an
|
||||||
|
# environment variable must never widen or forge the set of
|
||||||
|
# repositories a session may bind to.
|
||||||
|
"allowed_repositories": _json_list("allowed_repositories"),
|
||||||
|
# #706 cross-repository canonical root binding. Config-sourced here (the
|
||||||
|
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
|
||||||
|
# by canonical_repository_root.configured_canonical_root, not widened here).
|
||||||
|
"canonical_repository_root": jp.get("canonical_repository_root") or None,
|
||||||
"audit_label": audit_label,
|
"audit_label": audit_label,
|
||||||
"token_source_name": token_source,
|
"token_source_name": token_source,
|
||||||
"auth_source_type": auth_type,
|
"auth_source_type": auth_type,
|
||||||
|
|||||||
@@ -475,6 +475,57 @@ def _require_enabled(kind, name, obj):
|
|||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_allowed_repositories(name, raw):
|
||||||
|
"""Validate the optional per-profile repository authorization scope (#714).
|
||||||
|
|
||||||
|
``allowed_repositories`` is an authorization boundary of canonical
|
||||||
|
``owner/repository`` slugs. It is not the session binding: the verified
|
||||||
|
workspace repository selects exactly one entry at bind time. Absent means
|
||||||
|
"no repository scope configured" and is allowed, so existing profiles keep
|
||||||
|
working until an operator provisions the field.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories must be a list of "
|
||||||
|
"'owner/repository' strings"
|
||||||
|
)
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||||
|
"a canonical 'owner/repository' slug"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_canonical_repository_root(name, raw):
|
||||||
|
"""Validate the optional per-profile canonical repository root (#706).
|
||||||
|
|
||||||
|
``canonical_repository_root`` binds a cross-repository namespace to the
|
||||||
|
working root of its target repository (separate from the immutable
|
||||||
|
Gitea-Tools install checkout). It is an absolute filesystem path; existence
|
||||||
|
and git identity are validated at bind time by the runtime guard, not here
|
||||||
|
(config validation stays filesystem-independent). Absent means the
|
||||||
|
single-repo default and is allowed.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' canonical_repository_root must be a non-empty "
|
||||||
|
"absolute path string to the target repository working root"
|
||||||
|
)
|
||||||
|
if not os.path.isabs(raw.strip()):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' canonical_repository_root {raw!r} must be an "
|
||||||
|
"absolute path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reject_inline_secrets(kind, name, obj):
|
def _reject_inline_secrets(kind, name, obj):
|
||||||
for key in _INLINE_SECRET_KEYS:
|
for key in _INLINE_SECRET_KEYS:
|
||||||
if key in obj:
|
if key in obj:
|
||||||
@@ -549,6 +600,10 @@ def _load_v2_contexts(data, path):
|
|||||||
forbidden = raw.get("forbidden_operations") or []
|
forbidden = raw.get("forbidden_operations") or []
|
||||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||||
|
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||||
|
_validate_canonical_repository_root(
|
||||||
|
name, raw.get("canonical_repository_root")
|
||||||
|
)
|
||||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||||
|
|||||||
+2194
-275
File diff suppressed because it is too large
Load Diff
+185
-2
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ REASON_CONFIG_ERROR = "config_error"
|
|||||||
REASON_INTERNAL_ERROR = "internal_error"
|
REASON_INTERNAL_ERROR = "internal_error"
|
||||||
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||||
REASON_HTTP_ERROR = "http_error"
|
REASON_HTTP_ERROR = "http_error"
|
||||||
|
# Typed structured errors for previously-opaque raw mutation failures.
|
||||||
|
REASON_PREFLIGHT_ORDER = "preflight_order_violation"
|
||||||
|
REASON_MALFORMED_RESPONSE = "malformed_response"
|
||||||
|
|
||||||
ERROR_CLASS_AUTHENTICATION = "authentication"
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
ERROR_CLASS_AUTHORIZATION = "authorization"
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
@@ -45,6 +49,7 @@ ERROR_CLASS_NETWORK = "network"
|
|||||||
ERROR_CLASS_CONFIGURATION = "configuration"
|
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||||
ERROR_CLASS_INTERNAL = "internal"
|
ERROR_CLASS_INTERNAL = "internal"
|
||||||
ERROR_CLASS_UPSTREAM = "upstream"
|
ERROR_CLASS_UPSTREAM = "upstream"
|
||||||
|
ERROR_CLASS_PRECONDITION = "precondition"
|
||||||
|
|
||||||
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||||
# Keychain contents, tokens, or arbitrary exception text.
|
# Keychain contents, tokens, or arbitrary exception text.
|
||||||
@@ -62,12 +67,128 @@ FIXED_MESSAGES: dict[str, str] = {
|
|||||||
REASON_INTERNAL_ERROR: "Internal tool error",
|
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||||
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||||
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||||
|
REASON_PREFLIGHT_ORDER: (
|
||||||
|
"Mutation blocked: pre-flight order violation (fail closed)"
|
||||||
|
),
|
||||||
|
REASON_MALFORMED_RESPONSE: "Malformed response from Gitea",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Error class for a self-declared safe reason code (``gitea_reason_code``).
|
||||||
|
_REASON_ERROR_CLASS: dict[str, str] = {
|
||||||
|
REASON_AUTH_FAILED: ERROR_CLASS_AUTHENTICATION,
|
||||||
|
REASON_AUTH_INVALID_TOKEN: ERROR_CLASS_AUTHENTICATION,
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE: ERROR_CLASS_AUTHORIZATION,
|
||||||
|
REASON_AUTHZ_DENIED: ERROR_CLASS_AUTHORIZATION,
|
||||||
|
REASON_NETWORK_ERROR: ERROR_CLASS_NETWORK,
|
||||||
|
REASON_CONFIG_ERROR: ERROR_CLASS_CONFIGURATION,
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE: ERROR_CLASS_UPSTREAM,
|
||||||
|
REASON_HTTP_ERROR: ERROR_CLASS_INTERNAL,
|
||||||
|
REASON_PREFLIGHT_ORDER: ERROR_CLASS_PRECONDITION,
|
||||||
|
REASON_MALFORMED_RESPONSE: ERROR_CLASS_INTERNAL,
|
||||||
|
REASON_INTERNAL_ERROR: ERROR_CLASS_INTERNAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bounds for the safe diagnostic fields added to the ``internal_error`` path.
|
||||||
|
_DETAIL_LIMIT = 200
|
||||||
|
_CLASS_LIMIT = 120
|
||||||
|
_STAGE_LIMIT = 64
|
||||||
|
|
||||||
|
# Absolute-path prefixes stripped from diagnostic detail (never leak layout).
|
||||||
|
_ABS_PATH_RE = re.compile(r"/(?:Users|home|private|var|tmp|opt|etc|root)/[^\s'\"]*")
|
||||||
|
_DETAIL_SECRET_PREFIXES = ("token ", "Basic ", "Bearer ", "Authorization: ")
|
||||||
|
_SECRET_KEY = (
|
||||||
|
r"token|password|passwd|secret|authorization|api[_-]?key|"
|
||||||
|
r"access[_-]?token|refresh[_-]?token|session|cookie"
|
||||||
|
)
|
||||||
|
_SECRET_JSON_RE = re.compile(
|
||||||
|
r'"(' + _SECRET_KEY + r')"\s*:\s*"[^"]*"', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_SECRET_KV_RE = re.compile(
|
||||||
|
r"\b(" + _SECRET_KEY + r")\s*=\s*[^\s&\"']+", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
_MUTATION_STAGE_ATTR = "_gitea_mutation_stage"
|
||||||
|
_SELF_DECLARED_REASON_ATTR = "gitea_reason_code"
|
||||||
|
|
||||||
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||||
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_str(exc: BaseException) -> str:
|
||||||
|
"""``str(exc)`` that never raises (a poisoned ``__str__`` must not escape)."""
|
||||||
|
try:
|
||||||
|
return str(exc)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_exception_class(exc: BaseException) -> str:
|
||||||
|
"""Fully-qualified type identifier for *exc* — never instance/message text."""
|
||||||
|
try:
|
||||||
|
t = type(exc)
|
||||||
|
mod = getattr(t, "__module__", "") or ""
|
||||||
|
name = getattr(t, "__qualname__", None) or getattr(t, "__name__", "") or ""
|
||||||
|
ident = f"{mod}.{name}" if mod else name
|
||||||
|
return ident[:_CLASS_LIMIT]
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_detail(text: Any, limit: int = _DETAIL_LIMIT) -> str:
|
||||||
|
"""Strictly redact free-form exception text for safe diagnostics.
|
||||||
|
|
||||||
|
Removes token/Authorization credentials, raw URLs and hostnames (via the
|
||||||
|
shared audit redactor), and absolute filesystem paths, then collapses
|
||||||
|
whitespace and truncates. Never raises; on any failure returns "".
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
out = str(text)
|
||||||
|
# Drop credential-prefixed runs (token/Basic/Bearer/Authorization).
|
||||||
|
for prefix in _DETAIL_SECRET_PREFIXES:
|
||||||
|
idx = 0
|
||||||
|
while True:
|
||||||
|
i = out.find(prefix, idx)
|
||||||
|
if i == -1:
|
||||||
|
break
|
||||||
|
j = i + len(prefix)
|
||||||
|
while j < len(out) and not out[j].isspace():
|
||||||
|
j += 1
|
||||||
|
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||||||
|
idx = i + len(prefix) + len("[REDACTED]")
|
||||||
|
# Secret VALUES carried in JSON ("key":"value") or kv (key=value) form,
|
||||||
|
# even short ones (e.g. a password), keyed by a sensitive field name.
|
||||||
|
out = _SECRET_JSON_RE.sub(r'"\1":"[REDACTED]"', out)
|
||||||
|
out = _SECRET_KV_RE.sub(r"\1=[REDACTED]", out)
|
||||||
|
# URLs / query secrets / hostnames.
|
||||||
|
try:
|
||||||
|
import gitea_audit
|
||||||
|
|
||||||
|
out = gitea_audit.redact_urls(out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Absolute filesystem paths (workspace layout is not for LLM output).
|
||||||
|
out = _ABS_PATH_RE.sub("[PATH]", out)
|
||||||
|
# Any bare hostname the URL redactor missed (defence in depth).
|
||||||
|
out = re.sub(r"\b[\w.-]+\.(?:cc|net|com|org|io|dev|local)\b", "[HOST]", out)
|
||||||
|
out = " ".join(out.split())
|
||||||
|
return out[:limit]
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_mutation_stage(exc: BaseException) -> str | None:
|
||||||
|
"""Return the bounded, redacted mutation stage tagged on *exc* (or None)."""
|
||||||
|
try:
|
||||||
|
raw = getattr(exc, _MUTATION_STAGE_ATTR, None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return _redact_detail(raw, limit=_STAGE_LIMIT) or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fixed_message(reason_code: str) -> str:
|
def fixed_message(reason_code: str) -> str:
|
||||||
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||||
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||||
@@ -154,9 +275,34 @@ def classify_exception(exc: BaseException) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _self_declared_classification(exc: BaseException) -> dict[str, Any] | None:
|
||||||
|
"""Honor a safe ``gitea_reason_code`` attribute set by server-side raisers.
|
||||||
|
|
||||||
|
Converts a raw exception that self-declares a **known** reason code into a
|
||||||
|
typed structured error (fixed message) instead of an opaque internal_error.
|
||||||
|
Unknown / non-string / secret values are ignored (fail closed to internal).
|
||||||
|
"""
|
||||||
|
reason = getattr(exc, _SELF_DECLARED_REASON_ATTR, None)
|
||||||
|
if not isinstance(reason, str) or reason not in FIXED_MESSAGES:
|
||||||
|
return None
|
||||||
|
if reason == REASON_INTERNAL_ERROR:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"reason_code": reason,
|
||||||
|
"error_class": _REASON_ERROR_CLASS.get(reason, ERROR_CLASS_INTERNAL),
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(reason),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||||
import gitea_auth
|
import gitea_auth
|
||||||
|
|
||||||
|
declared = _self_declared_classification(exc)
|
||||||
|
if declared is not None:
|
||||||
|
return declared
|
||||||
|
|
||||||
if isinstance(exc, gitea_auth.GiteaAuthError):
|
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||||
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||||
if code not in (
|
if code not in (
|
||||||
@@ -233,13 +379,23 @@ def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
|||||||
return _classify_exception_impl(cause)
|
return _classify_exception_impl(cause)
|
||||||
|
|
||||||
# No message-substring authentication heuristics (reviewer finding #3).
|
# No message-substring authentication heuristics (reviewer finding #3).
|
||||||
return {
|
# Unexpected failure → internal_error, now carrying SAFE diagnostics so the
|
||||||
|
# failure is actionable: a type identifier + strictly-redacted detail +
|
||||||
|
# optional mutation-stage tag. The operator ``message`` stays the fixed
|
||||||
|
# constant; none of these fields carry secrets/bodies/paths/env.
|
||||||
|
result = {
|
||||||
"reason_code": REASON_INTERNAL_ERROR,
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
"error_class": ERROR_CLASS_INTERNAL,
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
"http_status": None,
|
"http_status": None,
|
||||||
"message": fixed_message(REASON_INTERNAL_ERROR),
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
"transport_survives": True,
|
"transport_survives": True,
|
||||||
|
"exception_class": _safe_exception_class(exc),
|
||||||
|
"detail": _redact_detail(_safe_str(exc)),
|
||||||
}
|
}
|
||||||
|
stage = _safe_mutation_stage(exc)
|
||||||
|
if stage:
|
||||||
|
result["mutation_stage"] = stage
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def build_structured_error_payload(
|
def build_structured_error_payload(
|
||||||
@@ -277,6 +433,19 @@ def build_structured_error_payload(
|
|||||||
payload["tool"] = tool_name[:120]
|
payload["tool"] = tool_name[:120]
|
||||||
if profile_name and isinstance(profile_name, str):
|
if profile_name and isinstance(profile_name, str):
|
||||||
payload["profile"] = profile_name[:80]
|
payload["profile"] = profile_name[:80]
|
||||||
|
# Safe diagnostics — internal_error path only. These make an otherwise
|
||||||
|
# opaque crash actionable without leaking secrets. Re-derived here from
|
||||||
|
# the fixed message gate above; typed reasons never carry them.
|
||||||
|
if payload["reason_code"] == REASON_INTERNAL_ERROR:
|
||||||
|
exc_cls = classification.get("exception_class")
|
||||||
|
if isinstance(exc_cls, str) and exc_cls:
|
||||||
|
payload["exception_class"] = exc_cls[:_CLASS_LIMIT]
|
||||||
|
detail = classification.get("detail")
|
||||||
|
if isinstance(detail, str):
|
||||||
|
payload["detail"] = _redact_detail(detail)
|
||||||
|
stage = classification.get("mutation_stage")
|
||||||
|
if isinstance(stage, str) and stage:
|
||||||
|
payload["mutation_stage"] = stage[:_STAGE_LIMIT]
|
||||||
return payload
|
return payload
|
||||||
except Exception:
|
except Exception:
|
||||||
return {
|
return {
|
||||||
@@ -316,7 +485,21 @@ def log_sanitized_daemon_reason(
|
|||||||
status = classification.get("http_status")
|
status = classification.get("http_status")
|
||||||
if isinstance(status, int):
|
if isinstance(status, int):
|
||||||
parts.append(f"http_status={status}")
|
parts.append(f"http_status={status}")
|
||||||
# Intentionally no detail= / message= field — secrets lived there.
|
# Safe diagnostics — internal_error path ONLY. Typed reasons still emit
|
||||||
|
# no detail= (secrets lived there). class/detail/stage are re-redacted
|
||||||
|
# here as defence in depth.
|
||||||
|
if reason == REASON_INTERNAL_ERROR:
|
||||||
|
exc_cls = classification.get("exception_class")
|
||||||
|
if isinstance(exc_cls, str) and exc_cls:
|
||||||
|
parts.append(f"exception_class={exc_cls[:_CLASS_LIMIT]}")
|
||||||
|
stage = classification.get("mutation_stage")
|
||||||
|
if isinstance(stage, str) and stage:
|
||||||
|
parts.append(
|
||||||
|
f"mutation_stage={_redact_detail(stage, limit=_STAGE_LIMIT)}"
|
||||||
|
)
|
||||||
|
detail = classification.get("detail")
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
parts.append(f"detail={_redact_detail(detail)}")
|
||||||
line = " ".join(parts)
|
line = " ".join(parts)
|
||||||
stream.write(line + "\n")
|
stream.write(line + "\n")
|
||||||
if hasattr(stream, "flush"):
|
if hasattr(stream, "flush"):
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
|||||||
|
|
||||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||||
|
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||||
|
|
||||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||||
SOURCE_ADOPT,
|
SOURCE_ADOPT,
|
||||||
SOURCE_ACQUIRE,
|
SOURCE_ACQUIRE,
|
||||||
|
SOURCE_ACQUIRE_MERGER,
|
||||||
SOURCE_HEARTBEAT,
|
SOURCE_HEARTBEAT,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,8 +211,10 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
|||||||
r"(?is)\b("
|
r"(?is)\b("
|
||||||
r"gitea_adopt_merger_pr_lease|"
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
r"gitea_acquire_reviewer_pr_lease|"
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
r"adoption_comment_id\s*[:=]|"
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
|||||||
@@ -109,8 +109,17 @@ def resolve_namespace_mutation_context(
|
|||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
worktree: str | None = None,
|
worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
|
configured_canonical_root: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||||
|
|
||||||
|
When *configured_canonical_root* is supplied (a cross-repository namespace
|
||||||
|
bound to an external target repository, #706), the canonical repository root
|
||||||
|
is that configured target rather than the MCP install checkout. This keeps
|
||||||
|
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||||
|
repository the namespace actually mutates. Without it the single-repo
|
||||||
|
default is preserved: the canonical root follows the process checkout.
|
||||||
|
"""
|
||||||
demotions: list[str] = []
|
demotions: list[str] = []
|
||||||
workspace, binding_source = resolve_namespace_workspace(
|
workspace, binding_source = resolve_namespace_workspace(
|
||||||
role_kind=role_kind,
|
role_kind=role_kind,
|
||||||
@@ -132,7 +141,11 @@ def resolve_namespace_mutation_context(
|
|||||||
env=env,
|
env=env,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
)
|
)
|
||||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
configured = (configured_canonical_root or "").strip()
|
||||||
|
if configured:
|
||||||
|
canonical_root = os.path.realpath(configured)
|
||||||
|
else:
|
||||||
|
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||||
return {
|
return {
|
||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"workspace_binding_source": binding_source,
|
"workspace_binding_source": binding_source,
|
||||||
@@ -258,6 +271,7 @@ def assess_namespace_mutation_workspace(
|
|||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
current_branch: str | None = None,
|
current_branch: str | None = None,
|
||||||
|
configured_canonical_root: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||||
ctx = resolve_namespace_mutation_context(
|
ctx = resolve_namespace_mutation_context(
|
||||||
@@ -268,6 +282,7 @@ def assess_namespace_mutation_workspace(
|
|||||||
env=env,
|
env=env,
|
||||||
session_lease_worktree=session_lease_worktree,
|
session_lease_worktree=session_lease_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
|
configured_canonical_root=configured_canonical_root,
|
||||||
)
|
)
|
||||||
mutation_workspace = ctx["workspace_path"]
|
mutation_workspace = ctx["workspace_path"]
|
||||||
binding_source = ctx["workspace_binding_source"]
|
binding_source = ctx["workspace_binding_source"]
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
||||||
|
|
||||||
|
Pure assessment helpers for:
|
||||||
|
|
||||||
|
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
||||||
|
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
||||||
|
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
||||||
|
of both PR head and live base head; never rebase/force-push.
|
||||||
|
|
||||||
|
All recommendation logic is hermetic (no network) so unit tests cover every
|
||||||
|
acceptance path without Gitea credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Recommended next actions (controller / skill vocabulary).
|
||||||
|
ACTION_MERGE_NOW = "merge_now"
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
||||||
|
ACTION_BLOCKED = "blocked"
|
||||||
|
|
||||||
|
_VALID_ACTIONS = frozenset({
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED,
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Author-only mutation; reviewer/merger must never update author branches.
|
||||||
|
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
||||||
|
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
||||||
|
|
||||||
|
# Allowed Gitea update style — merge only (never rebase).
|
||||||
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
|
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text if _FULL_SHA.match(text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_role(role: str | None) -> str:
|
||||||
|
return (role or "").strip().lower() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_pr_sync_status(
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
pr_number: int,
|
||||||
|
pr_state: str | None = None,
|
||||||
|
source_branch: str | None = None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
base_head_sha: str | None = None,
|
||||||
|
commits_behind: int | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
branch_protection_requires_current_base: bool | None = None,
|
||||||
|
approval_at_current_head: bool | None = None,
|
||||||
|
checks_status: str | None = None,
|
||||||
|
active_author_lock: bool | None = None,
|
||||||
|
active_reviewer_lease: bool | None = None,
|
||||||
|
active_merger_lease: bool | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
checks_required: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recommend the next sanctioned PR lifecycle action.
|
||||||
|
|
||||||
|
Decision order (fail closed):
|
||||||
|
|
||||||
|
1. Incomplete identity / open state / head SHAs → ``blocked``
|
||||||
|
2. Conflicts (or mergeable false with conflict signal) →
|
||||||
|
``author_conflict_remediation``
|
||||||
|
3. Head changed after approval / prepared verdict for former head →
|
||||||
|
``fresh_review_required`` (unless conflicts already routed author work)
|
||||||
|
4. Behind live base + branch protection requires current base +
|
||||||
|
auto-mergeable → ``update_branch_by_merge``
|
||||||
|
5. Approval at current head + mergeable + checks ok (+ update not
|
||||||
|
required) → ``merge_now``
|
||||||
|
6. Otherwise ``blocked`` with structured reasons
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
pr_head = _normalize_sha(pr_head_sha)
|
||||||
|
base_head = _normalize_sha(base_head_sha)
|
||||||
|
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
state = (pr_state or "").strip().lower()
|
||||||
|
checks = (checks_status or "unknown").strip().lower()
|
||||||
|
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
||||||
|
requires_current = bool(branch_protection_requires_current_base)
|
||||||
|
approval_ok = bool(approval_at_current_head)
|
||||||
|
|
||||||
|
# Derive conflict when caller only supplies mergeable=False.
|
||||||
|
if has_conflicts is None and mergeable is False:
|
||||||
|
has_conflicts = True
|
||||||
|
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"host": (host or "").strip() or None,
|
||||||
|
"org": (org or "").strip() or None,
|
||||||
|
"repo": (repo or "").strip() or None,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"pr_state": state or None,
|
||||||
|
"source_branch": (source_branch or "").strip() or None,
|
||||||
|
"pr_head_sha": pr_head,
|
||||||
|
"base_head_sha": base_head,
|
||||||
|
"commits_behind": behind,
|
||||||
|
"mergeable": mergeable,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"branch_protection_requires_current_base": requires_current,
|
||||||
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
|
"checks_status": checks,
|
||||||
|
"active_locks_and_leases": {
|
||||||
|
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||||
|
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||||
|
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
||||||
|
},
|
||||||
|
"prepared_verdict_head_sha": prepared_head,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"approval_valid_for_merge": False,
|
||||||
|
"stale_approval": False,
|
||||||
|
"stale_prepared_verdict": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
"performed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||||
|
reasons.append("pr_number must be a positive integer (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if state and state not in ("open",):
|
||||||
|
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
if not state:
|
||||||
|
reasons.append("PR state missing (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if base_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if behind is None:
|
||||||
|
reasons.append("commits_behind missing or invalid (fail closed)")
|
||||||
|
if mergeable is None:
|
||||||
|
reasons.append("mergeable signal missing (fail closed)")
|
||||||
|
if approval_at_current_head is None:
|
||||||
|
reasons.append("approval_at_current_head missing (fail closed)")
|
||||||
|
if branch_protection_requires_current_base is None:
|
||||||
|
reasons.append(
|
||||||
|
"branch_protection_requires_current_base missing (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Stale prepared verdict / approval across heads.
|
||||||
|
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
||||||
|
result["stale_prepared_verdict"] = stale_prepared
|
||||||
|
stale_approval = not approval_ok
|
||||||
|
result["stale_approval"] = stale_approval
|
||||||
|
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
||||||
|
|
||||||
|
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
# Distinguish pure non-mergeable without explicit conflict flag
|
||||||
|
# still routes author remediation (Gitea cannot auto-update).
|
||||||
|
reasons.append(
|
||||||
|
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
||||||
|
f"{pr_head}; route author conflict remediation in the existing "
|
||||||
|
"issue/PR worktree under branches/ (never force-push or rebase)"
|
||||||
|
)
|
||||||
|
if stale_approval:
|
||||||
|
reasons.append(
|
||||||
|
"any approval at a former head is invalid; after remediation "
|
||||||
|
"require a fresh independent review at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Stale approval / prepared verdict at former head ─────────────────
|
||||||
|
if not approval_ok or stale_prepared:
|
||||||
|
if behind and behind > 0 and requires_current and mergeable is True:
|
||||||
|
# Must update first; update will also invalidate approval.
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base and branch protection "
|
||||||
|
"requires current base; automatic merge-from-base is available"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the current head (or prepared verdict "
|
||||||
|
"is head-scoped to a former SHA); after update require fresh review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the exact current PR head "
|
||||||
|
f"({pr_head}); never reuse a former-head approval for merge"
|
||||||
|
)
|
||||||
|
if stale_prepared:
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict pinned to former head {prepared_head} cannot "
|
||||||
|
f"authorize review/merge at current head {pr_head}"
|
||||||
|
)
|
||||||
|
if active_reviewer_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active reviewer lease must be released or superseded for the "
|
||||||
|
"new head before a fresh independent review"
|
||||||
|
)
|
||||||
|
if active_merger_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active merger lease cannot cross heads; release or re-acquire "
|
||||||
|
"at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Outdated + protection requires current base, auto-updatable ──────
|
||||||
|
if behind and behind > 0 and requires_current:
|
||||||
|
if mergeable is True and conflicts is not True:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base {base_head}; "
|
||||||
|
"branch protection requires the PR branch to contain current base; "
|
||||||
|
"Gitea can merge base into the PR branch without conflicts"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"route author-only update_branch_by_merge; never rebase or force-push; "
|
||||||
|
"new head invalidates prior approval and requires fresh independent review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
# Approval today is at old head that will change — not mergeable yet
|
||||||
|
# for final merge until re-reviewed, but assess marks update path.
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
result["stale_approval"] = True # will become stale after update
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"update required by branch protection but automatic merge is not available"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Checks gate for merge_now ────────────────────────────────────────
|
||||||
|
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
||||||
|
if checks in ("pending", "running", "queued"):
|
||||||
|
reasons.append(f"required checks are not finished (status={checks})")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
if checks in ("failure", "failed", "error", "cancelled"):
|
||||||
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
# unknown — fail closed when checks_required
|
||||||
|
if checks == "unknown":
|
||||||
|
reasons.append("checks status unknown (fail closed)")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Ready to merge without update ────────────────────────────────────
|
||||||
|
# Includes: current with approval; outdated when update is NOT required.
|
||||||
|
if approval_ok and mergeable is True and conflicts is not True:
|
||||||
|
if behind and behind > 0 and not requires_current:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base but branch protection "
|
||||||
|
"does not require the latest base; preserve the approved head"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"PR has a valid approval at the exact current head, is conflict-free "
|
||||||
|
"and mergeable; do not update the branch unnecessarily"
|
||||||
|
)
|
||||||
|
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
||||||
|
result["recommended_next_action"] = ACTION_MERGE_NOW
|
||||||
|
result["approval_valid_for_merge"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_update_pr_branch_preflight(
|
||||||
|
*,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_pr_head_sha: str | None,
|
||||||
|
live_pr_head_sha: str | None,
|
||||||
|
expected_base_head_sha: str | None,
|
||||||
|
live_base_head_sha: str | None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
style: str | None = UPDATE_STYLE_MERGE,
|
||||||
|
has_author_lock: bool | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree_owns_source_branch: bool | None = None,
|
||||||
|
control_checkout_clean: bool | None = None,
|
||||||
|
master_parity_ok: bool | None = None,
|
||||||
|
force_push: bool = False,
|
||||||
|
rebase: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Author-only preflight for update-by-merge; fail closed on any race.
|
||||||
|
|
||||||
|
When conflicts exist, returns a structured author-remediation handoff and
|
||||||
|
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
role = _normalize_role(role_kind)
|
||||||
|
exp_pr = _normalize_sha(expected_pr_head_sha)
|
||||||
|
live_pr = _normalize_sha(live_pr_head_sha)
|
||||||
|
exp_base = _normalize_sha(expected_base_head_sha)
|
||||||
|
live_base = _normalize_sha(live_base_head_sha)
|
||||||
|
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
||||||
|
|
||||||
|
conflicts = has_conflicts
|
||||||
|
if conflicts is None and mergeable is False:
|
||||||
|
conflicts = True
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mutation_allowed": False,
|
||||||
|
"performed": False,
|
||||||
|
"style": style_norm,
|
||||||
|
"role_kind": role,
|
||||||
|
"expected_pr_head_sha": exp_pr,
|
||||||
|
"live_pr_head_sha": live_pr,
|
||||||
|
"expected_base_head_sha": exp_base,
|
||||||
|
"live_base_head_sha": live_base,
|
||||||
|
"head_race": False,
|
||||||
|
"base_race": False,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"author_remediation_handoff": False,
|
||||||
|
"approval_invalidated_for_former_head": False,
|
||||||
|
"force_push": bool(force_push),
|
||||||
|
"rebase": bool(rebase),
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role gate — author only.
|
||||||
|
if role not in _AUTHOR_UPDATE_ROLES:
|
||||||
|
reasons.append(
|
||||||
|
f"role '{role}' cannot update an author PR branch "
|
||||||
|
"(author-only; reviewer/merger denial)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if force_push:
|
||||||
|
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
||||||
|
return result
|
||||||
|
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
||||||
|
reasons.append(
|
||||||
|
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
||||||
|
"(never rebase)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if style_norm != UPDATE_STYLE_MERGE:
|
||||||
|
reasons.append(
|
||||||
|
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not exp_pr or not live_pr:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if not exp_base or not live_base:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if exp_pr != live_pr:
|
||||||
|
result["head_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if exp_base != live_base:
|
||||||
|
result["base_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"base head race: expected {exp_base} but live is {live_base} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if has_author_lock is not True:
|
||||||
|
reasons.append(
|
||||||
|
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
||||||
|
)
|
||||||
|
wt = (worktree_path or "").strip()
|
||||||
|
if not wt:
|
||||||
|
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
||||||
|
else:
|
||||||
|
norm = wt.replace("\\", "/")
|
||||||
|
if "/branches/" not in norm and not norm.startswith("branches/"):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
||||||
|
)
|
||||||
|
if worktree_owns_source_branch is False:
|
||||||
|
reasons.append(
|
||||||
|
"worktree does not own the PR source branch (fail closed)"
|
||||||
|
)
|
||||||
|
if control_checkout_clean is False:
|
||||||
|
reasons.append("control checkout is not clean (fail closed)")
|
||||||
|
if master_parity_ok is False:
|
||||||
|
reasons.append("runtime/master parity failed (fail closed)")
|
||||||
|
|
||||||
|
if conflicts is True:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"conflicts present: return structured author-remediation handoff "
|
||||||
|
"without creating a partial remote update"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"resolve conflicts explicitly in the existing dedicated worktree, "
|
||||||
|
"run focused and regression tests, commit/push via sanctioned author "
|
||||||
|
"workflow, then route the new exact head to independent review"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if mergeable is False and conflicts is not False:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"PR is not mergeable; refuse automatic update and hand off to "
|
||||||
|
"author conflict remediation (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["mutation_allowed"] = True
|
||||||
|
reasons.append(
|
||||||
|
"preflight passed: author may merge live base into the PR branch via "
|
||||||
|
"native MCP update (style=merge only)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_update_head_transition(
|
||||||
|
*,
|
||||||
|
former_pr_head_sha: str | None,
|
||||||
|
new_pr_head_sha: str | None,
|
||||||
|
former_approval_head_sha: str | None = None,
|
||||||
|
former_reviewer_lease_head_sha: str | None = None,
|
||||||
|
former_merger_lease_head_sha: str | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
||||||
|
|
||||||
|
The new head never inherits approval, reviewer/merger leases, or prepared
|
||||||
|
verdicts from the former head.
|
||||||
|
"""
|
||||||
|
former = _normalize_sha(former_pr_head_sha)
|
||||||
|
new = _normalize_sha(new_pr_head_sha)
|
||||||
|
reasons: list[str] = []
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"former_pr_head_sha": former,
|
||||||
|
"new_pr_head_sha": new,
|
||||||
|
"head_changed": bool(former and new and former != new),
|
||||||
|
"approval_invalidated": False,
|
||||||
|
"reviewer_lease_superseded": False,
|
||||||
|
"merger_lease_superseded": False,
|
||||||
|
"prepared_verdict_invalidated": False,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not former or not new:
|
||||||
|
reasons.append("former and new PR head SHAs required (fail closed)")
|
||||||
|
return result
|
||||||
|
if former == new:
|
||||||
|
reasons.append(
|
||||||
|
"PR head unchanged after update; unexpected for merge-from-base"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["head_changed"] = True
|
||||||
|
# Approval at former head is always void at new head.
|
||||||
|
former_approval = _normalize_sha(former_approval_head_sha) or former
|
||||||
|
if former_approval != new:
|
||||||
|
result["approval_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"approval for former head {former_approval} is invalid at new head "
|
||||||
|
f"{new}; never preserve approval across a head change"
|
||||||
|
)
|
||||||
|
|
||||||
|
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
||||||
|
if rev_lease and rev_lease != new:
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
||||||
|
"release or supersede before fresh review"
|
||||||
|
)
|
||||||
|
elif rev_lease is None:
|
||||||
|
# Unknown lease head still must not authorize at new head.
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any reviewer lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
||||||
|
if mer_lease and mer_lease != new:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"merger lease for head {mer_lease} cannot cross to {new}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any merger lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
if prepared and prepared != new:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
||||||
|
)
|
||||||
|
elif prepared is None:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
"prepared verdicts associated with the former head are invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"route the new exact head to independent review "
|
||||||
|
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_sequential_queue_step(
|
||||||
|
*,
|
||||||
|
just_merged: bool,
|
||||||
|
master_refreshed: bool,
|
||||||
|
next_pr_number: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Controller sequential processing: reassess only after merge + master refresh."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if just_merged and not master_refreshed:
|
||||||
|
reasons.append(
|
||||||
|
"master must be refreshed after each merge before reassessing the next PR "
|
||||||
|
"(do not update every PR simultaneously)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if not just_merged:
|
||||||
|
reasons.append("no merge completed; sequential step does not advance")
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if next_pr_number:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR "
|
||||||
|
f"#{next_pr_number}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": True,
|
||||||
|
"process_next": True,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"post_merge_cleanup_handoff": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_approval_usable_at_head(
|
||||||
|
*,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
approved_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Stale approval cannot authorize merge (head-scoped only)."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
approved = _normalize_sha(approved_head_sha)
|
||||||
|
ok = bool(current and approved and current == approved)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale approval: approved head '{approved or '(none)'}' does not "
|
||||||
|
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"approval_usable": ok,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"approved_head_sha": approved,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lease_usable_at_head(
|
||||||
|
*,
|
||||||
|
lease_kind: str,
|
||||||
|
lease_head_sha: str | None,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reviewer/merger leases cannot cross heads."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
lease_head = _normalize_sha(lease_head_sha)
|
||||||
|
kind = (lease_kind or "").strip().lower() or "unknown"
|
||||||
|
ok = bool(current and lease_head and current == lease_head)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
||||||
|
f"authorize work at current head '{current or '(none)'}'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"lease_usable": ok,
|
||||||
|
"lease_kind": kind,
|
||||||
|
"lease_head_sha": lease_head,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
@@ -76,7 +76,6 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"create_pr",
|
"create_pr",
|
||||||
"comment_pr",
|
"comment_pr",
|
||||||
"address_pr_change_requests",
|
"address_pr_change_requests",
|
||||||
"delete_branch",
|
|
||||||
"work_issue",
|
"work_issue",
|
||||||
"work-issue",
|
"work-issue",
|
||||||
"reconcile_landed_pr",
|
"reconcile_landed_pr",
|
||||||
@@ -84,6 +83,10 @@ AUTHOR_TASKS = frozenset({
|
|||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
|
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||||
|
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
||||||
|
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||||
|
"delete_branch",
|
||||||
"reconcile_already_landed_pr",
|
"reconcile_already_landed_pr",
|
||||||
"reconcile_already_landed",
|
"reconcile_already_landed",
|
||||||
"reconcile-landed-pr",
|
"reconcile-landed-pr",
|
||||||
@@ -99,7 +102,8 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"create_pr": "author",
|
"create_pr": "author",
|
||||||
"comment_pr": "author",
|
"comment_pr": "author",
|
||||||
"address_pr_change_requests": "author",
|
"address_pr_change_requests": "author",
|
||||||
"delete_branch": "author",
|
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
||||||
|
"delete_branch": "reconciler",
|
||||||
"review_pr": "reviewer",
|
"review_pr": "reviewer",
|
||||||
"merge_pr": "merger",
|
"merge_pr": "merger",
|
||||||
"blind_pr_queue_review": "reviewer",
|
"blind_pr_queue_review": "reviewer",
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"""Session-immutable MCP mutation context (#714).
|
||||||
|
|
||||||
|
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||||
|
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||||
|
|
||||||
|
Capability resolution and mutation gates must evaluate only the active
|
||||||
|
profile for the requested remote. Silent cross-host / cross-profile
|
||||||
|
substitution is forbidden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SessionContext:
|
||||||
|
"""One atomic, immutable process-session binding."""
|
||||||
|
|
||||||
|
profile_name: str | None
|
||||||
|
remote: str | None
|
||||||
|
host: str | None
|
||||||
|
identity: str | None
|
||||||
|
repository: str | None
|
||||||
|
org: str | None
|
||||||
|
role_kind: str | None
|
||||||
|
expected_username: str | None
|
||||||
|
source: str
|
||||||
|
pid: int
|
||||||
|
canonical_repository_root: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"profile_name": self.profile_name,
|
||||||
|
"remote": self.remote,
|
||||||
|
"host": self.host,
|
||||||
|
"identity": self.identity,
|
||||||
|
"repository": self.repository,
|
||||||
|
"org": self.org,
|
||||||
|
"role_kind": self.role_kind,
|
||||||
|
"expected_username": self.expected_username,
|
||||||
|
"source": self.source,
|
||||||
|
"pid": self.pid,
|
||||||
|
"canonical_repository_root": self.canonical_repository_root,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||||
|
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||||
|
# sanctioned rebind atomic across concurrent MCP calls.
|
||||||
|
_SESSION_CONTEXT: _SessionContext | None = None
|
||||||
|
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_session_context_for_testing() -> None:
|
||||||
|
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||||
|
|
||||||
|
Production sessions transition only through process startup/PID change or
|
||||||
|
the explicit profile-activation rebind. Keeping this helper private and
|
||||||
|
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||||
|
bypass.
|
||||||
|
"""
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||||
|
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
_SESSION_CONTEXT = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_context() -> dict[str, Any] | None:
|
||||||
|
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None:
|
||||||
|
return None
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def profile_host(profile: dict | None) -> str | None:
|
||||||
|
"""Hostname from profile base_url, lowercased, or None."""
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
base = (profile.get("base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
return host or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||||
|
"""Hostname for a known remote key."""
|
||||||
|
if not remote or not remotes:
|
||||||
|
return None
|
||||||
|
entry = remotes.get(remote) or {}
|
||||||
|
return (entry.get("host") or "").strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def profile_matches_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||||
|
if not profile or not remote:
|
||||||
|
return False
|
||||||
|
r_host = remote_host(remote, remotes)
|
||||||
|
p_host = profile_host(profile)
|
||||||
|
if r_host and p_host:
|
||||||
|
return r_host == p_host
|
||||||
|
# Fall back to context name heuristics when base_url missing.
|
||||||
|
ctx = (profile.get("context") or "").strip().lower()
|
||||||
|
if not ctx or not contexts:
|
||||||
|
return False
|
||||||
|
ctx_data = contexts.get(ctx) or {}
|
||||||
|
gitea = ctx_data.get("gitea") or {}
|
||||||
|
base = (gitea.get("base_url") or "").strip()
|
||||||
|
if not base or not r_host:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return bool(c_host) and c_host == r_host
|
||||||
|
|
||||||
|
|
||||||
|
def bind_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "bind",
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
canonical_repository_root=canonical_repository_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_session_context_unlocked(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None,
|
||||||
|
org: str | None,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
source: str,
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Store a complete immutable context while the caller holds the lock."""
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
_SESSION_CONTEXT = _SessionContext(
|
||||||
|
profile_name=(profile_name or "").strip() or None,
|
||||||
|
remote=(remote or "").strip() or None,
|
||||||
|
host=(host or "").strip().lower() or None,
|
||||||
|
identity=(identity or "").strip() or None,
|
||||||
|
repository=(repository or "").strip() or None,
|
||||||
|
org=(org or "").strip() or None,
|
||||||
|
role_kind=(role_kind or "").strip() or None,
|
||||||
|
expected_username=(expected_username or "").strip() or None,
|
||||||
|
source=source,
|
||||||
|
pid=os.getpid(),
|
||||||
|
canonical_repository_root=(canonical_repository_root or "").strip() or None,
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_session_context_if_unbound(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "seed",
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind only when this process has no current context.
|
||||||
|
|
||||||
|
A changed environment or an interleaved call is not a session boundary and
|
||||||
|
therefore cannot replace an established binding. A newly started/forked
|
||||||
|
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||||
|
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||||
|
"""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
canonical_repository_root=canonical_repository_root,
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def assess_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None = None,
|
||||||
|
identity: str | None = None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
canonical_repository_root: str | None = None,
|
||||||
|
require_bound: bool = False,
|
||||||
|
require_complete: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compare live values against the bound session context.
|
||||||
|
|
||||||
|
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||||
|
``require_bound`` is false, does not block (caller may seed). When
|
||||||
|
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||||
|
additionally fails closed when the binding carries no verified
|
||||||
|
repository/organization identity, so a mutation can never run against an
|
||||||
|
unknown repository (#714).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
bound = _SESSION_CONTEXT
|
||||||
|
ctx = bound.as_dict() if bound is not None else None
|
||||||
|
if ctx is None or ctx.get("pid") != os.getpid():
|
||||||
|
if require_bound:
|
||||||
|
reasons.append(
|
||||||
|
"session mutation context is unbound; call gitea_whoami or "
|
||||||
|
"gitea_activate_profile before mutating (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
return _assessment(True, reasons, ctx)
|
||||||
|
|
||||||
|
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||||
|
reasons.append(
|
||||||
|
"session repository/organization identity is unverified "
|
||||||
|
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||||
|
"a mutation cannot proceed without a verified workspace "
|
||||||
|
"repository (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
|
||||||
|
live_profile = (profile_name or "").strip() or None
|
||||||
|
live_remote = (remote or "").strip() or None
|
||||||
|
live_host = (host or "").strip().lower() or None
|
||||||
|
live_identity = (identity or "").strip() or None
|
||||||
|
live_repo = (repository or "").strip() or None
|
||||||
|
live_org = (org or "").strip() or None
|
||||||
|
live_canonical = (canonical_repository_root or "").strip() or None
|
||||||
|
|
||||||
|
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||||
|
reasons.append(
|
||||||
|
f"profile drift: live '{live_profile}' != bound "
|
||||||
|
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||||
|
reasons.append(
|
||||||
|
f"remote drift: live '{live_remote}' != bound "
|
||||||
|
f"'{ctx.get('remote')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||||
|
reasons.append(
|
||||||
|
f"host drift: live '{live_host}' != bound "
|
||||||
|
f"'{ctx.get('host')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
ctx.get("identity")
|
||||||
|
and live_identity
|
||||||
|
and live_identity != ctx.get("identity")
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"identity drift: live '{live_identity}' != bound "
|
||||||
|
f"'{ctx.get('identity')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||||
|
reasons.append(
|
||||||
|
f"repository drift: live '{live_repo}' != bound "
|
||||||
|
f"'{ctx.get('repository')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||||
|
reasons.append(
|
||||||
|
f"org drift: live '{live_org}' != bound "
|
||||||
|
f"'{ctx.get('org')}' (fail closed)"
|
||||||
|
)
|
||||||
|
bound_canonical = ctx.get("canonical_repository_root")
|
||||||
|
if bound_canonical and live_canonical and live_canonical != bound_canonical:
|
||||||
|
reasons.append(
|
||||||
|
f"canonical repository root drift: live '{live_canonical}' != bound "
|
||||||
|
f"'{bound_canonical}' (forged or conflicting cross-repository "
|
||||||
|
"binding, fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = expected_username or ctx.get("expected_username")
|
||||||
|
if expected and live_identity and live_identity != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{live_identity}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assessment(not reasons, reasons, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_identity_match(
|
||||||
|
*,
|
||||||
|
authenticated: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when profile declares a username that does not match live auth."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
auth = (authenticated or "").strip() or None
|
||||||
|
expected = (expected_username or "").strip() or None
|
||||||
|
if expected and auth and auth != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{auth}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"authenticated": auth,
|
||||||
|
"expected_username": expected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||||
|
match = _REPO_SLUG_RE.match(slug or "")
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group("org"), match.group("repo")
|
||||||
|
|
||||||
|
|
||||||
|
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||||
|
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||||
|
left = (org or "").strip()
|
||||||
|
right = (repo or "").strip()
|
||||||
|
if not left or not right:
|
||||||
|
return None
|
||||||
|
return f"{left}/{right}"
|
||||||
|
|
||||||
|
|
||||||
|
def declared_allowed_repositories(
|
||||||
|
profile: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||||
|
|
||||||
|
This list is an authorization boundary, never the session binding itself:
|
||||||
|
the verified workspace selects exactly one entry (see
|
||||||
|
:func:`assess_repository_scope`).
|
||||||
|
|
||||||
|
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||||
|
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||||
|
an empty scope (which would fail open). Read-only diagnostics may use
|
||||||
|
strict=False.
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"profile unresolved; cannot declare allowed_repositories "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
raw = profile.get("allowed_repositories")
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_repositories must be a list of owner/repository slugs "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
slugs: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||||
|
continue
|
||||||
|
parsed = parse_repository_slug(entry)
|
||||||
|
if not parsed:
|
||||||
|
errors.append(
|
||||||
|
f"malformed allowed_repositories entry {entry!r} "
|
||||||
|
"(expected owner/repository)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||||
|
if strict and errors:
|
||||||
|
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_scope(
|
||||||
|
*,
|
||||||
|
workspace_slug: str | None,
|
||||||
|
allowed: list[str] | None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
require_scope: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authorize the verified workspace repository against the profile allowlist.
|
||||||
|
|
||||||
|
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||||
|
several repositories still binds to the single verified one, and never to
|
||||||
|
the list as a whole.
|
||||||
|
|
||||||
|
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||||
|
workspace remote cannot self-authorize without a configured boundary.
|
||||||
|
"""
|
||||||
|
scope = list(allowed or [])
|
||||||
|
reasons: list[str] = []
|
||||||
|
name = profile_name or "(active profile)"
|
||||||
|
if not scope:
|
||||||
|
if require_scope:
|
||||||
|
reasons.append(
|
||||||
|
f"mutation denied: profile '{name}' has no non-empty "
|
||||||
|
"allowed_repositories authorization boundary (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": False,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
if not workspace_slug:
|
||||||
|
reasons.append(
|
||||||
|
f"no verified workspace repository could be established for profile "
|
||||||
|
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||||
|
)
|
||||||
|
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||||
|
reasons.append(
|
||||||
|
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||||
|
f"is not authorized by profile '{name}' allowed_repositories "
|
||||||
|
f"{sorted(scope)} (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": sorted(scope),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_override(
|
||||||
|
*,
|
||||||
|
requested_org: str | None,
|
||||||
|
requested_repo: str | None,
|
||||||
|
bound_org: str | None,
|
||||||
|
bound_repo: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||||
|
|
||||||
|
A mutation request is never allowed to establish, complete, or replace the
|
||||||
|
binding — it may only be checked against it.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
req_org = (requested_org or "").strip() or None
|
||||||
|
req_repo = (requested_repo or "").strip() or None
|
||||||
|
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"repository override denial: request targets '{req_repo}' but the "
|
||||||
|
f"session is bound to '{bound_repo}' (fail closed)"
|
||||||
|
)
|
||||||
|
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"organization override denial: request targets '{req_org}' but the "
|
||||||
|
f"session is bound to '{bound_org}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_profiles_for_remote(
|
||||||
|
config: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||||
|
if not config or not remote:
|
||||||
|
return []
|
||||||
|
profiles = config.get("profiles") or {}
|
||||||
|
contexts = config.get("contexts") or {}
|
||||||
|
names: list[str] = []
|
||||||
|
for name, data in profiles.items():
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
if not data.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||||
|
names.append(name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_allowed_for_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether the active profile may serve *remote*."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not profile:
|
||||||
|
reasons.append("active profile unresolved (fail closed)")
|
||||||
|
return {"proven": False, "block": True, "reasons": reasons}
|
||||||
|
if not remote:
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||||
|
# first call may establish the process remote/host pin; after that,
|
||||||
|
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||||
|
# require positive host/context alignment here.
|
||||||
|
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||||
|
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||||
|
p_host = profile_host(profile) or "(none)"
|
||||||
|
r_host = remote_host(remote, remotes) or "(none)"
|
||||||
|
reasons.append(
|
||||||
|
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||||
|
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_context_audit_fields(
|
||||||
|
ctx: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fields to include in pre-mutation audit records."""
|
||||||
|
data = ctx if ctx is not None else get_session_context()
|
||||||
|
if not data:
|
||||||
|
return {
|
||||||
|
"session_context_bound": False,
|
||||||
|
"session_profile": None,
|
||||||
|
"session_remote": None,
|
||||||
|
"session_host": None,
|
||||||
|
"session_identity": None,
|
||||||
|
"session_repository": None,
|
||||||
|
"session_org": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"session_context_bound": True,
|
||||||
|
"session_profile": data.get("profile_name"),
|
||||||
|
"session_remote": data.get("remote"),
|
||||||
|
"session_host": data.get("host"),
|
||||||
|
"session_identity": data.get("identity"),
|
||||||
|
"session_repository": data.get("repository"),
|
||||||
|
"session_org": data.get("org"),
|
||||||
|
"session_role_kind": data.get("role_kind"),
|
||||||
|
"session_context_source": data.get("source"),
|
||||||
|
"session_canonical_repository_root": data.get("canonical_repository_root"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"bound_context": dict(ctx) if ctx else None,
|
||||||
|
"audit": mutation_context_audit_fields(ctx),
|
||||||
|
}
|
||||||
@@ -33,22 +33,34 @@ Steps:
|
|||||||
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
*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.
|
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.
|
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||||
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
|
||||||
|
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
|
||||||
|
- `merge_now` → continue merger path (do **not** update the branch)
|
||||||
|
- `update_branch_by_merge` → STOP; hand off to **author** for
|
||||||
|
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
|
||||||
|
- `author_conflict_remediation` → STOP; author worktree conflict fix
|
||||||
|
- `fresh_review_required` → STOP; independent re-review at current head
|
||||||
|
- `blocked` → STOP and diagnose
|
||||||
|
Never merge on a former-head approval after update/remediation.
|
||||||
|
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||||
output (or runtime context) proving merge_pr is allowed — a bare
|
output (or runtime context) proving merge_pr is allowed — a bare
|
||||||
"capability checks passed" claim is downgraded.
|
"capability checks passed" claim is downgraded.
|
||||||
5. Final live-state recheck (#179), immediately before the merge mutation —
|
6. Final live-state recheck (#179), immediately before the merge mutation —
|
||||||
re-read the live PR and prove:
|
re-read the live PR and prove:
|
||||||
- PR still open
|
- PR still open
|
||||||
- live head SHA still equals the pinned/reviewed head SHA
|
- live head SHA still equals the pinned/reviewed head SHA
|
||||||
- base branch unchanged
|
- base branch unchanged
|
||||||
- no undismissed REQUEST_CHANGES / blocking review state remains
|
- no undismissed REQUEST_CHANGES / blocking review state remains
|
||||||
If any recheck fails → STOP, re-pin, re-validate.
|
If any recheck fails → STOP, re-pin, re-validate.
|
||||||
6. If any gate fails → STOP and report.
|
7. If any gate fails → STOP and report.
|
||||||
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||||
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
||||||
the changed-file set.
|
the changed-file set.
|
||||||
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
9. 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.*
|
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||||
|
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
|
||||||
|
cleanup handoff, then reassess the **next** PR (do not batch-update all
|
||||||
|
open PRs).
|
||||||
|
|
||||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||||
|
|||||||
@@ -950,9 +950,53 @@ Final reports must state:
|
|||||||
* final live head SHA before merge
|
* final live head SHA before merge
|
||||||
* whether any push occurred during validation
|
* whether any push occurred during validation
|
||||||
|
|
||||||
|
## 26D. PR synchronization and conflict-remediation lifecycle
|
||||||
|
|
||||||
|
**Do not treat “approved” as the final readiness state.** For every approved
|
||||||
|
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
|
||||||
|
`recommended_next_action`:
|
||||||
|
|
||||||
|
| Action | Meaning | Next role / tools |
|
||||||
|
|--------|---------|-------------------|
|
||||||
|
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
|
||||||
|
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
|
||||||
|
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
|
||||||
|
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
|
||||||
|
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
* Pin both expected PR head and expected base head for every update. Either
|
||||||
|
race → fail closed with no partial mutation.
|
||||||
|
* Never rebase or force-push author branches.
|
||||||
|
* Never update an author branch from a reviewer or merger profile.
|
||||||
|
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
|
||||||
|
across a head change.
|
||||||
|
* Process PRs **sequentially**: synchronize/remediate one → review new head →
|
||||||
|
merge → refresh live `master` → reassess the next PR. Do not update every
|
||||||
|
open PR at once (each merge restales the rest).
|
||||||
|
* Conflict remediation only in the existing dedicated issue/PR worktree under
|
||||||
|
`branches/`. Do not delete that worktree until the updated PR is merged and
|
||||||
|
cleanup eligibility is proven.
|
||||||
|
* Post-merge: canonical cleanup handoff to reconciler (section 28).
|
||||||
|
|
||||||
|
### After `gitea_update_pr_branch_by_merge` succeeds
|
||||||
|
|
||||||
|
1. Treat former-head approval as invalidated.
|
||||||
|
2. Release/supersede obsolete reviewer and merger leases.
|
||||||
|
3. Route `recommended_next_action=fresh_review_required` at the new head.
|
||||||
|
4. Independent re-review, then merger lease/adopt + merge at the new head only.
|
||||||
|
|
||||||
|
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
|
||||||
|
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
|
||||||
|
access.
|
||||||
|
|
||||||
## 27. Merge rules
|
## 27. Merge rules
|
||||||
|
|
||||||
Before merge, rerun fresh live checks:
|
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
|
||||||
|
and may be behind base. Only proceed with merge when
|
||||||
|
`recommended_next_action` is `merge_now` and approval remains at the current
|
||||||
|
head. Then rerun fresh live checks:
|
||||||
|
|
||||||
* whoami
|
* whoami
|
||||||
* active profile/runtime
|
* active profile/runtime
|
||||||
|
|||||||
+37
-1
@@ -73,6 +73,24 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.branch.push",
|
"permission": "gitea.branch.push",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||||
|
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||||
|
"assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"review_pr": {
|
"review_pr": {
|
||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
@@ -107,6 +125,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "merger",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
|
"gitea_adopt_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||||
"cleanup_obsolete_reviewer_comment_lease": {
|
"cleanup_obsolete_reviewer_comment_lease": {
|
||||||
@@ -191,9 +221,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "merger",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
|
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
|
||||||
|
# only to the reconciler profile, so the resolver must classify this task as
|
||||||
|
# reconciler (previously "author", which no delete-capable profile held).
|
||||||
|
# Raw gitea_delete_branch still redirects reconciler to the guarded
|
||||||
|
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
|
||||||
|
# stay denied by both the permission gate and this role gate.
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
},
|
},
|
||||||
"cleanup_merged_pr_branch": {
|
"cleanup_merged_pr_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
|
|||||||
+63
-17
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
so durable load/save never touches host state even after env clears.
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
# Each pytest item is an independent logical MCP session. Reset both before
|
||||||
|
# and after the item; the post-yield reset is in a finally block so an
|
||||||
|
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||||
|
# next item. Production code has no automatic per-call reset path.
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
for env_key in [
|
for env_key in [
|
||||||
"GITEA_SESSION_PROFILE_LOCK",
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
"GITEA_ACTIVE_WORKTREE",
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
@@ -80,11 +88,31 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
_state_tmp.cleanup()
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
return
|
return
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
|
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||||
|
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||||
|
# object with its own identity caches; leaving it dirty leaks login across
|
||||||
|
# tests that import the implementation module directly.
|
||||||
|
try:
|
||||||
|
import gitea_mcp_server as _gitea_impl
|
||||||
|
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||||
|
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||||
@@ -113,19 +141,37 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
capability_stop_terminal.clear()
|
capability_stop_terminal.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
import capability_stop_terminal
|
||||||
|
capability_stop_terminal.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import review_workflow_load
|
||||||
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# #714: deterministic workspace remotes only (no host git dependency).
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _deterministic_workspace_remotes():
|
||||||
|
try:
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||||
|
install_deterministic_remote_urls()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield
|
yield
|
||||||
try:
|
|
||||||
import capability_stop_terminal
|
|
||||||
capability_stop_terminal.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
mcp_server._REVIEW_DECISION_LOCK = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_state_tmp.cleanup()
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Centralized config-backed mutation fixture for #714.
|
||||||
|
|
||||||
|
Mutation tests must opt into this helper explicitly. It never grants
|
||||||
|
mutation authority via environment variables alone.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||||
|
- profile-scoped operations (not every mutation op for every test)
|
||||||
|
- deterministic workspace remotes (no host Git dependency)
|
||||||
|
- one canonical repository per profile by default
|
||||||
|
- isolated state + cleanup in ``finally``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||||
|
MDCPS_SLUG = "913443/eAgenda"
|
||||||
|
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||||
|
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||||
|
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||||
|
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||||
|
|
||||||
|
|
||||||
|
def _author_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _author_forbidden() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _merger_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
context: str,
|
||||||
|
role: str,
|
||||||
|
base_url: str,
|
||||||
|
auth_env: str,
|
||||||
|
allowed_operations: Sequence[str],
|
||||||
|
forbidden_operations: Sequence[str],
|
||||||
|
allowed_repositories: Sequence[str],
|
||||||
|
username: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
body = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": context,
|
||||||
|
"role": role,
|
||||||
|
"base_url": base_url,
|
||||||
|
"auth": {"type": "env", "name": auth_env},
|
||||||
|
"allowed_operations": list(allowed_operations),
|
||||||
|
"forbidden_operations": list(forbidden_operations),
|
||||||
|
"allowed_repositories": list(allowed_repositories),
|
||||||
|
"execution_profile": name,
|
||||||
|
}
|
||||||
|
if username:
|
||||||
|
body["username"] = username
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def build_dual_remote_config(
|
||||||
|
*,
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||||
|
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a minimal dual-host v2 config.
|
||||||
|
|
||||||
|
Each profile authorizes only its host-canonical repository by default.
|
||||||
|
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||||
|
REMOTES to that synthetic unit-test identity.
|
||||||
|
"""
|
||||||
|
ops = list(allowed_operations or _author_ops())
|
||||||
|
forb = _author_forbidden()
|
||||||
|
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||||
|
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||||
|
if include_example_repo:
|
||||||
|
for repos in (prgs_repos, mdcps_repos):
|
||||||
|
if EXAMPLE_SLUG not in repos:
|
||||||
|
repos.append(EXAMPLE_SLUG)
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"test-author-prgs": _profile(
|
||||||
|
name="test-author-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-author-dadeschools": _profile(
|
||||||
|
name="test-author-dadeschools",
|
||||||
|
context="mdcps",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.dadeschools.net",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=mdcps_repos,
|
||||||
|
),
|
||||||
|
"test-reviewer-prgs": _profile(
|
||||||
|
name="test-reviewer-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="reviewer",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_reviewer_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-merger-prgs": _profile(
|
||||||
|
name="test-merger-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="merger",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_merger_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_profiles:
|
||||||
|
profiles.update(deepcopy(extra_profiles))
|
||||||
|
|
||||||
|
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||||
|
for alias, base in (
|
||||||
|
("gitea-author", "test-author-prgs"),
|
||||||
|
("author-test", "test-author-dadeschools"),
|
||||||
|
("author", "test-author-dadeschools"),
|
||||||
|
("full-author", "test-author-dadeschools"),
|
||||||
|
("test-author", "test-author-dadeschools"),
|
||||||
|
("prgs-author", "test-author-prgs"),
|
||||||
|
("gitea-reviewer", "test-reviewer-prgs"),
|
||||||
|
("prgs-reviewer", "test-reviewer-prgs"),
|
||||||
|
("gitea-merger", "test-merger-prgs"),
|
||||||
|
("prgs-merger", "test-merger-prgs"),
|
||||||
|
):
|
||||||
|
if alias not in profiles and base in profiles:
|
||||||
|
clone = deepcopy(profiles[base])
|
||||||
|
clone["execution_profile"] = alias
|
||||||
|
profiles[alias] = clone
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_v2_config(**kwargs):
|
||||||
|
"""Back-compat alias."""
|
||||||
|
return build_dual_remote_config(
|
||||||
|
allowed_operations=kwargs.get("allowed_operations"),
|
||||||
|
extra_profiles=kwargs.get("extra_profiles"),
|
||||||
|
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_allowed_repositories(
|
||||||
|
config: dict,
|
||||||
|
repos: Sequence[str],
|
||||||
|
*,
|
||||||
|
profiles: Sequence[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||||
|
for name, prof in (out.get("profiles") or {}).items():
|
||||||
|
if name in targets:
|
||||||
|
prof["allowed_repositories"] = list(repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_all_profiles_have_scope(
|
||||||
|
config: dict,
|
||||||
|
default_repos: Sequence[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
for _name, prof in (out.get("profiles") or {}).items():
|
||||||
|
raw = prof.get("allowed_repositories")
|
||||||
|
if not isinstance(raw, (list, tuple)) or not raw:
|
||||||
|
prof["allowed_repositories"] = list(default_repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def mutation_profile_env(
|
||||||
|
*,
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories: Sequence[str] | None = None,
|
||||||
|
workspace_urls: dict | None = None,
|
||||||
|
clear_env: bool = False,
|
||||||
|
extra_env: dict | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
config: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||||
|
import gitea_config
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
prgs_repos = None
|
||||||
|
mdcps_repos = None
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
# Caller-specified single allowlist applied to both host profiles.
|
||||||
|
prgs_repos = list(allowed_repositories)
|
||||||
|
mdcps_repos = list(allowed_repositories)
|
||||||
|
cfg = build_dual_remote_config(
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
allowed_repositories_prgs=prgs_repos,
|
||||||
|
allowed_repositories_mdcps=mdcps_repos,
|
||||||
|
extra_profiles=extra_profiles,
|
||||||
|
include_example_repo=include_example_repo,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfg = deepcopy(config)
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||||
|
|
||||||
|
urls = (
|
||||||
|
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||||
|
if workspace_urls is None
|
||||||
|
else dict(workspace_urls)
|
||||||
|
)
|
||||||
|
|
||||||
|
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||||
|
try:
|
||||||
|
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||||
|
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cfg, fh)
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": cfg_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_env:
|
||||||
|
env.update(extra_env)
|
||||||
|
|
||||||
|
def _remote_url(name: str):
|
||||||
|
if name in urls:
|
||||||
|
return urls[name]
|
||||||
|
# Prefer live REMOTES (tests often patch Example-Org).
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"env": env,
|
||||||
|
"config_path": cfg_path,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"urls": urls,
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=clear_env):
|
||||||
|
os.environ.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||||
|
mcp_srv = None
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv # type: ignore
|
||||||
|
except Exception:
|
||||||
|
mcp_srv = None
|
||||||
|
if mcp_srv is not None:
|
||||||
|
with patch.object(
|
||||||
|
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||||
|
):
|
||||||
|
yield payload
|
||||||
|
else:
|
||||||
|
yield payload
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||||
|
_SHARED_CFG_PATH = None
|
||||||
|
_SHARED_CFG_DIR = None
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||||
|
"""Write (once) a dual-remote mutation config and return its path."""
|
||||||
|
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
import atexit
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if include_example_repo:
|
||||||
|
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
):
|
||||||
|
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||||
|
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(), fh)
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_env(
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
*,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
**extra,
|
||||||
|
) -> dict:
|
||||||
|
"""Env dict for mutation tests: config-backed + optional extras.
|
||||||
|
|
||||||
|
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||||
|
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||||
|
include_example_repo=include_example_repo
|
||||||
|
),
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
# Callers must not be able to drop the pytest marker by accident.
|
||||||
|
env.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def install_deterministic_remote_urls() -> None:
|
||||||
|
"""Patch server modules so workspace remotes are deterministic.
|
||||||
|
|
||||||
|
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||||
|
workspace alignment). Map the historical prgs→Timesheet default to
|
||||||
|
Gitea-Tools so session binding matches the control repository under test.
|
||||||
|
"""
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
def _url(name: str):
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if name == "prgs":
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools":
|
||||||
|
return MDCPS_URL
|
||||||
|
return None
|
||||||
|
|
||||||
|
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv
|
||||||
|
|
||||||
|
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
|
|||||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-prgs",
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
+46
-16
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||||
|
|
||||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||||
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _env(self, **extra):
|
def _env(self, **extra):
|
||||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
"GITEA_PROFILE_NAME", None
|
||||||
|
) or "test-author-prgs"
|
||||||
|
env = shared_mutation_env(
|
||||||
|
profile,
|
||||||
|
GITEA_AUDIT_LOG=self.audit_path,
|
||||||
|
GITEA_TOKEN_TEST="test-token",
|
||||||
|
)
|
||||||
|
# Strip legacy env-only authority knobs if callers still pass them;
|
||||||
|
# config-backed profiles own operations and repositories (#714).
|
||||||
|
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_PROFILE_NAME", None)
|
||||||
env.update(extra)
|
env.update(extra)
|
||||||
|
env["GITEA_MCP_PROFILE"] = profile
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _records(self):
|
def _records(self):
|
||||||
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
rec = recs[0]
|
rec = recs[0]
|
||||||
self.assertEqual(rec["action"], "create_issue")
|
self.assertEqual(rec["action"], "create_issue")
|
||||||
self.assertEqual(rec["result"], "succeeded")
|
self.assertEqual(rec["result"], "succeeded")
|
||||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||||
self.assertEqual(rec["issue_number"], 11)
|
self.assertEqual(rec["issue_number"], 11)
|
||||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||||
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
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.
|
# 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"}
|
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||||
with patch.dict(os.environ, {
|
with patch.dict(
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
os.environ,
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
shared_mutation_env("test-author-prgs"),
|
||||||
}, clear=True):
|
clear=True,
|
||||||
|
):
|
||||||
gitea_create_issue(title="x", remote="prgs")
|
gitea_create_issue(title="x", remote="prgs")
|
||||||
issue_posts = [
|
issue_posts = [
|
||||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||||
@@ -342,8 +359,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
self._pr("author-bot"), approval, # gate 7 feedback
|
self._pr("author-bot"), approval, # gate 7 feedback
|
||||||
{}, {"merged_commit_sha": "c1"},
|
{}, {"merged_commit_sha": "c1"},
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||||
@@ -362,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||||
# Self-author merge is blocked; must still be recorded as blocked.
|
# Self-author merge is blocked; must still be recorded as blocked.
|
||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||||
@@ -385,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
import session_context_binding as _sc
|
||||||
|
from tests.test_mcp_server import (
|
||||||
|
_init_reviewer_session,
|
||||||
|
_install_owned_reviewer_lease,
|
||||||
|
)
|
||||||
from mcp_server import gitea_mark_final_review_decision
|
from mcp_server import gitea_mark_final_review_decision
|
||||||
|
|
||||||
|
# Rebind after profile env is applied so durable session state
|
||||||
|
# matches the active reviewer profile (#714 / #695).
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
_init_reviewer_session("prgs")
|
||||||
|
lease = _install_owned_reviewer_lease(8)
|
||||||
|
lease.start()
|
||||||
|
self.addCleanup(lease.stop)
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(
|
||||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||||
)
|
)
|
||||||
@@ -398,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
body="LGTM", remote="prgs",
|
body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(
|
||||||
|
r["performed"],
|
||||||
|
msg=f"submit_pr_review blocked: {r}",
|
||||||
|
)
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
self.assertEqual(len(recs), 1)
|
self.assertEqual(len(recs), 1)
|
||||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ from review_proofs import assess_audit_reconciliation_report as proofs_assess
|
|||||||
from task_capability_map import required_permission, required_role
|
from task_capability_map import required_permission, required_role
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
DELETE_PROFILE = {
|
||||||
"profile_name": "prgs-author-delete",
|
# #729: delete_branch is reconciler-owned.
|
||||||
"role": "author",
|
"profile_name": "prgs-reconciler-delete",
|
||||||
|
"role": "reconciler",
|
||||||
"allowed_operations": [
|
"allowed_operations": [
|
||||||
"gitea.read",
|
"gitea.read",
|
||||||
"gitea.pr.create",
|
"gitea.pr.create",
|
||||||
@@ -238,7 +239,7 @@ class TestMcpGates(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||||
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
def test_delete_branch_blocked_in_audit_phase(self, _profile):
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
|
||||||
@@ -278,7 +279,7 @@ class TestMcpGates(unittest.TestCase):
|
|||||||
after_state="gone",
|
after_state="gone",
|
||||||
)
|
)
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
self.mock_api.return_value = {}
|
self.mock_api.return_value = {}
|
||||||
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
|
|||||||
@@ -257,22 +257,27 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertFalse(delete_calls)
|
self.assertFalse(delete_calls)
|
||||||
|
|
||||||
def test_reconciler_with_branch_delete_cannot_raw_delete(self):
|
def test_reconciler_with_branch_delete_can_raw_delete(self):
|
||||||
"""#687: reconciler + gitea.branch.delete still cannot call raw delete."""
|
"""#729: delete_branch is re-homed from author to reconciler. The
|
||||||
|
reconciler is the delete-capable role and performs raw deletion of an
|
||||||
|
eligible (non-preservation, non-protected) branch — superseding the
|
||||||
|
#687 redirect that previously blocked it."""
|
||||||
patch(
|
patch(
|
||||||
"mcp_server.get_profile",
|
"mcp_server.get_profile",
|
||||||
return_value=dict(RECONCILER_WITH_DELETE),
|
return_value=dict(RECONCILER_WITH_DELETE),
|
||||||
).start()
|
).start()
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
|
self.mock_api.return_value = {}
|
||||||
res = gitea_delete_branch(
|
res = gitea_delete_branch(
|
||||||
branch="fix/issue-683-workflow-guard-hardening",
|
branch="fix/issue-683-workflow-guard-hardening",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
)
|
)
|
||||||
self.assertFalse(res.get("success", True))
|
self.assertTrue(res.get("success"))
|
||||||
self.assertFalse(res.get("performed", True))
|
delete_calls = [
|
||||||
reasons = " ".join(res.get("reasons") or [])
|
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||||
self.assertIn("raw gitea_delete_branch", reasons)
|
]
|
||||||
self.assertIn("cleanup_merged_pr_branch", reasons)
|
self.assertTrue(delete_calls)
|
||||||
self.mock_api.assert_not_called()
|
|
||||||
|
|
||||||
def test_reconciler_raw_delete_denies_preservation_branch(self):
|
def test_reconciler_raw_delete_denies_preservation_branch(self):
|
||||||
patch(
|
patch(
|
||||||
@@ -286,17 +291,18 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
|||||||
self.assertFalse(res.get("performed", True))
|
self.assertFalse(res.get("performed", True))
|
||||||
self.mock_api.assert_not_called()
|
self.mock_api.assert_not_called()
|
||||||
|
|
||||||
def test_author_with_branch_delete_role_ok_but_preserve_blocked(self):
|
def test_reconciler_raw_delete_role_ok_but_preserve_blocked(self):
|
||||||
"""Author role may use raw delete path when permitted; preserve fails closed."""
|
"""#729: reconciler role may use raw delete path when permitted;
|
||||||
|
preservation branch still fails closed."""
|
||||||
patch(
|
patch(
|
||||||
"mcp_server.get_profile",
|
"mcp_server.get_profile",
|
||||||
return_value={
|
return_value={
|
||||||
"profile_name": "prgs-author",
|
"profile_name": "prgs-reconciler",
|
||||||
"role": "author",
|
"role": "reconciler",
|
||||||
"allowed_operations": [
|
"allowed_operations": [
|
||||||
"gitea.read",
|
"gitea.read",
|
||||||
"gitea.pr.create",
|
"gitea.issue.comment",
|
||||||
"gitea.branch.push",
|
"gitea.pr.comment",
|
||||||
"gitea.branch.delete",
|
"gitea.branch.delete",
|
||||||
],
|
],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ CONFIG = {
|
|||||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||||
"forbidden_operations": ["gitea.pr.create"],
|
"forbidden_operations": ["gitea.pr.create"],
|
||||||
"execution_profile": "commit-author",
|
"execution_profile": "commit-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"pr-only-author": {
|
"pr-only-author": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -39,6 +40,7 @@ CONFIG = {
|
|||||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||||
"forbidden_operations": ["gitea.repo.commit"],
|
"forbidden_operations": ["gitea.repo.commit"],
|
||||||
"execution_profile": "pr-only-author",
|
"execution_profile": "pr-only-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +53,7 @@ CONFIG = {
|
|||||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
||||||
],
|
],
|
||||||
"execution_profile": "reviewer-profile",
|
"execution_profile": "reviewer-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-no-commit": {
|
"reviewer-no-commit": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -49,6 +50,7 @@ CONFIG = {
|
|||||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||||
],
|
],
|
||||||
"execution_profile": "reviewer-no-commit",
|
"execution_profile": "reviewer-no-commit",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CONFIG = {
|
|||||||
"gitea.pr.review",
|
"gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||||
os.remove(self.lock_file_path)
|
os.remove(self.lock_file_path)
|
||||||
|
|
||||||
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||||
its integration into gitea_auth.get_profile / get_auth_header.
|
its integration into gitea_auth.get_profile / get_auth_header.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
"""Tests for create_issue.py.
|
"""Tests for create_issue.py.
|
||||||
|
|
||||||
Every test mocks auth functions so no real network calls or keychain
|
Every test mocks auth functions so no real network calls or keychain
|
||||||
@@ -12,7 +13,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch, MagicMock, patch
|
||||||
|
|
||||||
# The module under test lives in the repo root, not a package.
|
# The module under test lives in the repo root, not a package.
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@unittest.skipIf(_SKIP, _REASON)
|
@unittest.skipIf(_SKIP, _REASON)
|
||||||
class TestAPIPayload(unittest.TestCase):
|
class TestAPIPayload(unittest.TestCase):
|
||||||
|
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||||
|
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||||
|
migration of legacy omitted-target assertions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._ws_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._ws_env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._ws_env.stop()
|
||||||
|
|
||||||
"""Ensure the JSON payload sent to Gitea is correct."""
|
"""Ensure the JSON payload sent to Gitea is correct."""
|
||||||
|
|
||||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
|
|||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][1]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
url,
|
url,
|
||||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
"""Tests for create_pr.py.
|
"""Tests for create_pr.py.
|
||||||
|
|
||||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||||
@@ -8,7 +9,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch, MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
import create_pr # noqa: E402
|
import create_pr # noqa: E402
|
||||||
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
|
|||||||
# API payload
|
# API payload
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestAPIPayload(unittest.TestCase):
|
class TestAPIPayload(unittest.TestCase):
|
||||||
|
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||||
|
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||||
|
migration of legacy omitted-target assertions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._ws_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._ws_env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._ws_env.stop()
|
||||||
|
|
||||||
|
|
||||||
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
||||||
def test_payload_fields(self, _cred):
|
def test_payload_fields(self, _cred):
|
||||||
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
|
|||||||
url = MockReq.call_args[0][0]
|
url = MockReq.call_args[0][0]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
url,
|
url,
|
||||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
"""Tests for gitea_delete_branch capability + role gate (Issue #408, #729).
|
||||||
|
|
||||||
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||||
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||||
structured permission report). With it, deletion proceeds through existing
|
structured permission report).
|
||||||
preflight and audit unchanged.
|
|
||||||
|
#729: delete_branch is reconciler-owned. ``gitea.branch.delete`` is granted only
|
||||||
|
to the reconciler profile, so the resolver classifies delete_branch as a
|
||||||
|
reconciler task. Raw ``gitea_delete_branch`` still redirects the reconciler to
|
||||||
|
the guarded ``gitea_cleanup_merged_pr_branch`` path (#514/#687); author,
|
||||||
|
reviewer, and merger remain denied by the permission gate and/or the required
|
||||||
|
role gate.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,6 +22,8 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
|
|||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
from mcp_server import gitea_delete_branch
|
from mcp_server import gitea_delete_branch
|
||||||
|
import task_capability_map
|
||||||
|
import role_session_router
|
||||||
|
|
||||||
FAKE_AUTH = "token fake"
|
FAKE_AUTH = "token fake"
|
||||||
|
|
||||||
@@ -38,6 +46,22 @@ AUTHOR_WITH_DELETE = {
|
|||||||
"audit_label": "prgs-author-deleter",
|
"audit_label": "prgs-author-deleter",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #729: delete_branch is reconciler-owned. The reconciler holds
|
||||||
|
# gitea.branch.delete but raw gitea_delete_branch redirects it to the guarded
|
||||||
|
# gitea_cleanup_merged_pr_branch path (#514/#687).
|
||||||
|
RECONCILER_WITH_DELETE = {
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create",
|
||||||
|
],
|
||||||
|
"audit_label": "prgs-reconciler",
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"contexts": {
|
"contexts": {
|
||||||
@@ -81,6 +105,20 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"execution_profile": "reviewer-profile",
|
"execution_profile": "reviewer-profile",
|
||||||
},
|
},
|
||||||
|
# #729: reconciler is the only delete-capable role.
|
||||||
|
"reconciler-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "reconciler",
|
||||||
|
"username": "reconciler-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_RECONCILER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||||
|
"execution_profile": "reconciler-profile",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
}
|
}
|
||||||
@@ -121,6 +159,9 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
|||||||
def _set_profile(self, profile):
|
def _set_profile(self, profile):
|
||||||
patch("mcp_server.get_profile", return_value=profile).start()
|
patch("mcp_server.get_profile", return_value=profile).start()
|
||||||
|
|
||||||
|
def _delete_calls(self):
|
||||||
|
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||||
|
|
||||||
def test_blocked_without_delete_capability(self):
|
def test_blocked_without_delete_capability(self):
|
||||||
self._set_profile(AUTHOR_NO_DELETE)
|
self._set_profile(AUTHOR_NO_DELETE)
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
@@ -135,33 +176,53 @@ class TestDeleteBranchToolGate(unittest.TestCase):
|
|||||||
self.mock_api.assert_not_called()
|
self.mock_api.assert_not_called()
|
||||||
self.mock_auth.assert_not_called()
|
self.mock_auth.assert_not_called()
|
||||||
|
|
||||||
def test_allowed_delete_proceeds(self):
|
def test_author_with_delete_perm_denied_by_role_gate(self):
|
||||||
|
"""#729: even an author holding gitea.branch.delete is denied — the
|
||||||
|
required role is now reconciler. Fail closed, no API DELETE."""
|
||||||
self._set_profile(AUTHOR_WITH_DELETE)
|
self._set_profile(AUTHOR_WITH_DELETE)
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||||
|
self.assertEqual(res["active_role_kind"], "author")
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_reviewer_with_delete_perm_denied_by_role_gate(self):
|
||||||
|
"""A reviewer that somehow holds the permission is still denied."""
|
||||||
|
reviewer_with_delete = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.review", "gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
self._set_profile(reviewer_with_delete)
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="reviewer")
|
||||||
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||||
|
self.assertEqual(res["active_role_kind"], "reviewer")
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_reconciler_performs_raw_delete(self):
|
||||||
|
"""#729: the reconciler is the delete-capable role and performs the raw
|
||||||
|
deletion (no audit phase active here); the API DELETE is issued."""
|
||||||
|
self._set_profile(RECONCILER_WITH_DELETE)
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check(
|
||||||
|
"capability", resolved_role="reconciler")
|
||||||
|
self.mock_api.return_value = {}
|
||||||
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertIn("deleted", res["message"])
|
self.assertIn("deleted", res["message"])
|
||||||
delete_calls = [
|
self.assertTrue(self._delete_calls())
|
||||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
|
||||||
]
|
|
||||||
self.assertTrue(delete_calls)
|
|
||||||
|
|
||||||
def test_allowed_delete_audited_with_capability_proof(self):
|
|
||||||
self._set_profile(AUTHOR_WITH_DELETE)
|
|
||||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
|
||||||
mock_write = patch("gitea_audit.write_event").start()
|
|
||||||
mcp_server.record_preflight_check("whoami")
|
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
|
||||||
self.mock_api.return_value = {}
|
|
||||||
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
|
||||||
mock_write.assert_called()
|
|
||||||
event = mock_write.call_args[0][0]
|
|
||||||
self.assertEqual(event["action"], "delete_branch")
|
|
||||||
self.assertEqual(
|
|
||||||
event["request_metadata"]["required_permission"],
|
|
||||||
"gitea.branch.delete",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteBranchResolverParity(unittest.TestCase):
|
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||||
@@ -194,23 +255,88 @@ class TestDeleteBranchResolverParity(unittest.TestCase):
|
|||||||
"GITEA_MCP_PROFILE": profile,
|
"GITEA_MCP_PROFILE": profile,
|
||||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
"GITEA_TOKEN_RECONCILER": "reconciler-pass",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _delete_calls(self):
|
||||||
|
return [c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"]
|
||||||
|
|
||||||
|
def test_reconciler_resolver_allows_delete_branch(self):
|
||||||
|
"""#729: resolve(delete_branch) on the reconciler profile is allowed and
|
||||||
|
classified as a reconciler task."""
|
||||||
|
with patch.dict(os.environ, self._env("reconciler-profile"), clear=True):
|
||||||
|
resolve = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="delete_branch", remote="prgs")
|
||||||
|
# Deterministic role-map outcomes (avoid runtime-staleness-dependent
|
||||||
|
# allowed_in_current_session, which folds in reconnect state).
|
||||||
|
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||||
|
self.assertEqual(
|
||||||
|
resolve["required_operation_permission"], "gitea.branch.delete")
|
||||||
|
self.assertTrue(resolve["active_profile_permission_allowed"])
|
||||||
|
self.assertTrue(resolve["configured"])
|
||||||
|
self.assertIn("reconciler-profile", resolve["matching_configured_profile"])
|
||||||
|
self.assertEqual(resolve["active_role_kind"], "reconciler")
|
||||||
|
|
||||||
|
def test_author_resolver_denies_delete_branch(self):
|
||||||
|
"""#729: an author session cannot resolve delete_branch — required role
|
||||||
|
is reconciler and the author lacks the permission."""
|
||||||
|
with patch.dict(os.environ, self._env("author-no-delete"), clear=True):
|
||||||
|
resolve = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="delete_branch", remote="prgs")
|
||||||
|
self.assertEqual(resolve["required_role_kind"], "reconciler")
|
||||||
|
self.assertFalse(resolve["allowed_in_current_session"])
|
||||||
|
|
||||||
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||||
resolve = mcp_server.gitea_resolve_task_capability(
|
resolve = mcp_server.gitea_resolve_task_capability(
|
||||||
task="delete_branch", remote="prgs")
|
task="delete_branch", remote="prgs")
|
||||||
self.assertFalse(resolve["allowed_in_current_session"])
|
self.assertFalse(resolve["allowed_in_current_session"])
|
||||||
|
patch("mcp_server.get_profile", return_value={
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": ["gitea.branch.delete"],
|
||||||
|
}).start()
|
||||||
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
self.assertFalse(res["success"])
|
self.assertFalse(res["success"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
res["permission_report"]["missing_permission"],
|
res["permission_report"]["missing_permission"],
|
||||||
resolve["required_operation_permission"],
|
resolve["required_operation_permission"],
|
||||||
)
|
)
|
||||||
delete_calls = [
|
self.assertFalse(self._delete_calls())
|
||||||
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
|
||||||
]
|
|
||||||
self.assertFalse(delete_calls)
|
class TestDeleteBranchRoleMapParity(unittest.TestCase):
|
||||||
|
"""#729: both single-source-of-truth maps must classify delete_branch as
|
||||||
|
reconciler and stay in agreement."""
|
||||||
|
|
||||||
|
def test_task_capability_map_role_reconciler(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("delete_branch"), "reconciler")
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("delete_branch"),
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_router_required_role_reconciler(self):
|
||||||
|
self.assertEqual(
|
||||||
|
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
role_session_router.required_role_for_task("delete_branch"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_router_set_membership_moved(self):
|
||||||
|
self.assertIn("delete_branch", role_session_router.RECONCILER_TASKS)
|
||||||
|
self.assertNotIn("delete_branch", role_session_router.AUTHOR_TASKS)
|
||||||
|
|
||||||
|
def test_maps_agree(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("delete_branch"),
|
||||||
|
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||||
|
|
||||||
|
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||||
|
* touch mcp_config.json (or any MCP client config)
|
||||||
|
* spawn recovery threads
|
||||||
|
* call os._exit / terminate the serving process
|
||||||
|
* claim that an auto-restart was triggered
|
||||||
|
|
||||||
|
Recovery is owned by the IDE/client reconnect path only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_PROFILES = (
|
||||||
|
("create_issue", "prgs-author", "author"),
|
||||||
|
("review_pr", "prgs-reviewer", "reviewer"),
|
||||||
|
("merge_pr", "prgs-merger", "merger"),
|
||||||
|
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||||
|
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||||
|
mock_getpid = MagicMock(return_value=12345)
|
||||||
|
mock_exists = MagicMock(return_value=True)
|
||||||
|
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||||
|
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||||
|
|
||||||
|
ps_output = (
|
||||||
|
" PID LSTART COMMAND\n"
|
||||||
|
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
)
|
||||||
|
mock_run_ps = MagicMock()
|
||||||
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
|
mock_run_env = MagicMock()
|
||||||
|
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||||
|
|
||||||
|
mock_run_git = MagicMock()
|
||||||
|
mock_run_git.stdout = "SAME"
|
||||||
|
|
||||||
|
def side_effect(args, **kwargs):
|
||||||
|
if args[0] == "ps" and "eww" in args:
|
||||||
|
return mock_run_env
|
||||||
|
if args[0] == "ps":
|
||||||
|
return mock_run_ps
|
||||||
|
if args[0] == "git":
|
||||||
|
return mock_run_git
|
||||||
|
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||||
|
|
||||||
|
mock_run = MagicMock(side_effect=side_effect)
|
||||||
|
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch("os.utime")
|
||||||
|
@patch("threading.Thread")
|
||||||
|
@patch("os._exit")
|
||||||
|
def test_stale_self_does_not_touch_config_or_exit(
|
||||||
|
self,
|
||||||
|
mock_exit,
|
||||||
|
mock_thread,
|
||||||
|
mock_utime,
|
||||||
|
mock_getpid,
|
||||||
|
mock_exists,
|
||||||
|
mock_getmtime,
|
||||||
|
mock_run,
|
||||||
|
):
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||||
|
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||||
|
|
||||||
|
before_threads = threading.active_count()
|
||||||
|
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||||
|
"create_issue", ["prgs-author"]
|
||||||
|
)
|
||||||
|
after_threads = threading.active_count()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||||
|
for r in reasons),
|
||||||
|
reasons,
|
||||||
|
)
|
||||||
|
# Must not claim auto-restart / config touch
|
||||||
|
blob = " ".join(reasons)
|
||||||
|
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||||
|
self.assertNotIn("touched mcp_config", blob)
|
||||||
|
self.assertNotIn("will cleanly exit", blob)
|
||||||
|
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
self.assertEqual(before_threads, after_threads)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch("os.utime")
|
||||||
|
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||||
|
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||||
|
):
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||||
|
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||||
|
"create_issue", ["prgs-author"]
|
||||||
|
)
|
||||||
|
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||||
|
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
|
||||||
|
def test_trigger_mcp_auto_restart_removed(self):
|
||||||
|
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||||
|
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||||
|
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"role": role,
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"profiles": {
|
||||||
|
profile_name: {
|
||||||
|
"role": role,
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||||
|
profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="test-user"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_ensure_matching_profile", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
), patch(
|
||||||
|
"subprocess.run", mock_run
|
||||||
|
), patch(
|
||||||
|
"os.path.getmtime", mock_getmtime
|
||||||
|
), patch(
|
||||||
|
"os.path.exists", mock_exists
|
||||||
|
), patch(
|
||||||
|
"os.getpid", mock_getpid
|
||||||
|
), patch(
|
||||||
|
"os.utime"
|
||||||
|
) as mock_utime, patch(
|
||||||
|
"threading.Thread"
|
||||||
|
) as mock_thread, patch(
|
||||||
|
"os._exit"
|
||||||
|
) as mock_exit:
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
return result, mock_utime, mock_thread, mock_exit
|
||||||
|
|
||||||
|
def test_stale_returns_typed_blocker_fields(self):
|
||||||
|
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||||
|
"create_issue", "prgs-author", "author"
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("restart_required"), result)
|
||||||
|
self.assertTrue(result.get("stop_required"), result)
|
||||||
|
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||||
|
self.assertIs(result.get("mutation_performed"), False)
|
||||||
|
action = result.get("exact_safe_next_action") or ""
|
||||||
|
self.assertIn("reconnect", action.lower())
|
||||||
|
self.assertNotIn("None; ready for operations", action)
|
||||||
|
reason = result.get("reason") or ""
|
||||||
|
self.assertIn("stale-runtime", reason)
|
||||||
|
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||||
|
for task, profile, role in ROLE_PROFILES:
|
||||||
|
with self.subTest(task=task, profile=profile):
|
||||||
|
# Skip tasks that may be unknown on this branch
|
||||||
|
try:
|
||||||
|
import task_capability_map as tcm
|
||||||
|
|
||||||
|
tcm.required_permission(task)
|
||||||
|
except Exception:
|
||||||
|
self.skipTest(f"task {task} not in capability map")
|
||||||
|
|
||||||
|
result, mock_utime, mock_thread, mock_exit = (
|
||||||
|
self._resolve_with_stale_runtime(task, profile, role)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
result.get("restart_required") or result.get("stop_required"),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||||
|
)
|
||||||
|
self.assertIs(result.get("mutation_performed"), False, result)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
def test_config_mtime_and_contents_unchanged(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = os.path.join(tmp, "mcp_config.json")
|
||||||
|
original = '{"servers": {"gitea-author": {}}}'
|
||||||
|
with open(cfg, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(original)
|
||||||
|
mtime_before = os.path.getmtime(cfg)
|
||||||
|
|
||||||
|
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||||
|
"create_issue", "prgs-author", "author"
|
||||||
|
)
|
||||||
|
# Force-path also must not use real utime when diagnostics runs
|
||||||
|
with open(cfg, encoding="utf-8") as fh:
|
||||||
|
after = fh.read()
|
||||||
|
self.assertEqual(after, original)
|
||||||
|
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
self.assertTrue(result.get("restart_required"), result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||||
|
def test_parity_stale_still_reports_restart_required(self):
|
||||||
|
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||||
|
import master_parity_gate as mpg
|
||||||
|
|
||||||
|
out = mpg.assess_master_parity(
|
||||||
|
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
)
|
||||||
|
self.assertFalse(out.get("in_parity"))
|
||||||
|
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||||
|
def test_resolve_docstring_declares_side_effect_free(self):
|
||||||
|
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||||
|
lower = doc.lower()
|
||||||
|
self.assertTrue(
|
||||||
|
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||||
|
doc,
|
||||||
|
)
|
||||||
|
self.assertNotIn("auto-restart", lower)
|
||||||
|
self.assertNotIn("os._exit", lower)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
|
||||||
|
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
def test_resolve_uses_report_only_stale_binding_assessment(self):
|
||||||
|
"""Capability resolve must call stale-binding assess with auto_recover=False."""
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||||
|
"prgs-author"
|
||||||
|
)
|
||||||
|
fake_binding = {
|
||||||
|
"classification": "missing_path",
|
||||||
|
"active_worktree": "/tmp/gone",
|
||||||
|
"reasons": ["path missing"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="test-user"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_ensure_matching_profile", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
return_value=fake_binding,
|
||||||
|
) as mock_assess, patch(
|
||||||
|
"subprocess.run", mock_run
|
||||||
|
), patch(
|
||||||
|
"os.path.getmtime", mock_getmtime
|
||||||
|
), patch(
|
||||||
|
"os.path.exists", mock_exists
|
||||||
|
), patch(
|
||||||
|
"os.getpid", mock_getpid
|
||||||
|
), patch(
|
||||||
|
"os.utime"
|
||||||
|
) as mock_utime, patch(
|
||||||
|
"threading.Thread"
|
||||||
|
) as mock_thread, patch(
|
||||||
|
"os._exit"
|
||||||
|
) as mock_exit:
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_assess.assert_called()
|
||||||
|
# Every call must be report-only (no env clear / session-state write).
|
||||||
|
for call in mock_assess.call_args_list:
|
||||||
|
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||||
|
self.assertEqual(
|
||||||
|
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||||
|
)
|
||||||
|
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
|
||||||
|
self.assertIs(result.get("mutation_performed"), False, result)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"""Issue #706: immutable startup/profile canonical_repository_root capability.
|
||||||
|
|
||||||
|
Cross-repository MCP namespaces (e.g. ``eagenda-author``) must bind their
|
||||||
|
canonical repository root to the *configured target repository*, not to the
|
||||||
|
Gitea-Tools install checkout the server script lives in. These tests prove:
|
||||||
|
|
||||||
|
* the configured binding is resolved from profile/env with env precedence,
|
||||||
|
* the target repository identity and git common-directory membership are
|
||||||
|
validated (AC3),
|
||||||
|
* the branches-only guard (#274) is enforced *inside the target repo* (AC4),
|
||||||
|
* missing / conflicting / forged bindings fail closed (AC5),
|
||||||
|
* prgs and mdcps namespaces stay simultaneously isolated (AC6),
|
||||||
|
* the session binding pins the canonical root immutably,
|
||||||
|
* no weakening of the install-root single-repo default (AC8).
|
||||||
|
|
||||||
|
Uses two distinct real git repositories/worktrees (AC7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import canonical_repository_root as crr # noqa: E402
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import namespace_workspace_binding as nwb # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: str, *args: str) -> str:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", cwd, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return res.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||||
|
"""Create a real git repo with one commit and a remote; return realpath root."""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_git(str(path), "init", "-q")
|
||||||
|
_git(str(path), "config", "user.email", "[email protected]")
|
||||||
|
_git(str(path), "config", "user.name", "Test")
|
||||||
|
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||||
|
(path / "README.md").write_text("seed\n")
|
||||||
|
_git(str(path), "add", "README.md")
|
||||||
|
_git(str(path), "commit", "-q", "-m", "seed")
|
||||||
|
return os.path.realpath(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_worktree(repo_root: str, worktree_path: Path, branch: str) -> str:
|
||||||
|
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(worktree_path))
|
||||||
|
return os.path.realpath(str(worktree_path))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# configured_canonical_root: resolution + precedence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestConfiguredCanonicalRoot(unittest.TestCase):
|
||||||
|
def test_unconfigured_returns_none(self):
|
||||||
|
value, source = crr.configured_canonical_root({}, {})
|
||||||
|
self.assertIsNone(value)
|
||||||
|
self.assertIsNone(source)
|
||||||
|
|
||||||
|
def test_profile_field_used(self):
|
||||||
|
value, source = crr.configured_canonical_root(
|
||||||
|
{"canonical_repository_root": "/repo/eAgenda"}, {}
|
||||||
|
)
|
||||||
|
self.assertEqual(value, "/repo/eAgenda")
|
||||||
|
self.assertIn("profile", source)
|
||||||
|
|
||||||
|
def test_env_overrides_profile(self):
|
||||||
|
value, source = crr.configured_canonical_root(
|
||||||
|
{"canonical_repository_root": "/repo/eAgenda"},
|
||||||
|
{crr.CANONICAL_ROOT_ENV: "/repo/other"},
|
||||||
|
)
|
||||||
|
self.assertEqual(value, "/repo/other")
|
||||||
|
self.assertIn(crr.CANONICAL_ROOT_ENV, source)
|
||||||
|
|
||||||
|
def test_blank_values_ignored(self):
|
||||||
|
value, source = crr.configured_canonical_root(
|
||||||
|
{"canonical_repository_root": " "},
|
||||||
|
{crr.CANONICAL_ROOT_ENV: ""},
|
||||||
|
)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
self.assertIsNone(source)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# assess_canonical_repository_root: default (single-repo) path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestUnconfiguredFallback(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = self._tmp.name
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_unconfigured_falls_back_to_process_root(self):
|
||||||
|
root = _init_repo(
|
||||||
|
Path(self.tmp) / "install",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
)
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=None,
|
||||||
|
source=None,
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=root,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["proven"])
|
||||||
|
self.assertFalse(got["block"])
|
||||||
|
self.assertFalse(got["configured"])
|
||||||
|
self.assertEqual(got["canonical_repo_root"], root)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# assess_canonical_repository_root: configured cross-repo path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestConfiguredCrossRepo(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self._tmp.name)
|
||||||
|
self.install = _init_repo(
|
||||||
|
self.tmp / "Gitea-Tools",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
)
|
||||||
|
self.target = _init_repo(
|
||||||
|
self.tmp / "mcp-control-plane",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_valid_configured_root_binds_to_target(self):
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.target,
|
||||||
|
source="profile canonical_repository_root",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.install,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["proven"], got.get("reasons"))
|
||||||
|
self.assertFalse(got["block"])
|
||||||
|
self.assertTrue(got["configured"])
|
||||||
|
self.assertEqual(got["canonical_repo_root"], self.target)
|
||||||
|
self.assertNotEqual(got["canonical_repo_root"], self.install)
|
||||||
|
|
||||||
|
def test_missing_path_fails_closed(self):
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=str(self.tmp / "does-not-exist"),
|
||||||
|
source="profile canonical_repository_root",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
self.assertFalse(got["proven"])
|
||||||
|
self.assertTrue(any("exist" in r for r in got["reasons"]))
|
||||||
|
|
||||||
|
def test_non_git_path_fails_closed(self):
|
||||||
|
plain = self.tmp / "plain"
|
||||||
|
plain.mkdir()
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=str(plain),
|
||||||
|
source="profile canonical_repository_root",
|
||||||
|
expected_slug=None,
|
||||||
|
process_project_root=self.install,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
self.assertTrue(any("git" in r for r in got["reasons"]))
|
||||||
|
|
||||||
|
def test_identity_mismatch_fails_closed(self):
|
||||||
|
# Configured root is the install repo, but session expects the target
|
||||||
|
# repo identity: a forged/conflicting binding.
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.install,
|
||||||
|
source="profile canonical_repository_root",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.install,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
self.assertTrue(any("identity" in r for r in got["reasons"]))
|
||||||
|
|
||||||
|
def test_unprovable_identity_blocks_when_required(self):
|
||||||
|
noremote = _init_repo(self.tmp / "noremote-src", "x", remote_name="origin")
|
||||||
|
_git(noremote, "remote", "remove", "origin")
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=noremote,
|
||||||
|
source="profile canonical_repository_root",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.install,
|
||||||
|
require_binding=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
|
||||||
|
def test_repository_identity_slug_reads_remote(self):
|
||||||
|
self.assertEqual(
|
||||||
|
crr.repository_identity_slug(self.target),
|
||||||
|
"Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Workspace membership + branches-only enforced inside the TARGET repo (AC4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self._tmp.name)
|
||||||
|
self.install = _init_repo(
|
||||||
|
self.tmp / "Gitea-Tools",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
)
|
||||||
|
self.target = _init_repo(
|
||||||
|
self.tmp / "mcp-control-plane",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||||
|
)
|
||||||
|
(Path(self.target) / "branches").mkdir()
|
||||||
|
self.target_wt = _add_worktree(
|
||||||
|
self.target,
|
||||||
|
Path(self.target) / "branches" / "author-issue-1",
|
||||||
|
"feat/issue-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_context_canonical_root_is_configured_target(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=self.target_wt,
|
||||||
|
process_project_root=self.install,
|
||||||
|
env={},
|
||||||
|
configured_canonical_root=self.target,
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
||||||
|
self.assertFalse(ctx["roots_aligned"])
|
||||||
|
|
||||||
|
def test_target_worktree_is_member_of_target_root(self):
|
||||||
|
got = nwb.amw.assess_workspace_repo_membership(
|
||||||
|
workspace_path=self.target_wt,
|
||||||
|
canonical_repo_root=self.target,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["proven"], got.get("reasons"))
|
||||||
|
|
||||||
|
def test_target_worktree_not_member_of_install_root(self):
|
||||||
|
got = nwb.amw.assess_workspace_repo_membership(
|
||||||
|
workspace_path=self.target_wt,
|
||||||
|
canonical_repo_root=self.install,
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
|
||||||
|
def test_branches_guard_passes_inside_target(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=self.target_wt,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install,
|
||||||
|
env={},
|
||||||
|
current_branch="feat/issue-1",
|
||||||
|
configured_canonical_root=self.target,
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
||||||
|
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
||||||
|
|
||||||
|
def test_target_control_checkout_blocks_branches_guard(self):
|
||||||
|
assessment = nwb.assess_namespace_mutation_workspace(
|
||||||
|
role_kind="author",
|
||||||
|
worktree_path=self.target, # stable target checkout, not branches/
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=self.install,
|
||||||
|
env={},
|
||||||
|
current_branch="master",
|
||||||
|
configured_canonical_root=self.target,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Immutable session pin (AC1/AC5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestSessionCanonicalRootPin(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
os.environ["PYTEST_CURRENT_TEST"] = "t"
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
def test_seed_stores_canonical_root(self):
|
||||||
|
ctx = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="mcp-control-plane-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="svc",
|
||||||
|
repository="mcp-control-plane",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
canonical_repository_root="/repo/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["canonical_repository_root"], "/repo/mcp-control-plane")
|
||||||
|
|
||||||
|
def test_canonical_root_drift_fails_closed(self):
|
||||||
|
session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="mcp-control-plane-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="svc",
|
||||||
|
repository="mcp-control-plane",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
canonical_repository_root="/repo/mcp-control-plane",
|
||||||
|
)
|
||||||
|
got = session_ctx.assess_session_context(
|
||||||
|
profile_name="mcp-control-plane-author",
|
||||||
|
remote="prgs",
|
||||||
|
canonical_repository_root="/repo/forged-elsewhere",
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
self.assertTrue(any("canonical" in r for r in got["reasons"]))
|
||||||
|
|
||||||
|
def test_matching_canonical_root_passes(self):
|
||||||
|
session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="mcp-control-plane-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="svc",
|
||||||
|
repository="mcp-control-plane",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
canonical_repository_root="/repo/mcp-control-plane",
|
||||||
|
)
|
||||||
|
got = session_ctx.assess_session_context(
|
||||||
|
profile_name="mcp-control-plane-author",
|
||||||
|
remote="prgs",
|
||||||
|
canonical_repository_root="/repo/mcp-control-plane",
|
||||||
|
)
|
||||||
|
self.assertFalse(got["block"], got["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simultaneous prgs / mdcps isolation (AC6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestSimultaneousIsolation(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self._tmp.name)
|
||||||
|
self.prgs = _init_repo(
|
||||||
|
self.tmp / "prgs-repo",
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||||
|
)
|
||||||
|
self.mdcps = _init_repo(
|
||||||
|
self.tmp / "mdcps-repo",
|
||||||
|
"https://gitea.dadeschools.net/dadeschools/eAgenda.git",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_each_namespace_binds_its_own_target(self):
|
||||||
|
a = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.prgs,
|
||||||
|
source="env",
|
||||||
|
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||||
|
process_project_root=self.tmp.as_posix(),
|
||||||
|
)
|
||||||
|
b = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.mdcps,
|
||||||
|
source="env",
|
||||||
|
expected_slug="dadeschools/eAgenda",
|
||||||
|
process_project_root=self.tmp.as_posix(),
|
||||||
|
)
|
||||||
|
self.assertEqual(a["canonical_repo_root"], self.prgs)
|
||||||
|
self.assertEqual(b["canonical_repo_root"], self.mdcps)
|
||||||
|
self.assertNotEqual(a["canonical_repo_root"], b["canonical_repo_root"])
|
||||||
|
|
||||||
|
def test_cross_wired_identity_blocks(self):
|
||||||
|
# prgs path claimed under the mdcps identity → forged binding.
|
||||||
|
got = crr.assess_canonical_repository_root(
|
||||||
|
configured_value=self.prgs,
|
||||||
|
source="env",
|
||||||
|
expected_slug="dadeschools/eAgenda",
|
||||||
|
process_project_root=self.tmp.as_posix(),
|
||||||
|
)
|
||||||
|
self.assertTrue(got["block"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config validation of the profile field (AC5: malformed bindings fail closed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestConfigValidation(unittest.TestCase):
|
||||||
|
def test_absent_is_allowed(self):
|
||||||
|
# single-repo default: no field configured
|
||||||
|
gitea_config._validate_canonical_repository_root("p", None)
|
||||||
|
|
||||||
|
def test_valid_absolute_path_ok(self):
|
||||||
|
gitea_config._validate_canonical_repository_root(
|
||||||
|
"p", "/Users/x/Development/mcp-control-plane"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_relative_path_rejected(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config._validate_canonical_repository_root(
|
||||||
|
"p", "relative/path"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_string_rejected(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config._validate_canonical_repository_root("p", " ")
|
||||||
|
|
||||||
|
def test_non_string_rejected(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config._validate_canonical_repository_root("p", ["/x"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Issue #706 F1 integration regression (review 457).
|
||||||
|
|
||||||
|
The unit tests in ``test_issue_706_canonical_repository_root.py`` exercise
|
||||||
|
``crr.assess_canonical_repository_root`` / ``session_ctx.seed_session_context_if_unbound``
|
||||||
|
with a *preselected* slug, so they never drive the real end-to-end path that
|
||||||
|
review 457 found broken:
|
||||||
|
|
||||||
|
_seed_session_context -> _trusted_session_repository -> _workspace_repository_slug
|
||||||
|
-> _local_git_remote_url (cwd=PROJECT_ROOT, always Gitea-Tools)
|
||||||
|
|
||||||
|
Before the fix, the session repository identity was pinned from the *install*
|
||||||
|
checkout remote even when a cross-repository ``canonical_repository_root`` was
|
||||||
|
configured to an external repository (e.g. mcp-control-plane). The mutation
|
||||||
|
preflight then derived ``expected_slug`` from that install-derived pin and
|
||||||
|
``_enforce_canonical_repository_root`` failed closed on a self-inflicted
|
||||||
|
identity mismatch, so the stated cross-repo namespaces stayed blocked.
|
||||||
|
|
||||||
|
These tests use two *real* temporary git repositories and drive the live
|
||||||
|
``mcp_server._seed_session_context`` and ``mcp_server._enforce_canonical_repository_root``
|
||||||
|
functions, asserting the session pins the *configured target* identity and that
|
||||||
|
enforcement accepts the target worktree — while every fail-closed property is
|
||||||
|
preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server # noqa: E402 # loads gitea_mcp_server.py into this namespace
|
||||||
|
import canonical_repository_root as crr # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
INSTALL_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
TARGET_SLUG = "Scaled-Tech-Consulting/mcp-control-plane"
|
||||||
|
TARGET_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"
|
||||||
|
OTHER_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/other-repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: str, *args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: Path, remote_url: str) -> str:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
_git(str(path), "init", "-q")
|
||||||
|
_git(str(path), "config", "user.email", "[email protected]")
|
||||||
|
_git(str(path), "config", "user.name", "Test")
|
||||||
|
_git(str(path), "remote", "add", "prgs", remote_url)
|
||||||
|
(path / "README.md").write_text("seed\n")
|
||||||
|
_git(str(path), "add", "README.md")
|
||||||
|
_git(str(path), "commit", "-q", "-m", "seed")
|
||||||
|
return os.path.realpath(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _add_worktree(repo_root: str, wt: Path, branch: str) -> str:
|
||||||
|
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(wt))
|
||||||
|
return os.path.realpath(str(wt))
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(role: str, *, canonical: str | None = None,
|
||||||
|
allowed=(TARGET_SLUG,)) -> dict:
|
||||||
|
p = {
|
||||||
|
"profile_name": f"mcp-control-plane-{role}",
|
||||||
|
"role": role,
|
||||||
|
"username": "svc",
|
||||||
|
"allowed_operations": ["gitea.read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": list(allowed),
|
||||||
|
}
|
||||||
|
if canonical is not None:
|
||||||
|
p["canonical_repository_root"] = canonical
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
tmp = Path(self._tmp.name)
|
||||||
|
self.install = _init_repo(tmp / "Gitea-Tools", INSTALL_URL)
|
||||||
|
self.target = _init_repo(tmp / "mcp-control-plane", TARGET_URL)
|
||||||
|
(Path(self.target) / "branches").mkdir()
|
||||||
|
self.target_wt = _add_worktree(
|
||||||
|
self.target, Path(self.target) / "branches" / "author-issue-1",
|
||||||
|
"feat/issue-1",
|
||||||
|
)
|
||||||
|
# PROJECT_ROOT and the install git remote are the Gitea-Tools install
|
||||||
|
# checkout — exactly the source that (mis)seeded the session before.
|
||||||
|
self._p_root = mock.patch.object(mcp_server, "PROJECT_ROOT", self.install)
|
||||||
|
self._p_url = mock.patch.object(
|
||||||
|
mcp_server, "_local_git_remote_url", return_value=INSTALL_URL
|
||||||
|
)
|
||||||
|
self._p_root.start()
|
||||||
|
self._p_url.start()
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mock.patch.stopall()
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
def _seed(self, profile, env=None):
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||||
|
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||||
|
return mcp_server._seed_session_context(
|
||||||
|
profile=profile, remote="prgs", host="gitea.prgs.cc",
|
||||||
|
identity="svc",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enforce(self, profile, env=None):
|
||||||
|
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||||
|
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||||
|
mcp_server._enforce_canonical_repository_root(
|
||||||
|
self.target_wt, remote="prgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestF1SeedsConfiguredTargetIdentity(_Base):
|
||||||
|
def test_env_config_seeds_target_not_install(self):
|
||||||
|
ctx = self._seed(
|
||||||
|
_profile("reviewer"),
|
||||||
|
env={crr.CANONICAL_ROOT_ENV: self.target},
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||||
|
# Regression guard: must NOT be the install repo.
|
||||||
|
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||||
|
|
||||||
|
def test_profile_field_seeds_target_not_install(self):
|
||||||
|
ctx = self._seed(_profile("merger", canonical=self.target))
|
||||||
|
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||||
|
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||||
|
|
||||||
|
def test_enforce_accepts_target_after_seed_reviewer(self):
|
||||||
|
prof = _profile("reviewer", canonical=self.target)
|
||||||
|
self._seed(prof)
|
||||||
|
# Would raise RuntimeError on the self-inflicted identity mismatch
|
||||||
|
# before the fix.
|
||||||
|
self._enforce(prof)
|
||||||
|
|
||||||
|
def test_enforce_accepts_target_after_seed_merger(self):
|
||||||
|
prof = _profile("merger", canonical=self.target)
|
||||||
|
self._seed(prof)
|
||||||
|
self._enforce(prof)
|
||||||
|
|
||||||
|
def test_env_overrides_profile_for_seed(self):
|
||||||
|
# profile points at install; env points at the real target → env wins.
|
||||||
|
prof = _profile("reviewer", canonical=self.install,
|
||||||
|
allowed=(TARGET_SLUG,))
|
||||||
|
ctx = self._seed(prof, env={crr.CANONICAL_ROOT_ENV: self.target})
|
||||||
|
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnconfiguredDefaultUnchanged(_Base):
|
||||||
|
def test_unconfigured_keeps_install_identity(self):
|
||||||
|
prof = _profile("author", allowed=("Scaled-Tech-Consulting/Gitea-Tools",))
|
||||||
|
ctx = self._seed(prof) # no env, no profile canonical field
|
||||||
|
self.assertEqual(ctx["repository"], "Gitea-Tools")
|
||||||
|
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailClosed(_Base):
|
||||||
|
def _trusted(self, profile, env=None, *, for_mutation=True):
|
||||||
|
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||||
|
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||||
|
return mcp_server._trusted_session_repository(
|
||||||
|
profile, "prgs", for_mutation=for_mutation
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonexistent_configured_root_fails_closed(self):
|
||||||
|
res = self._trusted(
|
||||||
|
_profile("reviewer"),
|
||||||
|
env={crr.CANONICAL_ROOT_ENV: self.target + "-missing"},
|
||||||
|
)
|
||||||
|
self.assertIsNone(res["repository"])
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
|
||||||
|
def test_non_git_configured_root_fails_closed(self):
|
||||||
|
plain = Path(self._tmp.name) / "plain"
|
||||||
|
plain.mkdir()
|
||||||
|
res = self._trusted(
|
||||||
|
_profile("reviewer"), env={crr.CANONICAL_ROOT_ENV: str(plain)}
|
||||||
|
)
|
||||||
|
self.assertIsNone(res["repository"])
|
||||||
|
self.assertTrue(any("git" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_unallowlisted_target_identity_fails_closed(self):
|
||||||
|
# Configured target is valid, but the profile does not authorize it.
|
||||||
|
res = self._trusted(
|
||||||
|
_profile("reviewer", canonical=self.target,
|
||||||
|
allowed=("Scaled-Tech-Consulting/Gitea-Tools",)),
|
||||||
|
)
|
||||||
|
self.assertIsNone(res["repository"])
|
||||||
|
self.assertTrue(any("scope" in r.lower() for r in res["reasons"]))
|
||||||
|
|
||||||
|
def test_forged_identity_conflict_fails_closed_at_enforce(self):
|
||||||
|
# Seed the target, then present a *different* configured root at
|
||||||
|
# enforcement time: the session pin no longer matches → fail closed.
|
||||||
|
other = _init_repo(Path(self._tmp.name) / "other", OTHER_URL)
|
||||||
|
prof_seed = _profile("reviewer", canonical=self.target)
|
||||||
|
self._seed(prof_seed)
|
||||||
|
prof_drift = _profile(
|
||||||
|
"reviewer", canonical=other,
|
||||||
|
allowed=(TARGET_SLUG, "Scaled-Tech-Consulting/other-repo"),
|
||||||
|
)
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self._enforce(prof_drift)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""#714: production first-bind pins the workspace-verified repository scope.
|
||||||
|
|
||||||
|
These tests drive the real entry points (``gitea_whoami``,
|
||||||
|
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
|
||||||
|
``gitea_activate_profile``) in production order. They deliberately do not
|
||||||
|
construct a ``_SessionContext`` directly: the defect they cover is that the
|
||||||
|
production first-bind path left ``repository``/``org`` unbound, so
|
||||||
|
``assess_session_context`` skipped its repository/org drift checks and a
|
||||||
|
same-host mutation against another repository was not blocked.
|
||||||
|
|
||||||
|
The trusted repository identity comes from the workspace-aligned git remote
|
||||||
|
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
|
||||||
|
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
|
||||||
|
caller-supplied ``org``/``repo`` argument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
WORKSPACE_ORG = "Scaled-Tech-Consulting"
|
||||||
|
WORKSPACE_REPO = "Gitea-Tools"
|
||||||
|
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
|
||||||
|
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
|
||||||
|
|
||||||
|
_BASE_AUTHOR_OPS = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _config(allowed_repositories=None):
|
||||||
|
profile = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||||
|
"allowed_operations": list(_BASE_AUTHOR_OPS),
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
}
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
profile["allowed_repositories"] = list(allowed_repositories)
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
# v2-contexts normalizes the config and keeps only rules.* — a
|
||||||
|
# top-level allow_runtime_switching would be dropped.
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {"prgs-author": profile},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ProductionOrderBase(unittest.TestCase):
|
||||||
|
"""Real entry points, pinned workspace remote, mocked network only."""
|
||||||
|
|
||||||
|
allowed_repositories = [WORKSPACE_SLUG]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
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.allowed_repositories)))
|
||||||
|
self._env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
# Pin the workspace git remote so the trusted source is deterministic
|
||||||
|
# and never depends on the developer's checkout layout.
|
||||||
|
self._remote_url = patch.object(
|
||||||
|
srv, "_local_git_remote_url", side_effect=self._local_remote_url
|
||||||
|
)
|
||||||
|
self._remote_url.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._remote_url.stop()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _local_remote_url(self, remote_name):
|
||||||
|
return WORKSPACE_URL if remote_name == "prgs" else None
|
||||||
|
|
||||||
|
def _api(self, method, url, header):
|
||||||
|
return {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "Test",
|
||||||
|
"id": 1,
|
||||||
|
"email": "[email protected]",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _live(self):
|
||||||
|
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
|
||||||
|
def test_whoami_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(ctx)
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
self.assertEqual(ctx["remote"], "prgs")
|
||||||
|
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_runtime_context_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_get_runtime_context(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_capability_preflight_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_activate_profile_binds_complete_context(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
|
||||||
|
def test_whoami_then_same_host_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_whoami_then_timesheet_blocks(self):
|
||||||
|
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org=WORKSPACE_ORG,
|
||||||
|
repo="Timesheet",
|
||||||
|
org_explicit=True,
|
||||||
|
repo_explicit=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_whoami_then_same_host_other_org_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_context_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_get_runtime_context(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_capability_preflight_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_activate_profile_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_cross_host_still_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(remote="dadeschools")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authorized_repository_mutation_remains_allowed(self):
|
||||||
|
"""Normal PR #715 author comment/push path must stay functional."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
self.assertIsNone(
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# A bare call with no override resolves to the same bound scope.
|
||||||
|
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
|
||||||
|
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
|
||||||
|
"""A mutation-first session must not be pinned by request values."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||||
|
)
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(ctx)
|
||||||
|
# Bound to the verified workspace, never to the request values.
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_mutation_first_with_attacker_values_is_blocked(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_later_call_cannot_overwrite_bound_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
before = session_ctx.get_session_context()
|
||||||
|
for _ in range(3):
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), before)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||||
|
def _local_remote_url(self, remote_name):
|
||||||
|
return None # no verifiable workspace repository
|
||||||
|
|
||||||
|
def test_mutation_blocks_when_workspace_repository_unverified(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNone(ctx["repository"])
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"no verified workspace repository" in r or "unverified" in r
|
||||||
|
for r in blocked["reasons"]
|
||||||
|
),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_activate_profile_rejects_unverifiable_workspace(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
self.assertFalse(res.get("success", True))
|
||||||
|
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||||
|
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
|
||||||
|
|
||||||
|
def test_workspace_absent_from_allowlist_blocks_mutation(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not authorized" in r for r in blocked["reasons"]),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workspace_absent_from_allowlist_blocks_activation(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
self.assertFalse(res.get("success", True))
|
||||||
|
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
|
||||||
|
"""Cross-project use is paused: only Gitea-Tools is authorized."""
|
||||||
|
|
||||||
|
def test_timesheet_workspace_is_rejected(self):
|
||||||
|
def timesheet_remote(remote_name):
|
||||||
|
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
with patch.object(
|
||||||
|
srv, "_local_git_remote_url", side_effect=timesheet_remote
|
||||||
|
):
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not authorized" in r for r in blocked["reasons"]),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
|
||||||
|
def test_concurrent_initialization_cannot_change_selected_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
thread_count = 8
|
||||||
|
barrier = threading.Barrier(thread_count)
|
||||||
|
results = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def racer(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
results.append(session_ctx.get_session_context())
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertFalse(any(t.is_alive() for t in threads))
|
||||||
|
winner = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(winner["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(winner["repository"], WORKSPACE_REPO)
|
||||||
|
self.assertEqual(results, [winner] * thread_count)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryScopeUnits(unittest.TestCase):
|
||||||
|
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["proven"])
|
||||||
|
self.assertFalse(scope["scope_enforced"])
|
||||||
|
|
||||||
|
def test_require_scope_blocks_empty_or_missing_allowlist(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG,
|
||||||
|
allowed=[],
|
||||||
|
profile_name="legacy",
|
||||||
|
require_scope=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["block"])
|
||||||
|
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
|
||||||
|
|
||||||
|
def test_declared_scope_authorizes_only_listed_repository(self):
|
||||||
|
allowed = session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": [WORKSPACE_SLUG]}
|
||||||
|
)
|
||||||
|
self.assertEqual(allowed, [WORKSPACE_SLUG])
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG, allowed=allowed
|
||||||
|
)["proven"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_workspace_slug_blocks_when_scope_declared(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=None, allowed=[WORKSPACE_SLUG]
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["block"])
|
||||||
|
|
||||||
|
def test_override_must_match_binding(self):
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org=WORKSPACE_ORG,
|
||||||
|
requested_repo="Other",
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org="Other-Org",
|
||||||
|
requested_repo=WORKSPACE_REPO,
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org=WORKSPACE_ORG,
|
||||||
|
requested_repo=WORKSPACE_REPO,
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["proven"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
|
||||||
|
),
|
||||||
|
[WORKSPACE_SLUG],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_mode_rejects_malformed_allowlist_entries(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
|
||||||
|
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
|
||||||
|
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
|
||||||
|
must not be treated as an explicit override against Gitea-Tools."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
# Simulate create_issue after resolve: org/repo are REMOTES defaults
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Timesheet",
|
||||||
|
)
|
||||||
|
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
|
||||||
|
|
||||||
|
def test_git_unavailable_blocks_mutation(self):
|
||||||
|
def no_remote(_name):
|
||||||
|
return None
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
|
||||||
|
for r in blocked["reasons"]
|
||||||
|
),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_mismatch_still_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org=WORKSPACE_ORG,
|
||||||
|
repo="Other-Tools",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_714 = {
|
||||||
|
"version": 2,
|
||||||
|
"allow_runtime_switching": True,
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
},
|
||||||
|
"mdcps-reviewer": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "mdcps",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "913443",
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"execution_profile": "mdcps-reviewer",
|
||||||
|
},
|
||||||
|
"mdcps-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "mdcps",
|
||||||
|
"role": "author",
|
||||||
|
"username": "913443",
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"execution_profile": "mdcps-author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue714SessionContextImmutability(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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_714))
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"dadeschools": {
|
||||||
|
"host": "gitea.dadeschools.net",
|
||||||
|
"org": "913443",
|
||||||
|
"repo": "eAgenda",
|
||||||
|
},
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
def _url(name):
|
||||||
|
if name == "dadeschools":
|
||||||
|
return "https://gitea.dadeschools.net/913443/eAgenda.git"
|
||||||
|
if name == "prgs":
|
||||||
|
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
return None
|
||||||
|
self._url_patch = patch.object(mcp_server, "_local_git_remote_url", side_effect=_url)
|
||||||
|
self._url_patch.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
|
self._env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "mdcps-reviewer",
|
||||||
|
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
|
||||||
|
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._url_patch.stop()
|
||||||
|
self._remotes.stop()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _api_side_effect(self, method, url, header):
|
||||||
|
# Token identity mapping for whoami endpoint
|
||||||
|
auth = str(header)
|
||||||
|
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
|
||||||
|
login = "913443"
|
||||||
|
else:
|
||||||
|
login = "jcwalker3"
|
||||||
|
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
|
||||||
|
|
||||||
|
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
|
||||||
|
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
act = mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertTrue(act["success"])
|
||||||
|
self.assertEqual(act["after_profile"], "mdcps-reviewer")
|
||||||
|
|
||||||
|
who1 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
self.assertEqual(who1["username"], "913443")
|
||||||
|
|
||||||
|
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
self.assertIn("mdcps-author", res["matching_configured_profile"])
|
||||||
|
|
||||||
|
who2 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||||
|
# Still pinned — never prgs-author
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repeated_calls_remain_on_mdcps_reviewer(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
for _ in range(5):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="review_pr", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||||
|
who = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||||
|
|
||||||
|
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
# Gate must not silently switch either
|
||||||
|
blocked = mcp_server._profile_permission_block(
|
||||||
|
"gitea.issue.comment", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertFalse(blocked.get("success", True))
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
|
||||||
|
"""Reverse of the incident: a pinned MDCPS profile must never serve a
|
||||||
|
prgs request, nor be silently swapped for the prgs-side profile."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-author", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
|
||||||
|
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
|
||||||
|
|
||||||
|
def test_unsupported_capability_fails_closed_structured(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
self.assertIn("reason", res)
|
||||||
|
self.assertIn("exact_safe_next_action", res)
|
||||||
|
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
||||||
|
# Unknown task still fail closed (structured denial after #723;
|
||||||
|
# never raises into internal_error and never substitutes profile).
|
||||||
|
unknown = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="reopen_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertFalse(unknown.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(unknown.get("stop_required"))
|
||||||
|
self.assertEqual(unknown.get("reason_code"), "unknown_task")
|
||||||
|
self.assertEqual(unknown.get("mutation_performed"), False)
|
||||||
|
self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer")
|
||||||
|
self.assertNotEqual(unknown.get("active_profile"), "prgs-author")
|
||||||
|
|
||||||
|
def test_identity_mismatch_blocks_mutation(self):
|
||||||
|
"""Profile expects 913443 but authenticated as jcwalker3."""
|
||||||
|
|
||||||
|
def wrong_identity(method, url, header):
|
||||||
|
return {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "Wrong",
|
||||||
|
"id": 9,
|
||||||
|
"email": "[email protected]",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=wrong_identity):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
blocked = mcp_server._session_context_mutation_block(
|
||||||
|
remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("identity mismatch" in r for r in blocked["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_host_mismatch_blocks_mutation(self):
|
||||||
|
"""mdcps-reviewer cannot serve prgs remote."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("cross-host" in r for r in blocked["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_drift_cannot_reach_write(self):
|
||||||
|
"""Simulated mid-session profile override cannot pass permission gate."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
# Bind session as mdcps-reviewer
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.get_session_context()["profile_name"],
|
||||||
|
"mdcps-reviewer",
|
||||||
|
)
|
||||||
|
# Hostile override without activate_profile
|
||||||
|
gitea_config._active_profile_override = "prgs-author"
|
||||||
|
blocked = mcp_server._session_context_mutation_block(
|
||||||
|
remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
|
||||||
|
|
||||||
|
def test_static_prgs_author_still_works(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertEqual(res["active_identity"], "jcwalker3")
|
||||||
|
|
||||||
|
def test_static_mdcps_author_still_works(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "mdcps-author",
|
||||||
|
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionContextBindingUnit(unittest.TestCase):
|
||||||
|
def test_profile_matches_remote_by_base_url(self):
|
||||||
|
remotes = {
|
||||||
|
"dadeschools": {"host": "gitea.dadeschools.net"},
|
||||||
|
"prgs": {"host": "gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
|
||||||
|
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
|
||||||
|
)
|
||||||
|
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
|
||||||
|
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
|
||||||
|
|
||||||
|
def test_bind_and_detect_drift(self):
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
ok = session_ctx.assess_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
)
|
||||||
|
self.assertTrue(ok["proven"])
|
||||||
|
bad = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="jcwalker3",
|
||||||
|
)
|
||||||
|
self.assertTrue(bad["block"])
|
||||||
|
self.assertTrue(any("drift" in r for r in bad["reasons"]))
|
||||||
|
|
||||||
|
def test_bound_context_survives_multiple_calls_and_exceptions(self):
|
||||||
|
original = session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
observed = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
source="interleaved-test-call",
|
||||||
|
)
|
||||||
|
self.assertEqual(observed, original)
|
||||||
|
raise RuntimeError("simulated caller failure")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), original)
|
||||||
|
drift = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(drift["block"])
|
||||||
|
|
||||||
|
def test_parallel_calls_cannot_overwrite_established_binding(self):
|
||||||
|
original = session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
barrier = threading.Barrier(9)
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def competing_seed(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
result = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name=f"prgs-author-{index}",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity=f"other-{index}",
|
||||||
|
source="parallel-test-call",
|
||||||
|
)
|
||||||
|
with results_lock:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
barrier.wait()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||||
|
self.assertEqual(results, [original] * 8)
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), original)
|
||||||
|
|
||||||
|
def test_concurrent_initialization_has_single_winning_binding(self):
|
||||||
|
"""Racing first-binds from an unbound context: exactly one winner, and
|
||||||
|
every racer observes that same complete binding (never a partial one)."""
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
thread_count = 8
|
||||||
|
barrier = threading.Barrier(thread_count)
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def racing_seed(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
result = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name=f"prgs-author-{index}",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity=f"user-{index}",
|
||||||
|
source="concurrent-init-test",
|
||||||
|
)
|
||||||
|
with results_lock:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=racing_seed, args=(i,))
|
||||||
|
for i in range(thread_count)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||||
|
winner = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(winner)
|
||||||
|
self.assertEqual(len(results), thread_count)
|
||||||
|
self.assertEqual(results, [winner] * thread_count)
|
||||||
|
# A losing racer's identity must never be spliced into the winner.
|
||||||
|
self.assertEqual(
|
||||||
|
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_mismatch_blocks_before_mutation(self):
|
||||||
|
"""Same host and profile, different repository, must still fail closed."""
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
same = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(same["proven"])
|
||||||
|
other_repo = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="eAgenda",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_repo["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("repository drift" in r for r in other_repo["reasons"])
|
||||||
|
)
|
||||||
|
other_org = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="913443",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_org["block"])
|
||||||
|
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
|
||||||
|
|
||||||
|
def test_returned_snapshot_cannot_mutate_binding(self):
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
snapshot = session_ctx.get_session_context()
|
||||||
|
snapshot["profile_name"] = "prgs-author"
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
||||||
|
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test-boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
source="test-boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase):
|
||||||
|
"""Conflict-resolution regressions after merging advanced master into #715.
|
||||||
|
|
||||||
|
Preserves:
|
||||||
|
- #714 immutable session / no auto profile substitution
|
||||||
|
- #685 side-effect-free resolve (auto_recover=False)
|
||||||
|
- #709 actor identity helper from master
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_auto_switch_helpers_remain_fail_closed(self):
|
||||||
|
self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review"))
|
||||||
|
self.assertFalse(
|
||||||
|
mcp_server._try_auto_switch_for_operation(
|
||||||
|
"gitea.issue.comment", host="gitea.prgs.cc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Active profile is prgs-author; cannot match mdcps review permission
|
||||||
|
# and must not switch.
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.issue.create"],
|
||||||
|
"forbidden_operations": ["gitea.pr.approve"],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
matched = mcp_server._ensure_matching_profile(
|
||||||
|
"gitea.pr.approve", "reviewer", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsNone(matched)
|
||||||
|
self.assertNotEqual(
|
||||||
|
gitea_config.selected_profile_name() or "prgs-author",
|
||||||
|
"mdcps-reviewer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_actor_helper_survives_merge(self):
|
||||||
|
"""Master #709 F7 actor identity coexists with #714 immutability."""
|
||||||
|
self.assertTrue(hasattr(mcp_server, "_authenticated_actor"))
|
||||||
|
with patch(
|
||||||
|
"mcp_server.get_auth_header", return_value={"Authorization": "token x"}
|
||||||
|
), patch(
|
||||||
|
"mcp_server.api_request",
|
||||||
|
return_value={"id": 42, "login": "sysadmin"},
|
||||||
|
):
|
||||||
|
mcp_server._ACTOR_IDENTITY_CACHE.clear()
|
||||||
|
actor = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||||
|
self.assertEqual(actor.get("user_id"), 42)
|
||||||
|
self.assertEqual(actor.get("login"), "sysadmin")
|
||||||
|
# Cached; second call does not re-request
|
||||||
|
with patch("mcp_server.api_request") as mock_api:
|
||||||
|
actor2 = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
self.assertEqual(actor2.get("user_id"), 42)
|
||||||
|
|
||||||
|
def test_resolve_still_report_only_after_master_merge(self):
|
||||||
|
"""#685: resolve path must not pass auto_recover=True after conflict merge."""
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake_binding = {
|
||||||
|
"classification": "unbound",
|
||||||
|
"active_worktree": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=profile
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="jcwalker3"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
return_value=fake_binding,
|
||||||
|
) as mock_assess, patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
mock_assess.assert_called()
|
||||||
|
for call in mock_assess.call_args_list:
|
||||||
|
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||||
|
self.assertEqual(result.get("mutation_performed"), False)
|
||||||
|
# Session context must not have been rewritten by resolve
|
||||||
|
# (report-only; any prior binding stays; unbound stays unbound unless
|
||||||
|
# seed-on-read path is intentional — resolve must not activate peers).
|
||||||
|
self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Regression matrix for Issue #733.
|
||||||
|
|
||||||
|
``gitea_delete_branch`` accepts explicit ``org``/``repo`` but historically did
|
||||||
|
not propagate them through the anti-stomp preflight: preflight resolved the
|
||||||
|
remote-wide ``REMOTES`` default (bare ``prgs`` → ``Scaled-Tech-Consulting/
|
||||||
|
Timesheet``) and fail-closed with ``wrong_repo`` even though the caller
|
||||||
|
explicitly targeted ``Scaled-Tech-Consulting/Gitea-Tools``.
|
||||||
|
|
||||||
|
The fix has two parts, both exercised here:
|
||||||
|
|
||||||
|
1. ``gitea_delete_branch`` forwards the explicit ``org``/``repo`` into
|
||||||
|
``verify_preflight_purity`` so the shared #604 anti-stomp resolution
|
||||||
|
validates the *targeted* repository instead of the remote-wide default.
|
||||||
|
2. A workspace-derived repository-binding gate
|
||||||
|
(``_delete_branch_repository_binding_block``) validates explicit
|
||||||
|
coordinates against the immutable workspace identity, rejecting wrong,
|
||||||
|
substituted, or unverified targets — independent of the anti-stomp
|
||||||
|
remote/repo guard, which by the #530 contract trusts explicit intent.
|
||||||
|
|
||||||
|
Every existing gate (reconciler-only ownership; author/reviewer/merger denial;
|
||||||
|
protected/preservation blocks) is preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
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_delete_branch
|
||||||
|
import remote_repo_guard
|
||||||
|
import task_capability_map
|
||||||
|
import role_session_router
|
||||||
|
|
||||||
|
FAKE_AUTH = "token fake"
|
||||||
|
|
||||||
|
# The workspace is bound to Gitea-Tools even though the prgs REMOTES default
|
||||||
|
# repo is Timesheet (the exact #733 scenario).
|
||||||
|
WORKSPACE_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
|
||||||
|
RECONCILER = {
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||||
|
"audit_label": "prgs-reconciler",
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTHOR_WITH_DELETE = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.create", "gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
}
|
||||||
|
|
||||||
|
REVIEWER_WITH_DELETE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
MERGER_WITH_DELETE = {
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "prgs-merger",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _DeleteBranchBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._snap = (
|
||||||
|
mcp_server._preflight_whoami_called,
|
||||||
|
mcp_server._preflight_capability_called,
|
||||||
|
)
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_capability_called = False
|
||||||
|
# prgs default repo is Timesheet — the remote-wide default #733 must NOT
|
||||||
|
# fall back to.
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Timesheet",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
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()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
self.mock_api.return_value = {}
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
# Workspace is verifiably bound to Gitea-Tools.
|
||||||
|
patch(
|
||||||
|
"mcp_server._workspace_repository_slug", return_value=WORKSPACE_SLUG
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
(
|
||||||
|
mcp_server._preflight_whoami_called,
|
||||||
|
mcp_server._preflight_capability_called,
|
||||||
|
) = self._snap
|
||||||
|
|
||||||
|
def _bind(self, profile, role):
|
||||||
|
patch("mcp_server.get_profile", return_value=profile).start()
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role=role)
|
||||||
|
|
||||||
|
def _delete_calls(self):
|
||||||
|
return [
|
||||||
|
c for c in self.mock_api.call_args_list
|
||||||
|
if c.args and c.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositiveTimesheetDefaultExplicitGiteaTools(_DeleteBranchBase):
|
||||||
|
"""AC: prgs default=Timesheet + explicit Gitea-Tools → preflight validates
|
||||||
|
Gitea-Tools and permits an otherwise-eligible deletion."""
|
||||||
|
|
||||||
|
def test_explicit_gitea_tools_permits_delete_and_forwards_coords(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"], res)
|
||||||
|
self.assertIn("deleted", res["message"])
|
||||||
|
self.assertTrue(self._delete_calls())
|
||||||
|
# Forwarding: preflight received the explicit Gitea-Tools coordinates
|
||||||
|
# and the delete_branch task (not the remote-wide Timesheet default).
|
||||||
|
self.assertTrue(vpp.called)
|
||||||
|
kwargs = vpp.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs.get("task"), "delete_branch")
|
||||||
|
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegativeRepositoryBinding(_DeleteBranchBase):
|
||||||
|
"""AC negatives: wrong / substituted / unverified coordinates fail closed."""
|
||||||
|
|
||||||
|
def test_wrong_explicit_repo_fails_closed(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Timesheet", # not the workspace-bound Gitea-Tools
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||||
|
self.assertTrue(any("Timesheet" in r for r in res["reasons"]))
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
# Fail closed BEFORE any preflight/deletion side effect.
|
||||||
|
vpp.assert_not_called()
|
||||||
|
|
||||||
|
def test_repository_substitution_org_mismatch_rejected(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x",
|
||||||
|
remote="prgs",
|
||||||
|
org="Some-Other-Org",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_explicit_coords_without_workspace_identity_fail_closed(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
with patch("mcp_server._workspace_repository_slug", return_value=None):
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["blocker_kind"], "repository_binding")
|
||||||
|
self.assertTrue(any("unverified" in r for r in res["reasons"]))
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_matching_explicit_passes_binding(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
block = mcp_server._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNone(block)
|
||||||
|
|
||||||
|
def test_omitted_coords_pass_binding_and_are_revalidated_by_preflight(self):
|
||||||
|
# Omitted coordinates pass the binding gate (nothing to corroborate) but
|
||||||
|
# are forwarded as None so the anti-stomp resolution fails closed on the
|
||||||
|
# remote-wide Timesheet default (see TestAntiStompResolution below).
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
self.assertIsNone(
|
||||||
|
mcp_server._delete_branch_repository_binding_block(
|
||||||
|
"prgs", org=None, repo=None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with patch("mcp_server.verify_preflight_purity") as vpp:
|
||||||
|
gitea_delete_branch(branch="feat/x", remote="prgs")
|
||||||
|
self.assertTrue(vpp.called)
|
||||||
|
kwargs = vpp.call_args.kwargs
|
||||||
|
self.assertIsNone(kwargs.get("org"))
|
||||||
|
self.assertIsNone(kwargs.get("repo"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleDenials(_DeleteBranchBase):
|
||||||
|
"""AC: author, reviewer, and merger remain denied even holding the perm."""
|
||||||
|
|
||||||
|
def test_author_with_delete_perm_denied(self):
|
||||||
|
self._bind(AUTHOR_WITH_DELETE, "author")
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x", remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||||
|
self.assertEqual(res["active_role_kind"], "author")
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_reviewer_with_delete_perm_denied(self):
|
||||||
|
self._bind(REVIEWER_WITH_DELETE, "reviewer")
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x", remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_merger_with_delete_perm_denied(self):
|
||||||
|
self._bind(MERGER_WITH_DELETE, "merger")
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch="feat/x", remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(res["required_role_kind"], "reconciler")
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtectedAndPreservedBranches(_DeleteBranchBase):
|
||||||
|
"""AC: protected and preservation/evidence branches remain blocked."""
|
||||||
|
|
||||||
|
def test_protected_branch_blocked(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
for branch in ("master", "main", "dev"):
|
||||||
|
with self.subTest(branch=branch):
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch=branch, remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("protected" in r for r in res["reasons"]))
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
def test_preservation_branch_blocked(self):
|
||||||
|
self._bind(RECONCILER, "reconciler")
|
||||||
|
for branch in ("chore/preserve-local-master", "feat/evidence-run"):
|
||||||
|
with self.subTest(branch=branch):
|
||||||
|
res = gitea_delete_branch(
|
||||||
|
branch=branch, remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting", repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("preservation" in r for r in res["reasons"])
|
||||||
|
)
|
||||||
|
self.assertFalse(self._delete_calls())
|
||||||
|
|
||||||
|
|
||||||
|
class TestAntiStompResolution(unittest.TestCase):
|
||||||
|
"""The anti-stomp repository resolution validates the *targeted* repo and
|
||||||
|
fails closed on the remote-wide default when coordinates are omitted."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Timesheet",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def _capture_resolution(self, org, repo):
|
||||||
|
"""Drive the live anti-stomp runner and capture the org/repo it resolved
|
||||||
|
and passed to the pure assessor."""
|
||||||
|
passthrough = {
|
||||||
|
"allowed": True, "block": False, "blockers": [], "reasons": [],
|
||||||
|
"exact_next_action": "proceed", "blocker_kind": None, "checks": {},
|
||||||
|
}
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, clear=False
|
||||||
|
), patch(
|
||||||
|
"mcp_server._local_git_remote_url", return_value=LOCAL_GITEA_TOOLS_URL
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_profile", return_value=RECONCILER
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.anti_stomp_preflight,
|
||||||
|
"assess_anti_stomp_preflight",
|
||||||
|
return_value=passthrough,
|
||||||
|
) as m:
|
||||||
|
mcp_server._run_anti_stomp_preflight(
|
||||||
|
"delete_branch", remote="prgs", org=org, repo=repo
|
||||||
|
)
|
||||||
|
self.assertTrue(m.called)
|
||||||
|
return m.call_args.kwargs
|
||||||
|
|
||||||
|
def test_explicit_gitea_tools_resolved_and_marked_explicit(self):
|
||||||
|
kw = self._capture_resolution("Scaled-Tech-Consulting", "Gitea-Tools")
|
||||||
|
self.assertEqual(kw["resolved_org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
|
||||||
|
self.assertTrue(kw["org_explicit"])
|
||||||
|
self.assertTrue(kw["repo_explicit"])
|
||||||
|
|
||||||
|
def test_missing_coords_resolve_remote_default_and_fail_closed(self):
|
||||||
|
# Omitted coords resolve the remote-wide Timesheet default with
|
||||||
|
# org/repo NOT explicit; the remote/repo guard then blocks against the
|
||||||
|
# local Gitea-Tools remote — i.e. missing coordinates fail closed.
|
||||||
|
kw = self._capture_resolution(None, None)
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Timesheet")
|
||||||
|
self.assertFalse(kw["org_explicit"])
|
||||||
|
self.assertFalse(kw["repo_explicit"])
|
||||||
|
# Compose the guard exactly as the assessor would: Timesheet default,
|
||||||
|
# not explicit, local remote is Gitea-Tools → blocked (wrong_repo).
|
||||||
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||||
|
remote="prgs",
|
||||||
|
resolved_org=kw["resolved_org"],
|
||||||
|
resolved_repo=kw["resolved_repo"],
|
||||||
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||||
|
org_explicit=kw["org_explicit"],
|
||||||
|
repo_explicit=kw["repo_explicit"],
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityRoleMapConsistency(unittest.TestCase):
|
||||||
|
"""AC: task-capability and role-routing maps remain consistent (#729)."""
|
||||||
|
|
||||||
|
def test_delete_branch_is_reconciler_in_both_maps(self):
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("delete_branch"), "reconciler"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("delete_branch"),
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
role_session_router.required_role_for_task("delete_branch"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("delete_branch"),
|
||||||
|
role_session_router.TASK_REQUIRED_ROLE["delete_branch"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for the pre-create issue content gate (Issue #582)."""
|
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -15,9 +19,10 @@ from issue_content_gate import ( # noqa: E402
|
|||||||
from mcp_server import gitea_create_issue # noqa: E402
|
from mcp_server import gitea_create_issue # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
"test-author-prgs",
|
||||||
}
|
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeAndPointers(unittest.TestCase):
|
class TestNormalizeAndPointers(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -19,9 +23,10 @@ from mcp_server import gitea_create_issue # noqa: E402
|
|||||||
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
"test-author-prgs",
|
||||||
}
|
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||||
|
)
|
||||||
CANONICAL_TITLE = (
|
CANONICAL_TITLE = (
|
||||||
"Add hard queue-target resolution wall before PR inventory "
|
"Add hard queue-target resolution wall before PR inventory "
|
||||||
"or empty-queue claims"
|
"or empty-queue claims"
|
||||||
@@ -162,7 +167,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
|
|||||||
mock_role_check.return_value = (True, [])
|
mock_role_check.return_value = (True, [])
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
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):
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
result = gitea_create_issue(title="Unique new issue", body="body text")
|
result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
|
||||||
self.assertEqual(result["number"], 1)
|
self.assertEqual(result["number"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for early duplicate-work detection (#400)."""
|
"""Tests for early duplicate-work detection (#400)."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -144,10 +148,12 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
with patch.dict(os.environ, {
|
env = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
"test-author-prgs",
|
||||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
include_example_repo=True,
|
||||||
}, clear=True):
|
GITEA_ISSUE_LOCK_DIR=lock_dir,
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_lock_issue(
|
mcp_server.gitea_lock_issue(
|
||||||
issue_number=400,
|
issue_number=400,
|
||||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||||
@@ -161,7 +167,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
self._env_patch = patch.dict(
|
self._env_patch = patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self._dir.name,
|
||||||
|
),
|
||||||
clear=False,
|
clear=False,
|
||||||
)
|
)
|
||||||
self._env_patch.start()
|
self._env_patch.start()
|
||||||
@@ -171,6 +181,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
self._remotes.start()
|
self._remotes.start()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
try:
|
||||||
|
import session_context_binding as _sc
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
patch.stopall()
|
patch.stopall()
|
||||||
@@ -205,6 +220,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "test-author",
|
"audit_label": "test-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
})
|
})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
||||||
@@ -239,6 +256,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "test-author",
|
"audit_label": "test-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
})
|
})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
|
||||||
|
install_deterministic_remote_urls()
|
||||||
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
|
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -41,6 +46,7 @@ CONFIG = {
|
|||||||
"gitea.read", "gitea.issue.create", "gitea.issue.close",
|
"gitea.read", "gitea.issue.create", "gitea.issue.close",
|
||||||
"gitea.issue.comment"],
|
"gitea.issue.comment"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo", "913443/eAgenda"],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
||||||
|
|
||||||
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -13,22 +17,20 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
|
|||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-prgs",
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
CREATE_PR_ENV = {
|
CREATE_PR_ENV = shared_mutation_env(
|
||||||
"GITEA_PROFILE_NAME": "author-test",
|
"test-author-prgs",
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
),
|
),
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
GITEA_FORBIDDEN_OPERATIONS=(
|
||||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clean_master_git_state_for_lock():
|
def _clean_master_git_state_for_lock():
|
||||||
|
|||||||
+153
-106
@@ -1,3 +1,11 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import (
|
||||||
|
mutation_profile_env,
|
||||||
|
shared_mutation_env,
|
||||||
|
install_deterministic_remote_urls,
|
||||||
|
) # noqa: E402
|
||||||
"""Tests for the MCP server tool functions.
|
"""Tests for the MCP server tool functions.
|
||||||
|
|
||||||
Each tool is tested by calling the underlying function directly (not through
|
Each tool is tested by calling the underlying function directly (not through
|
||||||
@@ -47,6 +55,7 @@ from gitea_auth import get_profile # noqa: E402
|
|||||||
import gitea_config # noqa: E402
|
import gitea_config # noqa: E402
|
||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
|
install_deterministic_remote_urls()
|
||||||
import issue_lock_store
|
import issue_lock_store
|
||||||
import gitea_auth
|
import gitea_auth
|
||||||
|
|
||||||
@@ -221,22 +230,26 @@ def _seed_ready_review_decision(
|
|||||||
|
|
||||||
|
|
||||||
# Issue-write tools are profile-gated (#69).
|
# Issue-write tools are profile-gated (#69).
|
||||||
ISSUE_WRITE_ENV = {
|
# Default remote is dadeschools — use the host-aligned config profile so
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
# cross-host session binding does not fail closed (#714).
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
|
"test-author-dadeschools",
|
||||||
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment,"
|
||||||
|
"gitea.pr.create,gitea.branch.push,gitea.repo.commit,gitea.read"
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
# create_pr is gated on author profile + namespace (#69, #209).
|
# create_pr is gated on author profile + namespace (#69, #209).
|
||||||
CREATE_PR_ENV = {
|
# Default remote is dadeschools; use matching config-backed author profile.
|
||||||
"GITEA_PROFILE_NAME": "author-test",
|
CREATE_PR_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-dadeschools",
|
||||||
"gitea.read,gitea.pr.create,gitea.branch.push"
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
|
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
),
|
),
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
GITEA_FORBIDDEN_OPERATIONS="gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
|
||||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||||
|
|
||||||
@@ -287,6 +300,7 @@ def _bind_test_lock(**overrides) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Create Issue
|
# Create Issue
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateIssue(unittest.TestCase):
|
class TestCreateIssue(unittest.TestCase):
|
||||||
|
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
@@ -296,7 +310,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
|
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_create_issue(title="Test issue", body="body text")
|
result = gitea_create_issue(title="Test issue", body="body text")
|
||||||
self.assertEqual(result["number"], 1)
|
self.assertEqual(result["number"], 1)
|
||||||
self.assertNotIn("url", result)
|
self.assertNotIn("url", result)
|
||||||
@@ -314,7 +328,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
|
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue(title="Test issue", body="body text")
|
result = gitea_create_issue(title="Test issue", body="body text")
|
||||||
self.assertIn("issues/1", result["url"])
|
self.assertIn("issues/1", result["url"])
|
||||||
|
|
||||||
@@ -325,7 +339,18 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
|
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
env = {
|
||||||
|
**ISSUE_WRITE_ENV,
|
||||||
|
"GITEA_MCP_PROFILE": "test-author-prgs",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
import gitea_config
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
try:
|
||||||
|
import session_context_binding as _sc
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
result = gitea_create_issue(title="Test", remote="prgs")
|
result = gitea_create_issue(title="Test", remote="prgs")
|
||||||
self.assertEqual(result["number"], 5)
|
self.assertEqual(result["number"], 5)
|
||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][1]
|
||||||
@@ -338,7 +363,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_create_issue(
|
result = gitea_create_issue(
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
require_workflow_labels=True,
|
require_workflow_labels=True,
|
||||||
@@ -378,7 +403,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -421,7 +446,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -447,7 +472,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -470,7 +495,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(
|
_bind_test_lock(
|
||||||
issue_number=123,
|
issue_number=123,
|
||||||
branch_name="feat/x",
|
branch_name="feat/x",
|
||||||
@@ -495,7 +520,7 @@ class TestCloseIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_closes_issue(self, _auth, mock_api):
|
def test_closes_issue(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"state": "closed"}
|
mock_api.return_value = {"state": "closed"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_close_issue(issue_number=42)
|
result = gitea_close_issue(issue_number=42)
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("42", result["message"])
|
self.assertIn("42", result["message"])
|
||||||
@@ -601,7 +626,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_mark_issue(issue_number=5, action="start")
|
result = gitea_mark_issue(issue_number=5, action="start")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("claimed", result["message"])
|
self.assertIn("claimed", result["message"])
|
||||||
@@ -616,7 +641,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_mark_issue(issue_number=5, action="done")
|
result = gitea_mark_issue(issue_number=5, action="done")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("released", result["message"])
|
self.assertIn("released", result["message"])
|
||||||
@@ -629,7 +654,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_mark_issue(issue_number=5, action="start")
|
gitea_mark_issue(issue_number=5, action="start")
|
||||||
|
|
||||||
@@ -641,12 +666,12 @@ class TestAuthErrors(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=None)
|
@patch("mcp_server.get_auth_header", return_value=None)
|
||||||
def test_no_credentials_raises(self, _auth):
|
def test_no_credentials_raises(self, _auth):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_create_issue(title="test")
|
gitea_create_issue(title="test")
|
||||||
|
|
||||||
def test_unknown_remote_raises(self):
|
def test_unknown_remote_raises(self):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
gitea_create_issue(title="test", remote="nonexistent")
|
gitea_create_issue(title="test", remote="nonexistent")
|
||||||
|
|
||||||
@@ -860,7 +885,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", do="squash",
|
expected_head_sha="abc123", do="squash",
|
||||||
@@ -893,7 +918,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["b.py", "a.py"],
|
expected_changed_files=["b.py", "a.py"],
|
||||||
@@ -914,7 +939,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -945,7 +970,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -962,7 +987,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
|
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
||||||
@@ -973,7 +998,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_wrong_confirmation_blocks(self, _auth, mock_api):
|
def test_wrong_confirmation_blocks(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
@@ -985,7 +1010,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
gitea_merge_pr(
|
gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
||||||
@@ -999,7 +1024,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1010,7 +1035,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_unknown_identity_blocks(self, _auth):
|
def test_unknown_identity_blocks(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1022,7 +1047,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_unknown_profile_blocks(self, _auth, mock_api):
|
def test_unknown_profile_blocks(self, _auth, mock_api):
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||||
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
|
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1094,7 +1119,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
gitea_merge_pr(
|
gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
@@ -1110,7 +1135,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
|
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1124,7 +1149,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
|
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1138,7 +1163,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
|
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1156,7 +1181,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="deadbeef", remote="prgs")
|
expected_head_sha="deadbeef", remote="prgs")
|
||||||
@@ -1177,7 +1202,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["a.py", "b.py"],
|
expected_changed_files=["a.py", "b.py"],
|
||||||
@@ -1210,7 +1235,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge",
|
"GITEA_ALLOWED_OPERATIONS": "read,merge",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1229,7 +1254,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1252,7 +1277,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
||||||
expected_head_sha=new_sha)
|
expected_head_sha=new_sha)
|
||||||
@@ -1275,7 +1300,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1307,7 +1332,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1446,8 +1471,9 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
class TestDeleteBranch(unittest.TestCase):
|
class TestDeleteBranch(unittest.TestCase):
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
DELETE_PROFILE = {
|
||||||
"profile_name": "test-author-deleter",
|
# #729: delete_branch is reconciler-owned.
|
||||||
"role": "author",
|
"profile_name": "test-reconciler-deleter",
|
||||||
|
"role": "reconciler",
|
||||||
"allowed_operations": [
|
"allowed_operations": [
|
||||||
"gitea.read",
|
"gitea.read",
|
||||||
"gitea.pr.create",
|
"gitea.pr.create",
|
||||||
@@ -1463,7 +1489,7 @@ class TestDeleteBranch(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_delete_branch(self, _auth, mock_api, _profile):
|
def test_delete_branch(self, _auth, mock_api, _profile):
|
||||||
mcp_server.record_preflight_check("whoami")
|
mcp_server.record_preflight_check("whoami")
|
||||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
mcp_server.record_preflight_check("capability", resolved_role="reconciler")
|
||||||
mock_api.return_value = {}
|
mock_api.return_value = {}
|
||||||
result = gitea_delete_branch(branch="feat/branch")
|
result = gitea_delete_branch(branch="feat/branch")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
@@ -1620,10 +1646,8 @@ class TestCommitFiles(unittest.TestCase):
|
|||||||
files = [
|
files = [
|
||||||
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
||||||
]
|
]
|
||||||
env = {
|
env = shared_mutation_env("test-author-dadeschools")
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
|
with patch.dict(os.environ, env, clear=False):
|
||||||
}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
result = gitea_commit_files(
|
result = gitea_commit_files(
|
||||||
files=files,
|
files=files,
|
||||||
message="Initial commit",
|
message="Initial commit",
|
||||||
@@ -1738,7 +1762,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
|
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
|
||||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
|
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
|
||||||
@@ -1749,7 +1773,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
blob = repr(p).lower()
|
blob = repr(p).lower()
|
||||||
# The token VALUE must never appear. (The field name
|
# The token VALUE must never appear. (The field name
|
||||||
@@ -1766,7 +1790,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_whoami(remote="prgs")
|
result = gitea_whoami(remote="prgs")
|
||||||
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
|
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -1787,7 +1811,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||||
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
|
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_whoami(remote="prgs")
|
result = gitea_whoami(remote="prgs")
|
||||||
profile = result["profile"]
|
profile = result["profile"]
|
||||||
self.assertEqual(profile["environment"], None)
|
self.assertEqual(profile["environment"], None)
|
||||||
@@ -1856,7 +1880,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
|
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
|
||||||
self.assertEqual(p["audit_label"], "reviewer-runtime")
|
self.assertEqual(p["audit_label"], "reviewer-runtime")
|
||||||
@@ -1872,7 +1896,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs")
|
result = gitea_get_profile(remote="prgs")
|
||||||
self.assertEqual(result["profile_name"], "gitea-reviewer")
|
self.assertEqual(result["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
||||||
@@ -1893,7 +1917,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
|
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
|
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs")
|
result = gitea_get_profile(remote="prgs")
|
||||||
blob = repr(result).lower()
|
blob = repr(result).lower()
|
||||||
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
|
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||||
@@ -1975,7 +1999,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||||
self.assertTrue(r["eligible"])
|
self.assertTrue(r["eligible"])
|
||||||
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
||||||
@@ -1988,7 +2012,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
@@ -1999,7 +2023,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
@@ -2014,7 +2038,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIsNone(r["mergeable"])
|
self.assertIsNone(r["mergeable"])
|
||||||
@@ -2026,7 +2050,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
|
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("profile is not allowed to merge", r["reasons"])
|
self.assertIn("profile is not allowed to merge", r["reasons"])
|
||||||
@@ -2038,7 +2062,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
|
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("profile forbids 'merge'", r["reasons"])
|
self.assertIn("profile forbids 'merge'", r["reasons"])
|
||||||
@@ -2049,7 +2073,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("PR is not open (state=closed)", r["reasons"])
|
self.assertIn("PR is not open (state=closed)", r["reasons"])
|
||||||
@@ -2058,7 +2082,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
def test_unknown_identity_fails_closed(self, _auth):
|
def test_unknown_identity_fails_closed(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated identity could not be determined", r["reasons"])
|
self.assertIn("authenticated identity could not be determined", r["reasons"])
|
||||||
@@ -2077,7 +2101,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||||
blob = repr(r).lower()
|
blob = repr(r).lower()
|
||||||
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||||
@@ -2274,7 +2298,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2293,7 +2317,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2323,7 +2347,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2343,7 +2367,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2369,7 +2393,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="request_changes",
|
pr_number=8, action="request_changes",
|
||||||
body="needs work", remote="prgs",
|
body="needs work", remote="prgs",
|
||||||
@@ -2390,7 +2414,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
|
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="request_changes", remote="prgs",
|
pr_number=8, action="request_changes", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2411,7 +2435,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="comment", body="finding", remote="prgs",
|
pr_number=8, action="comment", body="finding", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2429,7 +2453,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(
|
||||||
8, "comment", remote="prgs", expected_head_sha="abc123",
|
8, "comment", remote="prgs", expected_head_sha="abc123",
|
||||||
)
|
)
|
||||||
@@ -2445,7 +2469,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
def test_unknown_identity_blocks(self, _auth):
|
def test_unknown_identity_blocks(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2460,7 +2484,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2479,7 +2503,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve",
|
pr_number=8, action="approve",
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -2503,7 +2527,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve",
|
pr_number=8, action="approve",
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -2532,7 +2556,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=5, action="approve", remote="prgs",
|
pr_number=5, action="approve", remote="prgs",
|
||||||
@@ -2552,7 +2576,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2646,7 +2670,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2679,7 +2703,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
||||||
with patch("mcp_server.gitea_get_pr_review_feedback",
|
with patch("mcp_server.gitea_get_pr_review_feedback",
|
||||||
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
first = gitea_submit_pr_review(
|
first = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2705,7 +2729,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
# Mark decision for specific remote, org, repo
|
# Mark decision for specific remote, org, repo
|
||||||
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
||||||
|
|
||||||
@@ -2743,13 +2767,29 @@ if __name__ == "__main__":
|
|||||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self._mut_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
shared_mutation_env("test-author-dadeschools"),
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._mut_env.start()
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||||
self.mock_audit = patch("gitea_audit.write_event").start()
|
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||||
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
||||||
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
|
patch("mcp_server.get_profile", return_value={
|
||||||
|
"profile_name": "test-author-dadeschools",
|
||||||
|
"allowed_operations": [
|
||||||
|
"read", "merge", "edit", "close",
|
||||||
|
"gitea.pr.close", "gitea.issue.close", "gitea.read",
|
||||||
|
],
|
||||||
|
"audit_label": "test",
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
}).start()
|
||||||
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
"mcp_server._pr_work_lease_reviewer_block",
|
||||||
@@ -2757,6 +2797,11 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self._mut_env.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
patch.stopall()
|
patch.stopall()
|
||||||
|
|
||||||
def test_close_issue_removes_in_progress(self):
|
def test_close_issue_removes_in_progress(self):
|
||||||
@@ -3162,7 +3207,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs",
|
result = gitea_get_profile(remote="prgs",
|
||||||
resolve_identity=False)
|
resolve_identity=False)
|
||||||
blob = repr(result)
|
blob = repr(result)
|
||||||
@@ -3184,7 +3229,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs",
|
result = gitea_get_profile(remote="prgs",
|
||||||
resolve_identity=False)
|
resolve_identity=False)
|
||||||
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
||||||
@@ -3196,7 +3241,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
env = {"GITEA_MCP_CONFIG": path,
|
env = {"GITEA_MCP_CONFIG": path,
|
||||||
"GITEA_MCP_PROFILE": "prgs-author"}
|
"GITEA_MCP_PROFILE": "prgs-author"}
|
||||||
with patch.dict(os.environ, env, clear=True), \
|
with patch.dict(os.environ, env, clear=False), \
|
||||||
patch("gitea_config._keychain_token", return_value="x"):
|
patch("gitea_config._keychain_token", return_value="x"):
|
||||||
result = gitea_audit_config()
|
result = gitea_audit_config()
|
||||||
finally:
|
finally:
|
||||||
@@ -3284,7 +3329,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||||
mock_api.return_value = [self._comment()]
|
mock_api.return_value = [self._comment()]
|
||||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||||
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
||||||
|
|
||||||
@@ -3293,7 +3338,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertTrue(result["reasons"])
|
self.assertTrue(result["reasons"])
|
||||||
@@ -3340,7 +3385,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||||
mock_api.return_value = self._comment(555)
|
mock_api.return_value = self._comment(555)
|
||||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertIn("issuecomment-555", result["url"])
|
self.assertIn("issuecomment-555", result["url"])
|
||||||
@@ -3352,7 +3397,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
||||||
"gitea.pr.approve,gitea.pr.merge"}
|
"gitea.pr.approve,gitea.pr.merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3366,7 +3411,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3502,7 +3547,7 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
|
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
|
||||||
"gitea.pr.create,gitea.read,gitea.repo.commit"}
|
"gitea.pr.create,gitea.read,gitea.repo.commit"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=51, body="audit findings", remote="prgs")
|
issue_number=51, body="audit findings", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3687,11 +3732,12 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._lock_dir = tempfile.TemporaryDirectory()
|
self._lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
# Most locking tests target remote=prgs; host-align profile (#714).
|
||||||
env = {
|
env = {
|
||||||
**ISSUE_WRITE_ENV,
|
**shared_mutation_env("test-author-prgs"),
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||||
}
|
}
|
||||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
self._env_patcher = patch.dict(os.environ, env, clear=False)
|
||||||
self._env_patcher.start()
|
self._env_patcher.start()
|
||||||
self._dup_fetcher_patcher = patch(
|
self._dup_fetcher_patcher = patch(
|
||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
@@ -3705,8 +3751,9 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
self._lock_dir.cleanup()
|
self._lock_dir.cleanup()
|
||||||
|
|
||||||
def _create_pr_env(self) -> dict:
|
def _create_pr_env(self) -> dict:
|
||||||
|
# PR-locking tests call create_pr with remote=prgs.
|
||||||
return {
|
return {
|
||||||
**CREATE_PR_ENV,
|
**shared_mutation_env("test-author-prgs"),
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3725,7 +3772,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
self.assertIn("created_at", res["work_lease"])
|
self.assertIn("created_at", res["work_lease"])
|
||||||
self.assertIn("expires_at", res["work_lease"])
|
self.assertIn("expires_at", res["work_lease"])
|
||||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
self.assertIn("last_heartbeat_at", res["work_lease"])
|
||||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
self.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
|
||||||
self.assertIn("lock_file_path", res)
|
self.assertIn("lock_file_path", res)
|
||||||
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
||||||
self.assertIn("worktree_path", lock)
|
self.assertIn("worktree_path", lock)
|
||||||
@@ -3844,7 +3891,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
||||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.lock_file_path(
|
issue_lock_store.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
@@ -3881,7 +3928,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
|
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
|
||||||
This MCP path must remain fail-closed for sticky expired foreign ownership.
|
This MCP path must remain fail-closed for sticky expired foreign ownership.
|
||||||
"""
|
"""
|
||||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||||
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
|
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
|
||||||
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
|
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
|
||||||
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
|
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
|
||||||
@@ -4075,12 +4122,12 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.lock_file_path(
|
issue_lock_store.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
repo="Gitea-Tools", # #714 session-bound
|
||||||
issue_number=447,
|
issue_number=447,
|
||||||
lock_dir=lock_dir,
|
lock_dir=lock_dir,
|
||||||
),
|
),
|
||||||
@@ -4089,7 +4136,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
branch_name="feat/issue-447-lock-provenance",
|
branch_name="feat/issue-447-lock-provenance",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
repo="Gitea-Tools", # #714 session-bound
|
||||||
worktree_path=worktree,
|
worktree_path=worktree,
|
||||||
lock_provenance=None,
|
lock_provenance=None,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||||
|
|
||||||
@patch("threading.Thread")
|
def test_auto_restart_helper_removed_from_read_only_path(self):
|
||||||
@patch("os.utime")
|
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
|
||||||
@patch("os.path.exists")
|
self.assertFalse(
|
||||||
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
|
||||||
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
|
||||||
mock_exists.return_value = True
|
)
|
||||||
gitea_mcp_server._restart_triggered = False
|
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
|
||||||
|
|
||||||
# Ensure we are not skipped in test mode for testing purposes
|
|
||||||
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
|
|
||||||
gitea_mcp_server._trigger_mcp_auto_restart()
|
|
||||||
|
|
||||||
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
|
|
||||||
mock_thread.assert_called_once()
|
|
||||||
self.assertTrue(gitea_mcp_server._restart_triggered)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
import task_capability_map as tcm
|
||||||
|
|
||||||
|
|
||||||
|
HEAD = "a" * 40
|
||||||
|
LIVE_HEAD = HEAD
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._preflight_whoami_called = True
|
||||||
|
mcp_server._preflight_capability_called = True
|
||||||
|
mcp_server._preflight_resolved_role = "merger"
|
||||||
|
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def _merger_profile(self, **extra):
|
||||||
|
p = {
|
||||||
|
"username": "merger-user",
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p.update(extra)
|
||||||
|
return p
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_success(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||||
|
{"id": 456},
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"], res)
|
||||||
|
self.assertTrue(res["acquired"])
|
||||||
|
self.assertEqual(res["comment_id"], 456)
|
||||||
|
self.assertEqual(res["session_id"], "merger-session")
|
||||||
|
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
|
||||||
|
body = mock_api.call_args_list[-1][0][3]["body"]
|
||||||
|
self.assertIn("reviewer_identity: merger-user", body)
|
||||||
|
self.assertIn("profile: prgs-merger", body)
|
||||||
|
session_lease = leases._SESSION_LEASE
|
||||||
|
self.assertIsNotNone(session_lease)
|
||||||
|
provenance = session_lease.get("lease_provenance") or {}
|
||||||
|
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_if_active_exists(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
active_body = leases.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=718,
|
||||||
|
issue_number=None,
|
||||||
|
reviewer_identity="other-user",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="other-session",
|
||||||
|
worktree="branches/review-1",
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
_comments.return_value = [
|
||||||
|
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
|
||||||
|
]
|
||||||
|
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["acquired"])
|
||||||
|
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
||||||
|
self.assertEqual(
|
||||||
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
|
||||||
|
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||||
|
{"id": 789},
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"], res)
|
||||||
|
kwargs = mock_verify.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
||||||
|
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
def test_acquire_merger_requires_candidate_head(self, mock_profile):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=None,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_head_mismatch(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
"""Empty/missing live head must not silently proceed past exact-head gate."""
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
# PR payload present but head.sha missing / empty / non-dict.
|
||||||
|
mock_api.return_value = {"state": "open", "head": {}}
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"], res)
|
||||||
|
self.assertFalse(res.get("acquired"), res)
|
||||||
|
self.assertTrue(
|
||||||
|
any("live PR head could not be resolved" in r for r in res["reasons"]),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
self.assertIsNone(res.get("live_head"))
|
||||||
|
self.assertEqual(res.get("candidate_head"), HEAD)
|
||||||
|
self.assertEqual(
|
||||||
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
# Completely empty GET /pulls body also fails closed.
|
||||||
|
mock_api.return_value = {}
|
||||||
|
res2 = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res2["success"], res2)
|
||||||
|
self.assertTrue(
|
||||||
|
any("live PR head could not be resolved" in r for r in res2["reasons"]),
|
||||||
|
res2,
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._profile_operation_gate")
|
||||||
|
def test_author_denied_merge_permission(self, mock_gate):
|
||||||
|
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
|
||||||
|
def gate(op):
|
||||||
|
if op == "gitea.pr.merge":
|
||||||
|
return [f"profile lacks {op}"]
|
||||||
|
return None
|
||||||
|
mock_gate.side_effect = gate
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._profile_operation_gate")
|
||||||
|
def test_reviewer_denied_merge_permission(self, mock_gate):
|
||||||
|
def gate(op):
|
||||||
|
if op == "gitea.pr.merge":
|
||||||
|
return [f"profile lacks {op}"]
|
||||||
|
return None
|
||||||
|
mock_gate.side_effect = gate
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityMapLeaseTasks(unittest.TestCase):
|
||||||
|
def test_merger_acquire_map_entries(self):
|
||||||
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||||
|
self.assertEqual(tcm.required_role(task), "merger")
|
||||||
|
|
||||||
|
def test_reviewer_acquire_map_entries(self):
|
||||||
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||||
|
self.assertEqual(tcm.required_role(task), "reviewer")
|
||||||
|
|
||||||
|
def test_adopt_merger_aliases(self):
|
||||||
|
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_role(task), "merger")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_acquire_reviewer_authorized(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
|
||||||
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
self.assertEqual(res["required_role_kind"], "reviewer", res)
|
||||||
|
self.assertEqual(
|
||||||
|
res["required_operation_permission"], "gitea.pr.comment", res
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("allowed_in_current_session"), res)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_acquire_reviewer_author_denied(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.read",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="acquire_reviewer_pr_lease", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"), res)
|
||||||
|
self.assertEqual(res["required_role_kind"], "reviewer")
|
||||||
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
"cannot perform reviewer" in guidance.lower()
|
||||||
|
or "reviewer" in guidance.lower()
|
||||||
|
or res.get("stop_required"),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
# Must not raise ValueError into the tool boundary.
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="some_made_up_task", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsInstance(res, dict)
|
||||||
|
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(res.get("stop_required"))
|
||||||
|
self.assertIs(res.get("mutation_performed"), False)
|
||||||
|
self.assertNotIn("token", str(res).lower())
|
||||||
|
self.assertNotIn("password", str(res).lower())
|
||||||
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||||
|
self.assertIn("Unknown task/action", guidance)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_merger_acquire_aliases(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-merger": {
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
|
||||||
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
self.assertEqual(res["required_role_kind"], "merger", res)
|
||||||
|
self.assertEqual(
|
||||||
|
res["required_operation_permission"], "gitea.pr.comment", res
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Merger lease adoption / recovery (#536)."""
|
"""Merger lease adoption / recovery (#536)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Safe mutation-error diagnostics for the tool boundary.
|
||||||
|
|
||||||
|
The #699/#701 boundary intentionally collapses every non-typed exception to an
|
||||||
|
opaque ``internal_error`` with the class and message discarded. That makes a
|
||||||
|
mutation failure (create_issue / create_pr / delete_branch / issue-lock)
|
||||||
|
undiagnosable: reason_code=internal_error, retryable=false, no actionable
|
||||||
|
detail.
|
||||||
|
|
||||||
|
This suite pins the additive contract:
|
||||||
|
|
||||||
|
1. For the ``internal_error`` fallback ONLY, capture a **safe exception class**
|
||||||
|
(a Python type identifier — never secrets) and a **strictly-redacted**
|
||||||
|
detail (no tokens, Authorization headers, raw URLs/hosts, absolute paths,
|
||||||
|
response bodies, env dumps), plus an optional ``mutation_stage`` tag.
|
||||||
|
2. Typed classifications (auth/authz/network/config/http) are unchanged and
|
||||||
|
still emit fixed messages with NO detail in the daemon log.
|
||||||
|
3. Any exception may self-declare a safe ``gitea_reason_code`` to be converted
|
||||||
|
into a typed structured error instead of an opaque internal_error.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import gitea_auth
|
||||||
|
import mcp_tool_error_boundary as boundary
|
||||||
|
|
||||||
|
# Adversarial text a poisoned internal exception might carry.
|
||||||
|
POISON = (
|
||||||
|
"boom at /Users/jasonwalker/secret/creds.json "
|
||||||
|
"Authorization: token ghp_leaked_secret_xyz "
|
||||||
|
"https://sysadmin:[email protected]/api/v1/x?token=supersecretXYZ "
|
||||||
|
'Bearer abc.def.ghi {"token":"supersecretXYZ","password":"hunter2"}'
|
||||||
|
)
|
||||||
|
SECRETS = (
|
||||||
|
"ghp_leaked_secret_xyz",
|
||||||
|
"supersecretXYZ",
|
||||||
|
"hunter2",
|
||||||
|
"/Users/jasonwalker/secret",
|
||||||
|
"gitea.prgs.cc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedactDetail(unittest.TestCase):
|
||||||
|
def test_strips_secrets_paths_urls_and_truncates(self):
|
||||||
|
out = boundary._redact_detail(POISON)
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, out)
|
||||||
|
self.assertLessEqual(len(out), boundary._DETAIL_LIMIT)
|
||||||
|
|
||||||
|
def test_empty_is_safe(self):
|
||||||
|
self.assertEqual(boundary._redact_detail(""), "")
|
||||||
|
self.assertEqual(boundary._redact_detail(None), "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeExceptionClass(unittest.TestCase):
|
||||||
|
def test_fully_qualified_type_identifier(self):
|
||||||
|
self.assertEqual(
|
||||||
|
boundary._safe_exception_class(RuntimeError("x")), "builtins.RuntimeError"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
boundary._safe_exception_class(KeyError("x")), "builtins.KeyError"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_class_name_never_carries_instance_text(self):
|
||||||
|
# The class identifier must not include the (possibly secret) message.
|
||||||
|
cls = boundary._safe_exception_class(RuntimeError(POISON))
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, cls)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInternalErrorGainsDiagnostics(unittest.TestCase):
|
||||||
|
def test_classify_internal_has_class_and_redacted_detail(self):
|
||||||
|
c = boundary.classify_exception(RuntimeError(POISON))
|
||||||
|
self.assertEqual(c["reason_code"], "internal_error")
|
||||||
|
self.assertEqual(c["error_class"], "internal")
|
||||||
|
self.assertEqual(c["exception_class"], "builtins.RuntimeError")
|
||||||
|
self.assertIn("detail", c)
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, json.dumps(c))
|
||||||
|
|
||||||
|
def test_payload_surfaces_class_and_detail_but_fixed_message(self):
|
||||||
|
p = boundary.build_structured_error_payload(
|
||||||
|
boundary.classify_exception(RuntimeError(POISON)),
|
||||||
|
tool_name="gitea_delete_branch",
|
||||||
|
)
|
||||||
|
self.assertEqual(p["reason_code"], "internal_error")
|
||||||
|
self.assertEqual(p["exception_class"], "builtins.RuntimeError")
|
||||||
|
self.assertIn("detail", p)
|
||||||
|
self.assertEqual(p["message"], boundary.fixed_message("internal_error"))
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, json.dumps(p))
|
||||||
|
|
||||||
|
def test_daemon_log_internal_includes_class_and_detail_no_secrets(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
c = boundary.classify_exception(RuntimeError(POISON))
|
||||||
|
boundary.log_sanitized_daemon_reason(
|
||||||
|
c, tool_name="gitea_delete_branch", stream=buf
|
||||||
|
)
|
||||||
|
line = buf.getvalue()
|
||||||
|
self.assertIn("reason_code=internal_error", line)
|
||||||
|
self.assertIn("exception_class=builtins.RuntimeError", line)
|
||||||
|
self.assertIn("detail=", line)
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, line)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypedPathsUnchanged(unittest.TestCase):
|
||||||
|
def test_auth_payload_has_no_detail_or_class(self):
|
||||||
|
p = boundary.build_structured_error_payload(
|
||||||
|
boundary.classify_exception(gitea_auth.GiteaAuthError())
|
||||||
|
)
|
||||||
|
self.assertNotIn("detail", p)
|
||||||
|
self.assertNotIn("exception_class", p)
|
||||||
|
|
||||||
|
def test_auth_daemon_log_has_no_detail(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
boundary.log_sanitized_daemon_reason(
|
||||||
|
boundary.classify_exception(gitea_auth.GiteaAuthError()),
|
||||||
|
tool_name="gitea_whoami",
|
||||||
|
stream=buf,
|
||||||
|
)
|
||||||
|
self.assertNotIn("detail=", buf.getvalue())
|
||||||
|
self.assertNotIn("exception_class=", buf.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationStageTag(unittest.TestCase):
|
||||||
|
def test_stage_flows_to_payload_and_log(self):
|
||||||
|
exc = RuntimeError("boom")
|
||||||
|
setattr(exc, "_gitea_mutation_stage", "preflight_purity")
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["mutation_stage"], "preflight_purity")
|
||||||
|
p = boundary.build_structured_error_payload(c, tool_name="gitea_create_pr")
|
||||||
|
self.assertEqual(p["mutation_stage"], "preflight_purity")
|
||||||
|
buf = io.StringIO()
|
||||||
|
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_create_pr", stream=buf)
|
||||||
|
self.assertIn("mutation_stage=preflight_purity", buf.getvalue())
|
||||||
|
|
||||||
|
def test_stage_value_is_bounded_and_safe(self):
|
||||||
|
exc = RuntimeError("boom")
|
||||||
|
setattr(exc, "_gitea_mutation_stage", "x" * 500 + POISON)
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, c["mutation_stage"])
|
||||||
|
self.assertLessEqual(len(c["mutation_stage"]), 64)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGiteaReasonCodeConversion(unittest.TestCase):
|
||||||
|
def test_self_declared_reason_becomes_typed(self):
|
||||||
|
exc = RuntimeError("Pre-flight order violation: whoami missing")
|
||||||
|
setattr(exc, "gitea_reason_code", "preflight_order_violation")
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["reason_code"], "preflight_order_violation")
|
||||||
|
self.assertNotEqual(c["reason_code"], "internal_error")
|
||||||
|
p = boundary.build_structured_error_payload(c, tool_name="gitea_delete_branch")
|
||||||
|
self.assertEqual(p["reason_code"], "preflight_order_violation")
|
||||||
|
self.assertEqual(
|
||||||
|
p["message"], boundary.fixed_message("preflight_order_violation")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_self_declared_reason_ignored(self):
|
||||||
|
exc = RuntimeError("boom")
|
||||||
|
setattr(exc, "gitea_reason_code", "not_a_real_reason")
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["reason_code"], "internal_error")
|
||||||
|
|
||||||
|
def test_preflight_reason_is_registered(self):
|
||||||
|
self.assertIn("preflight_order_violation", boundary.FIXED_MESSAGES)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdversarialInternalNoLeak(unittest.TestCase):
|
||||||
|
def test_poisoned_internal_never_leaks_in_result_or_log(self):
|
||||||
|
exc = RuntimeError(POISON)
|
||||||
|
buf = io.StringIO()
|
||||||
|
result = boundary.to_call_tool_result(
|
||||||
|
exc, tool_name="gitea_delete_branch", log=False
|
||||||
|
)
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
boundary.log_sanitized_daemon_reason(
|
||||||
|
c, tool_name="gitea_delete_branch", stream=buf
|
||||||
|
)
|
||||||
|
blob = (
|
||||||
|
json.dumps(result.structuredContent)
|
||||||
|
+ result.content[0].text
|
||||||
|
+ buf.getvalue()
|
||||||
|
)
|
||||||
|
for s in SECRETS:
|
||||||
|
self.assertNotIn(s, blob)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerSideTypedAndStage(unittest.TestCase):
|
||||||
|
"""Server raisers are typed and stage-taggable, end-to-end into the boundary."""
|
||||||
|
|
||||||
|
def test_preflight_order_error_is_typed_runtimeerror(self):
|
||||||
|
import gitea_mcp_server as s
|
||||||
|
|
||||||
|
exc = s._PreflightOrderError("whoami missing")
|
||||||
|
self.assertIsInstance(exc, RuntimeError) # legacy except RuntimeError
|
||||||
|
self.assertEqual(exc.gitea_reason_code, "preflight_order_violation")
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["reason_code"], "preflight_order_violation")
|
||||||
|
self.assertNotEqual(c["reason_code"], "internal_error")
|
||||||
|
|
||||||
|
def test_mutation_stage_tags_and_reraises(self):
|
||||||
|
import gitea_mcp_server as s
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
with s._mutation_stage("preflight_purity"):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
self.assertEqual(
|
||||||
|
getattr(ctx.exception, "_gitea_mutation_stage", None), "preflight_purity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mutation_stage_inner_wins(self):
|
||||||
|
import gitea_mcp_server as s
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
with s._mutation_stage("outer"):
|
||||||
|
with s._mutation_stage("inner"):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
self.assertEqual(
|
||||||
|
getattr(ctx.exception, "_gitea_mutation_stage", None), "inner"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Operation-name normalization table and enforcement tests — issue #106.
|
"""Operation-name normalization table and enforcement tests — issue #106.
|
||||||
|
|
||||||
Covers the required matrix from #106:
|
Covers the required matrix from #106:
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
|
"""Tests for operation-scoped role selection (#228, updated by #714).
|
||||||
|
|
||||||
|
#714 removes silent automatic profile substitution. Capability resolution
|
||||||
|
and mutation gates evaluate only the active profile; explicit
|
||||||
|
``gitea_activate_profile`` is required to switch roles.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
@@ -15,6 +20,7 @@ from reviewer_worktree import assess_author_worktree_continuity
|
|||||||
|
|
||||||
CONFIG_TEST = {
|
CONFIG_TEST = {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
|
"allow_runtime_switching": True,
|
||||||
"contexts": {
|
"contexts": {
|
||||||
"ctx": {
|
"ctx": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -30,9 +36,11 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
"username": "author-user",
|
"username": "author-user",
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||||
"execution_profile": "author-profile"
|
"execution_profile": "author-profile"
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
@@ -40,9 +48,11 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,15 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
self._remotes_patch.start()
|
self._remotes_patch.start()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._url = patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_local_git_remote_url",
|
||||||
|
side_effect=lambda n: {
|
||||||
|
"prgs": "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
"dadeschools": "https://gitea.dadeschools.net/913443/eAgenda.git",
|
||||||
|
}.get(n),
|
||||||
|
)
|
||||||
|
self._url.start()
|
||||||
gitea_config._active_profile_override = None
|
gitea_config._active_profile_override = None
|
||||||
mcp_server._MUTATION_AUTHORITY = None
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
@@ -64,6 +83,11 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
self._write_config(CONFIG_TEST)
|
self._write_config(CONFIG_TEST)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self._url.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self._remotes_patch.stop()
|
self._remotes_patch.stop()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
gitea_config._active_profile_override = None
|
gitea_config._active_profile_override = None
|
||||||
@@ -85,62 +109,71 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_auto_switch_to_reviewer(self, mock_api):
|
def test_no_auto_switch_to_reviewer(self, mock_api):
|
||||||
# mock identity resolution to return username matching profile
|
# #714: capability resolution must not silently switch author → reviewer
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
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(gitea_config.selected_profile_name(), "author-profile")
|
||||||
self.assertEqual(mcp_server.get_profile()["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")
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
# verify it automatically switched to reviewer-profile
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["available_in_session"])
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
self.assertEqual(res["active_identity"], "author-user")
|
||||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertIn("reviewer-profile", res["matching_configured_profile"])
|
||||||
|
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_auto_switch_to_author(self, mock_api):
|
def test_no_auto_switch_to_author(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
# initially we are reviewer-profile
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "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")
|
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
||||||
|
|
||||||
# verify it automatically switched to author-profile
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["available_in_session"])
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
self.assertEqual(res["active_profile"], "author-profile")
|
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||||
self.assertEqual(res["active_identity"], "author-user")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertIn("author-profile", res["matching_configured_profile"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_restart_required_when_unattached(self, mock_api):
|
def test_explicit_activate_profile_still_works(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")):
|
||||||
|
act = mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="reviewer-profile", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertTrue(act["success"])
|
||||||
|
self.assertEqual(act["after_profile"], "reviewer-profile")
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_denied_when_matching_profile_token_missing(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
||||||
# launch without reviewer token in env
|
# launch without reviewer token in env
|
||||||
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
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")
|
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["allowed_in_current_session"])
|
||||||
self.assertFalse(res["available_in_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["configured"])
|
self.assertTrue(res["configured"])
|
||||||
self.assertTrue(res["restart_required"])
|
|
||||||
self.assertTrue(res["stop_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"])
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
|
|
||||||
def test_author_continuity_dirty_worktree(self):
|
def test_author_continuity_dirty_worktree(self):
|
||||||
# author is allowed to keep dirty worktree
|
# author is allowed to keep dirty worktree
|
||||||
@@ -161,20 +194,21 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
self.assertFalse(res2["proven"])
|
self.assertFalse(res2["proven"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_mutating_actions_auto_switch(self, mock_api):
|
def test_mutating_actions_do_not_auto_switch(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
# verify we are author-profile
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "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(
|
||||||
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
|
"gitea.pr.merge", remote="prgs"
|
||||||
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertFalse(blocked.get("success", True))
|
||||||
|
|
||||||
# verify we dynamically switched to reviewer-profile
|
# profile must remain author — no silent substitution
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for the operator guide / project skills MCP tools (#128).
|
"""Tests for the operator guide / project skills MCP tools (#128).
|
||||||
|
|
||||||
Read-only capability-discovery tools: mcp_get_control_plane_guide,
|
Read-only capability-discovery tools: mcp_get_control_plane_guide,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Post-merge moot reviewer-lease handling (#515).
|
"""Post-merge moot reviewer-lease handling (#515).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""#727 / PR #728: author ownership when tracking issue number != PR number.
|
||||||
|
|
||||||
|
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
|
||||||
|
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
|
||||||
|
context and fails closed for unrelated issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import issue_lock_store
|
||||||
|
import gitea_mcp_server as mcp
|
||||||
|
|
||||||
|
|
||||||
|
def _live_lock(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str = "feat/issue-727-pr-sync-status",
|
||||||
|
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
|
||||||
|
remote: str = "prgs",
|
||||||
|
org: str = "Scaled-Tech-Consulting",
|
||||||
|
repo: str = "Gitea-Tools",
|
||||||
|
) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"acquired_at": now.isoformat(),
|
||||||
|
"expires_at": (now + timedelta(hours=2)).isoformat(),
|
||||||
|
"owner_pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
|
||||||
|
self.lock_dir = self._tmp.name
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# Clear session pointer for this process.
|
||||||
|
try:
|
||||||
|
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
|
||||||
|
if os.path.isfile(ptr):
|
||||||
|
os.unlink(ptr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_session_lock_accepted(self):
|
||||||
|
"""Tracking issue #727 lock is valid ownership for PR #728."""
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727\n\nNative PR sync lifecycle.",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertTrue(result["has_author_lock"])
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "session_lock")
|
||||||
|
self.assertIn(727, result["linked_issues"])
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_durable_lock_accepted(self):
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=727,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Fixes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "durable_lock")
|
||||||
|
|
||||||
|
def test_legacy_same_number_still_accepted(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=100,
|
||||||
|
branch_name="fix/issue-100",
|
||||||
|
worktree_path="/tmp/branches/issue-100",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=100,
|
||||||
|
pr_title="fix something",
|
||||||
|
pr_body="Closes #100",
|
||||||
|
source_branch="fix/issue-100",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 100)
|
||||||
|
|
||||||
|
def test_unrelated_issue_lock_rejected(self):
|
||||||
|
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=999,
|
||||||
|
branch_name="feat/issue-999",
|
||||||
|
worktree_path="/tmp/branches/issue-999",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=999,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertFalse(result["has_author_lock"])
|
||||||
|
self.assertIsNone(result["matched_issue"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
def test_branch_mismatch_on_session_lock_fail_closed(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=727,
|
||||||
|
branch_name="feat/other-branch",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_lock_fail_closed(self):
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Hermetic tests for PR synchronization / conflict-remediation lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pr_sync_status import (
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED,
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
assess_post_update_head_transition,
|
||||||
|
assess_pr_sync_status,
|
||||||
|
assess_sequential_queue_step,
|
||||||
|
assess_update_pr_branch_preflight,
|
||||||
|
lease_usable_at_head,
|
||||||
|
merge_approval_usable_at_head,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(prefix: str) -> str:
|
||||||
|
return (prefix + "0" * 40)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
PR_HEAD = _sha("aaaaaaaa")
|
||||||
|
BASE_HEAD = _sha("bbbbbbbb")
|
||||||
|
NEW_HEAD = _sha("cccccccc")
|
||||||
|
OLD_HEAD = _sha("dddddddd")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_kwargs(**overrides):
|
||||||
|
data = {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"pr_number": 100,
|
||||||
|
"pr_state": "open",
|
||||||
|
"source_branch": "fix/issue-100",
|
||||||
|
"pr_head_sha": PR_HEAD,
|
||||||
|
"base_head_sha": BASE_HEAD,
|
||||||
|
"commits_behind": 0,
|
||||||
|
"mergeable": True,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"branch_protection_requires_current_base": False,
|
||||||
|
"approval_at_current_head": True,
|
||||||
|
"checks_status": "success",
|
||||||
|
"active_author_lock": True,
|
||||||
|
"active_reviewer_lease": False,
|
||||||
|
"active_merger_lease": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessPrSyncStatus(unittest.TestCase):
|
||||||
|
def test_approved_current_mergeable_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs())
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertFalse(result["stale_approval"])
|
||||||
|
self.assertEqual(result["pr_head_sha"], PR_HEAD)
|
||||||
|
self.assertEqual(result["base_head_sha"], BASE_HEAD)
|
||||||
|
|
||||||
|
def test_approved_outdated_update_not_required_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=3,
|
||||||
|
branch_protection_requires_current_base=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertTrue(any("does not require" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_outdated_update_required_no_conflict(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=2,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=True,
|
||||||
|
has_conflicts=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_outdated_has_conflicts_author_remediation(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=5,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=False,
|
||||||
|
has_conflicts=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_stale_approval_fresh_review_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=False,
|
||||||
|
commits_behind=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
self.assertTrue(result["stale_approval"])
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_stale_prepared_verdict_cannot_cross_heads(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=True,
|
||||||
|
prepared_verdict_head_sha=OLD_HEAD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
self.assertTrue(result["stale_prepared_verdict"])
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_missing_heads_fail_closed(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(pr_head_sha="short", base_head_sha=None)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertTrue(any("PR head" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_closed_pr_blocked(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs(pr_state="closed"))
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_failed_checks_block_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs(checks_status="failure"))
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_stale_approval_with_update_required_routes_update(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=False,
|
||||||
|
commits_behind=1,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=True,
|
||||||
|
has_conflicts=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePrBranchPreflight(unittest.TestCase):
|
||||||
|
def _ok_kwargs(self, **overrides):
|
||||||
|
data = {
|
||||||
|
"role_kind": "author",
|
||||||
|
"expected_pr_head_sha": PR_HEAD,
|
||||||
|
"live_pr_head_sha": PR_HEAD,
|
||||||
|
"expected_base_head_sha": BASE_HEAD,
|
||||||
|
"live_base_head_sha": BASE_HEAD,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"mergeable": True,
|
||||||
|
"style": "merge",
|
||||||
|
"has_author_lock": True,
|
||||||
|
"worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100",
|
||||||
|
"worktree_owns_source_branch": True,
|
||||||
|
"control_checkout_clean": True,
|
||||||
|
"master_parity_ok": True,
|
||||||
|
"force_push": False,
|
||||||
|
"rebase": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def test_author_allowed_when_preflight_clean(self):
|
||||||
|
result = assess_update_pr_branch_preflight(**self._ok_kwargs())
|
||||||
|
self.assertTrue(result["mutation_allowed"])
|
||||||
|
self.assertFalse(result["head_race"])
|
||||||
|
self.assertFalse(result["base_race"])
|
||||||
|
|
||||||
|
def test_head_race_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(live_pr_head_sha=NEW_HEAD)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["head_race"])
|
||||||
|
self.assertTrue(any("PR head race" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_base_race_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(live_base_head_sha=NEW_HEAD)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["base_race"])
|
||||||
|
self.assertTrue(any("base head race" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_conflicts_structured_handoff_no_mutation(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(has_conflicts=True, mergeable=False)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["author_remediation_handoff"])
|
||||||
|
self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_reviewer_denied(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(role_kind="reviewer")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_merger_denied(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(role_kind="merger")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_force_push(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(force_push=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("force-push" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_rebase(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(style="rebase", rebase=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("rebase" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_lock_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(has_author_lock=False)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
|
def test_worktree_not_under_branches(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(worktree_path="/tmp/not-a-branches-path")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("branches/" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostUpdateHeadTransition(unittest.TestCase):
|
||||||
|
def test_update_invalidates_approval_and_requires_fresh_review(self):
|
||||||
|
result = assess_post_update_head_transition(
|
||||||
|
former_pr_head_sha=PR_HEAD,
|
||||||
|
new_pr_head_sha=NEW_HEAD,
|
||||||
|
former_approval_head_sha=PR_HEAD,
|
||||||
|
former_reviewer_lease_head_sha=PR_HEAD,
|
||||||
|
former_merger_lease_head_sha=PR_HEAD,
|
||||||
|
prepared_verdict_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["head_changed"])
|
||||||
|
self.assertTrue(result["approval_invalidated"])
|
||||||
|
self.assertTrue(result["reviewer_lease_superseded"])
|
||||||
|
self.assertTrue(result["merger_lease_superseded"])
|
||||||
|
self.assertTrue(result["prepared_verdict_invalidated"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_head_unexpected(self):
|
||||||
|
result = assess_post_update_head_transition(
|
||||||
|
former_pr_head_sha=PR_HEAD,
|
||||||
|
new_pr_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["head_changed"])
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleArtifacts(unittest.TestCase):
|
||||||
|
def test_stale_approval_cannot_authorize_merge(self):
|
||||||
|
result = merge_approval_usable_at_head(
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
approved_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_usable"])
|
||||||
|
|
||||||
|
def test_fresh_approval_usable(self):
|
||||||
|
result = merge_approval_usable_at_head(
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
approved_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["approval_usable"])
|
||||||
|
|
||||||
|
def test_stale_reviewer_lease_cannot_cross_heads(self):
|
||||||
|
result = lease_usable_at_head(
|
||||||
|
lease_kind="reviewer",
|
||||||
|
lease_head_sha=PR_HEAD,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["lease_usable"])
|
||||||
|
|
||||||
|
def test_stale_merger_lease_cannot_cross_heads(self):
|
||||||
|
result = lease_usable_at_head(
|
||||||
|
lease_kind="merger",
|
||||||
|
lease_head_sha=PR_HEAD,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["lease_usable"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSequentialQueue(unittest.TestCase):
|
||||||
|
def test_reassess_after_merge_and_master_refresh(self):
|
||||||
|
result = assess_sequential_queue_step(
|
||||||
|
just_merged=True,
|
||||||
|
master_refreshed=True,
|
||||||
|
next_pr_number=101,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["reassess_allowed"])
|
||||||
|
self.assertTrue(result["process_next"])
|
||||||
|
self.assertTrue(result["post_merge_cleanup_handoff"])
|
||||||
|
self.assertEqual(result["next_pr_number"], 101)
|
||||||
|
|
||||||
|
def test_block_reassess_without_master_refresh(self):
|
||||||
|
result = assess_sequential_queue_step(
|
||||||
|
just_merged=True,
|
||||||
|
master_refreshed=False,
|
||||||
|
next_pr_number=101,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["reassess_allowed"])
|
||||||
|
self.assertTrue(any("master must be refreshed" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Regression matrix: session-bound mutation preflight resolves the workspace repo.
|
||||||
|
|
||||||
|
Root-cause follow-up to #733. ``_resolve`` (the actual API-targeting resolver)
|
||||||
|
already prefers the workspace-aligned git remote over the ``REMOTES`` default
|
||||||
|
(bare ``prgs`` → ``Scaled-Tech-Consulting/Timesheet``). The shared #604
|
||||||
|
anti-stomp preflight (``_run_anti_stomp_preflight``) did **not** mirror that: for
|
||||||
|
every mutation task whose call site omitted ``org``/``repo`` it filled the
|
||||||
|
remote-wide ``REMOTES`` default, so the #530 repo guard false-positived
|
||||||
|
``wrong_repo`` against a ``Gitea-Tools`` checkout and blocked native
|
||||||
|
issue/PR/review/merge/lock/cleanup mutations.
|
||||||
|
|
||||||
|
The fix makes ``_run_anti_stomp_preflight`` prefer the trusted, git-remote-
|
||||||
|
derived workspace identity for omitted coordinates on **session-bound** mutation
|
||||||
|
tasks (marking those filled sides explicit, exactly like ``_resolve``), while
|
||||||
|
``delete_branch`` keeps its explicit cross-repository *target* contract (#733)
|
||||||
|
and continues to fail closed on omitted coordinates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import remote_repo_guard
|
||||||
|
|
||||||
|
# Workspace is bound to Gitea-Tools even though the prgs REMOTES default repo is
|
||||||
|
# Timesheet (the exact cross-repo scenario).
|
||||||
|
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
|
||||||
|
RECONCILER = {
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.comment", "gitea.pr.comment",
|
||||||
|
"gitea.pr.close", "gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.create", "gitea.pr.merge"],
|
||||||
|
"audit_label": "prgs-reconciler",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Session-bound mutation tasks (every MUTATION_TASK except the explicit-target
|
||||||
|
# delete_branch) must auto-resolve the workspace repository for omitted coords.
|
||||||
|
SESSION_BOUND_TASKS = [
|
||||||
|
"create_issue",
|
||||||
|
"comment_issue",
|
||||||
|
"close_issue",
|
||||||
|
"mark_issue",
|
||||||
|
"lock_issue",
|
||||||
|
"set_issue_labels",
|
||||||
|
"create_label",
|
||||||
|
"create_pr",
|
||||||
|
"close_pr",
|
||||||
|
"edit_pr",
|
||||||
|
"commit_files",
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
|
"cleanup_stale_claims",
|
||||||
|
"reconcile_merged_cleanups",
|
||||||
|
"reconcile_already_landed_pr",
|
||||||
|
"reconcile_close_superseded_pr",
|
||||||
|
"post_heartbeat",
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"merge_pr",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _AntiStompResolutionBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Timesheet",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def _capture_resolution(self, task, org, repo, *, local_url=LOCAL_GITEA_TOOLS_URL):
|
||||||
|
"""Drive the live anti-stomp runner and capture the org/repo it resolved
|
||||||
|
and passed to the pure assessor."""
|
||||||
|
passthrough = {
|
||||||
|
"allowed": True, "block": False, "blockers": [], "reasons": [],
|
||||||
|
"exact_next_action": "proceed", "blocker_kind": None, "checks": {},
|
||||||
|
}
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, clear=False
|
||||||
|
), patch(
|
||||||
|
"mcp_server._local_git_remote_url", return_value=local_url
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_profile", return_value=RECONCILER
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.anti_stomp_preflight,
|
||||||
|
"assess_anti_stomp_preflight",
|
||||||
|
return_value=passthrough,
|
||||||
|
) as m:
|
||||||
|
mcp_server._run_anti_stomp_preflight(
|
||||||
|
task, remote="prgs", org=org, repo=repo
|
||||||
|
)
|
||||||
|
self.assertTrue(m.called)
|
||||||
|
return m.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionBoundResolution(_AntiStompResolutionBase):
|
||||||
|
def test_omitted_coords_resolve_workspace_repo_and_mark_explicit(self):
|
||||||
|
for task in SESSION_BOUND_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
kw = self._capture_resolution(task, None, None)
|
||||||
|
self.assertEqual(
|
||||||
|
kw["resolved_org"], "Scaled-Tech-Consulting", task
|
||||||
|
)
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Gitea-Tools", task)
|
||||||
|
# Workspace-filled sides are intentional alignment → explicit.
|
||||||
|
self.assertTrue(kw["org_explicit"], task)
|
||||||
|
self.assertTrue(kw["repo_explicit"], task)
|
||||||
|
|
||||||
|
def test_resolved_workspace_repo_passes_the_530_guard(self):
|
||||||
|
kw = self._capture_resolution("create_pr", None, None)
|
||||||
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||||
|
remote="prgs",
|
||||||
|
resolved_org=kw["resolved_org"],
|
||||||
|
resolved_repo=kw["resolved_repo"],
|
||||||
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||||
|
org_explicit=kw["org_explicit"],
|
||||||
|
repo_explicit=kw["repo_explicit"],
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["block"], assessment)
|
||||||
|
|
||||||
|
def test_explicit_caller_coords_are_preserved(self):
|
||||||
|
kw = self._capture_resolution(
|
||||||
|
"create_pr", "Scaled-Tech-Consulting", "Gitea-Tools"
|
||||||
|
)
|
||||||
|
self.assertEqual(kw["resolved_org"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
|
||||||
|
self.assertTrue(kw["org_explicit"])
|
||||||
|
self.assertTrue(kw["repo_explicit"])
|
||||||
|
|
||||||
|
def test_no_workspace_identity_falls_back_to_remotes_default(self):
|
||||||
|
# No derivable local remote → best-effort REMOTES default preserved,
|
||||||
|
# not marked explicit (so the guard still corroborates when able).
|
||||||
|
kw = self._capture_resolution("create_pr", None, None, local_url=None)
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Timesheet")
|
||||||
|
self.assertFalse(kw["org_explicit"])
|
||||||
|
self.assertFalse(kw["repo_explicit"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteBranchContractPreserved(_AntiStompResolutionBase):
|
||||||
|
"""delete_branch keeps its explicit-target contract (#733): omitted coords
|
||||||
|
still resolve the remote-wide default and are NOT marked explicit, so the
|
||||||
|
guard fails closed against a mismatched workspace."""
|
||||||
|
|
||||||
|
def test_omitted_coords_still_remote_default_not_explicit(self):
|
||||||
|
kw = self._capture_resolution("delete_branch", None, None)
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Timesheet")
|
||||||
|
self.assertFalse(kw["org_explicit"])
|
||||||
|
self.assertFalse(kw["repo_explicit"])
|
||||||
|
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||||
|
remote="prgs",
|
||||||
|
resolved_org=kw["resolved_org"],
|
||||||
|
resolved_repo=kw["resolved_repo"],
|
||||||
|
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||||
|
org_explicit=kw["org_explicit"],
|
||||||
|
repo_explicit=kw["repo_explicit"],
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
|
||||||
|
def test_explicit_coords_still_forwarded(self):
|
||||||
|
kw = self._capture_resolution(
|
||||||
|
"delete_branch", "Scaled-Tech-Consulting", "Gitea-Tools"
|
||||||
|
)
|
||||||
|
self.assertEqual(kw["resolved_repo"], "Gitea-Tools")
|
||||||
|
self.assertTrue(kw["org_explicit"])
|
||||||
|
self.assertTrue(kw["repo_explicit"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorCallSiteForwardsOrgRepo(_AntiStompResolutionBase):
|
||||||
|
"""#735: author mutation entrypoints must pass org/repo into preflight."""
|
||||||
|
|
||||||
|
def _assert_call_forwards(self, fn_name: str, *args, **kwargs):
|
||||||
|
import inspect
|
||||||
|
fn = getattr(mcp_server, fn_name)
|
||||||
|
# Force preflight path to raise with the kwargs it received so we can
|
||||||
|
# prove org/repo were forwarded without performing the full mutation.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _capture(*a, **kw):
|
||||||
|
captured.update(kw)
|
||||||
|
captured["_args"] = a
|
||||||
|
raise RuntimeError("capture-only")
|
||||||
|
|
||||||
|
with patch.object(mcp_server, "verify_preflight_purity", side_effect=_capture), \
|
||||||
|
patch.object(mcp_server, "get_profile", return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||||
|
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
}), patch.object(
|
||||||
|
mcp_server, "_resolve",
|
||||||
|
return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_auth", return_value="token fake",
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.role_session_router,
|
||||||
|
"check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_namespace_mutation_block", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_profile_permission_block", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_production_guard_block_from_exc", return_value=None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
fn(*args, **kwargs)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "capture-only" not in str(exc):
|
||||||
|
# Some tools re-raise; still require capture when preflight ran.
|
||||||
|
if not captured:
|
||||||
|
raise
|
||||||
|
self.assertTrue(captured, f"{fn_name} never called verify_preflight_purity")
|
||||||
|
self.assertEqual(captured.get("org"), "Scaled-Tech-Consulting", fn_name)
|
||||||
|
self.assertEqual(captured.get("repo"), "Gitea-Tools", fn_name)
|
||||||
|
|
||||||
|
def test_create_issue_forwards_explicit_org_repo(self):
|
||||||
|
self._assert_call_forwards(
|
||||||
|
"gitea_create_issue",
|
||||||
|
"title",
|
||||||
|
body="body with acceptance criteria for gate",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/fake-wt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_pr_forwards_explicit_org_repo(self):
|
||||||
|
self._assert_call_forwards(
|
||||||
|
"gitea_create_pr",
|
||||||
|
title="t",
|
||||||
|
head="fix/issue-735-x",
|
||||||
|
base="master",
|
||||||
|
body="Closes #735",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/fake-wt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mark_issue_forwards_explicit_org_repo(self):
|
||||||
|
self._assert_call_forwards(
|
||||||
|
"gitea_mark_issue",
|
||||||
|
issue_number=735,
|
||||||
|
action="start",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/fake-wt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_commit_files_forwards_explicit_org_repo(self):
|
||||||
|
self._assert_call_forwards(
|
||||||
|
"gitea_commit_files",
|
||||||
|
files=[{"operation": "update", "path": "x", "content_plain": "y"}],
|
||||||
|
message="m",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression coverage for the remote/repo mismatch guard (#530).
|
"""Regression coverage for the remote/repo mismatch guard (#530).
|
||||||
|
|
||||||
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
||||||
@@ -162,12 +166,12 @@ class TestResolveServerWiring(unittest.TestCase):
|
|||||||
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
||||||
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
||||||
|
|
||||||
def test_resolve_blocks_on_default_repo_mismatch(self):
|
def test_resolve_prefers_workspace_over_timesheet_default(self):
|
||||||
|
"""#714: bare remote=prgs must use workspace Gitea-Tools, not REMOTES Timesheet."""
|
||||||
self._set_prgs_default_repo("Timesheet")
|
self._set_prgs_default_repo("Timesheet")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
host, org, repo = self.server._resolve("prgs", None, None, None)
|
||||||
self.server._resolve("prgs", None, None, None)
|
self.assertEqual(org, "Scaled-Tech-Consulting")
|
||||||
self.assertIn("Timesheet", str(ctx.exception))
|
self.assertEqual(repo, "Gitea-Tools")
|
||||||
self.assertIn("Gitea-Tools", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_resolve_allows_explicit_org_and_repo(self):
|
def test_resolve_allows_explicit_org_and_repo(self):
|
||||||
self._set_prgs_default_repo("Timesheet")
|
self._set_prgs_default_repo("Timesheet")
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertEqual(res["required_role_kind"], "author")
|
self.assertEqual(res["required_role_kind"], "author")
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
@@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
|
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
|
||||||
|
|
||||||
def test_resolve_unknown_task_fails_closed(self):
|
def test_resolve_unknown_task_fails_closed(self):
|
||||||
|
# #723: unknown tasks return structured unknown_task (no ValueError escape).
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
with self.assertRaises(ValueError):
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs")
|
task="invalid_task_xyz", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsInstance(res, dict)
|
||||||
|
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(res.get("stop_required"))
|
||||||
|
self.assertIs(res.get("mutation_performed"), False)
|
||||||
|
|
||||||
# Additional regression tests per #145 for permission boundaries and structured guidance
|
# Additional regression tests per #145 for permission boundaries and structured guidance
|
||||||
def test_issue_comment_does_not_imply_close(self):
|
def test_issue_comment_does_not_imply_close(self):
|
||||||
@@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||||
for unknown in ("close_pull_request", "close", "pr_close"):
|
for unknown in ("close_pull_request", "close", "pr_close"):
|
||||||
with self.assertRaises(ValueError):
|
unk = mcp_server.gitea_resolve_task_capability(
|
||||||
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
|
task=unknown, remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(unk.get("reason_code"), "unknown_task", unk)
|
||||||
|
self.assertFalse(unk.get("allowed_in_current_session"))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+101
-2
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("gitea_mcp_server.api_request")
|
@patch("gitea_mcp_server.api_request")
|
||||||
def test_save_draft_success(self, mock_api):
|
def test_save_draft_success(self, mock_api):
|
||||||
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
|
|
||||||
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
|
|
||||||
mock_api.return_value = {
|
mock_api.return_value = {
|
||||||
"head": {"sha": "headsha1234567890"},
|
"head": {"sha": "headsha1234567890"},
|
||||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
@@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(res.get("success"))
|
self.assertFalse(res.get("success"))
|
||||||
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
def test_resume_draft_foreign_lease(self, mock_comments, mock_api):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
gitea_mcp_server.gitea_save_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
action="approve",
|
||||||
|
body="Good changes",
|
||||||
|
expected_head_sha="headsha1234567890",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": 587,
|
||||||
|
"session_id": "my-session",
|
||||||
|
})
|
||||||
|
mock_comments.return_value = [
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"user": {"username": "sysadmin"},
|
||||||
|
"body": (
|
||||||
|
"<!-- mcp-review-lease:v1 -->\n"
|
||||||
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||||
|
"pr: #587\n"
|
||||||
|
"reviewer_identity: sysadmin\n"
|
||||||
|
"profile: prgs-reviewer\n"
|
||||||
|
"session_id: foreign-session-999\n"
|
||||||
|
"phase: claimed\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertTrue(
|
||||||
|
any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or []))
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
@patch("gitea_mcp_server.terminal_review_hard_stop_reasons")
|
||||||
|
def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "headsha1234567890"},
|
||||||
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
|
}
|
||||||
|
gitea_mcp_server.gitea_save_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
action="approve",
|
||||||
|
body="Good changes",
|
||||||
|
expected_head_sha="headsha1234567890",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": 587,
|
||||||
|
"session_id": "session-1234",
|
||||||
|
})
|
||||||
|
mock_comments.return_value = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user": {"username": "sysadmin"},
|
||||||
|
"body": (
|
||||||
|
"<!-- mcp-review-lease:v1 -->\n"
|
||||||
|
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||||
|
"pr: #587\n"
|
||||||
|
"reviewer_identity: sysadmin\n"
|
||||||
|
"profile: prgs-reviewer\n"
|
||||||
|
"session_id: session-1234\n"
|
||||||
|
"phase: claimed\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_hard_stop.return_value = [
|
||||||
|
"terminal review mutation already recorded for PR #587 (approve); #332 hard-stop"
|
||||||
|
]
|
||||||
|
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||||
|
pr_number=587,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="/tmp/test-worktree",
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("success"))
|
||||||
|
self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or [])))
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for reviewer/author namespace mismatch walls (#209)."""
|
"""Tests for reviewer/author namespace mismatch walls (#209)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -36,6 +40,7 @@ CONFIG = {
|
|||||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-author",
|
"execution_profile": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer": {
|
"prgs-reviewer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +56,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reviewer",
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer-issue-writer": {
|
"prgs-reviewer-issue-writer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -63,12 +69,13 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"forbidden_operations": ["gitea.pr.create"],
|
"forbidden_operations": ["gitea.pr.create"],
|
||||||
"execution_profile": "prgs-reviewer-issue-writer",
|
"execution_profile": "prgs-reviewer-issue-writer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
|
ISSUE_WRITE_ENV = {} # config-backed; operations come from profile allowlist (#714)
|
||||||
|
|
||||||
|
|
||||||
class NamespaceGateBase(unittest.TestCase):
|
class NamespaceGateBase(unittest.TestCase):
|
||||||
@@ -199,7 +206,7 @@ class TestMutationAuditFields(NamespaceGateBase):
|
|||||||
def test_successful_create_issue_audit_includes_namespace_fields(
|
def test_successful_create_issue_audit_includes_namespace_fields(
|
||||||
self, _auth, mock_api, _get_all, _role
|
self, _auth, mock_api, _get_all, _role
|
||||||
):
|
):
|
||||||
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
|
env = self._env("prgs-author", audit=True)
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_create_issue(title="audited", remote="prgs")
|
mcp_server.gitea_create_issue(title="audited", remote="prgs")
|
||||||
with open(self.audit_path, encoding="utf-8") as fh:
|
with open(self.audit_path, encoding="utf-8") as fh:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ CONFIG = {
|
|||||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-author",
|
"execution_profile": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer": {
|
"prgs-reviewer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +52,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reviewer",
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-merger": {
|
"prgs-merger": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -65,6 +67,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-merger",
|
"execution_profile": "prgs-merger",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reconciler": {
|
"prgs-reconciler": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -81,6 +84,7 @@ CONFIG = {
|
|||||||
"gitea.branch.push",
|
"gitea.branch.push",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reconciler",
|
"execution_profile": "prgs-reconciler",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
"execution_profile": "author-profile"
|
"execution_profile": "author-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -45,7 +46,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"merger-profile": {
|
"merger-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -55,7 +57,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
"execution_profile": "merger-profile"
|
"execution_profile": "merger-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"""Stale #332 review-decision lock cleanup (#594).
|
|
||||||
|
|
||||||
Proves:
|
|
||||||
a. active terminal locks still block unrelated terminal reviews
|
|
||||||
b. merged/closed PR locks can be cleared canonically
|
|
||||||
c. cleanup cannot bypass review gates on open PRs
|
|
||||||
d. after moot cleanup, mark_ready for a different PR can proceed
|
|
||||||
(PR #592-style after PR #586-style stale approve)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for canonical workflow skill naming and preflight (#551)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
Reference in New Issue
Block a user