Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e17a5c329 | ||
|
|
da6fd233f4 | ||
|
|
253269c61b |
@@ -45,7 +45,6 @@ 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. |
|
||||||
@@ -58,47 +57,6 @@ 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
|
||||||
|
|||||||
@@ -134,22 +134,16 @@ _TARGET_BRANCH_SHA_RE = re.compile(
|
|||||||
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
r"target branch sha\s*:\s*[0-9a-f]{40}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #698: structured proof is rendered in several equivalent shapes —
|
|
||||||
# `workflow_hash: abc...`, `workflow_hash=abc...`, or JSON
|
|
||||||
# `"workflow_hash": "abc..."`. Recognize all of them; demanding one exact
|
|
||||||
# punctuation style rejects legitimate structured workflow-load proof.
|
|
||||||
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
_WORKFLOW_LOAD_HELPER_RE = re.compile(
|
||||||
r"workflow[-_ ]load[-_ ]helper[-_ ]result\s*[:=]",
|
r"workflow[- ]load helper result\s*:",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_LOAD_HASH_RE = re.compile(
|
_WORKFLOW_LOAD_HASH_RE = re.compile(
|
||||||
r"workflow[-_ ]load[-_ ]helper[-_ ]result[\s\S]{0,400}?"
|
r"workflow[- ]load helper result[\s\S]{0,400}?workflow[_ ]hash\s*:\s*[0-9a-f]{12}",
|
||||||
r"workflow[_ ]hash\"?\s*[:=]\s*\"?[0-9a-f]{12}",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
_WORKFLOW_LOAD_BOUNDARY_RE = re.compile(
|
||||||
r"workflow[-_ ]load[-_ ]helper[-_ ]result[\s\S]{0,400}?"
|
r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)",
|
||||||
r"boundary[_ ]status\"?\s*[:=]\s*\"?(?:clean|violation)",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile(
|
||||||
@@ -250,59 +244,6 @@ def _normalize_task_kind(task_kind: str | None) -> str:
|
|||||||
return _TASK_KIND_ALIASES.get(raw, raw)
|
return _TASK_KIND_ALIASES.get(raw, raw)
|
||||||
|
|
||||||
|
|
||||||
def _iter_action_entries(action_log: list | None) -> list[dict]:
|
|
||||||
"""Yield only structured (dict) action-log entries (#698).
|
|
||||||
|
|
||||||
Callers must never crash on malformed entries (strings, numbers, null)
|
|
||||||
that reach the validator from LLM-composed or partially parsed logs;
|
|
||||||
:func:`sanitize_action_log` reports them separately.
|
|
||||||
"""
|
|
||||||
return [e for e in (action_log or []) if isinstance(e, dict)]
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_action_log(
|
|
||||||
action_log: list | None,
|
|
||||||
) -> tuple[list[dict], list[dict[str, str]]]:
|
|
||||||
"""Split an action log into structured entries and sanitized findings (#698).
|
|
||||||
|
|
||||||
Malformed entries become clear, sanitized ``warning`` findings — the
|
|
||||||
offending value's content is never echoed back (only its position and
|
|
||||||
type), so secrets or garbage in a broken log cannot leak into validation
|
|
||||||
errors, and validation itself proceeds without secondary exceptions.
|
|
||||||
"""
|
|
||||||
if action_log is None:
|
|
||||||
return [], []
|
|
||||||
if not isinstance(action_log, (list, tuple)):
|
|
||||||
return [], [
|
|
||||||
validator_finding(
|
|
||||||
"shared.action_log_malformed",
|
|
||||||
"downgrade",
|
|
||||||
"Action log",
|
|
||||||
"action_log is not a list of structured entries "
|
|
||||||
f"(got {type(action_log).__name__}); it was ignored",
|
|
||||||
"pass action_log as a list of dict entries",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
entries: list[dict] = []
|
|
||||||
findings: list[dict[str, str]] = []
|
|
||||||
for index, entry in enumerate(action_log):
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
entries.append(entry)
|
|
||||||
continue
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"shared.action_log_malformed",
|
|
||||||
"downgrade",
|
|
||||||
"Action log",
|
|
||||||
f"action_log entry {index} is not a structured mapping "
|
|
||||||
f"(got {type(entry).__name__}); the entry was ignored",
|
|
||||||
"repair the malformed action_log entry or drop it before "
|
|
||||||
"revalidating",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return entries, findings
|
|
||||||
|
|
||||||
|
|
||||||
def validator_finding(
|
def validator_finding(
|
||||||
rule_id: str,
|
rule_id: str,
|
||||||
severity: str,
|
severity: str,
|
||||||
@@ -480,7 +421,7 @@ def _rule_shared_canonical_comment_post_claim(
|
|||||||
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text))
|
||||||
rejected_in_log = False
|
rejected_in_log = False
|
||||||
if action_log:
|
if action_log:
|
||||||
for entry in _iter_action_entries(action_log):
|
for entry in action_log:
|
||||||
validation = entry.get("canonical_comment_validation") or {}
|
validation = entry.get("canonical_comment_validation") or {}
|
||||||
if validation.get("allowed") is False:
|
if validation.get("allowed") is False:
|
||||||
rejected_in_log = True
|
rejected_in_log = True
|
||||||
@@ -534,13 +475,9 @@ def _rule_reviewer_vague_mutations_none(
|
|||||||
action_log: list[dict] | None = None,
|
action_log: list[dict] | None = None,
|
||||||
mutations_observed: bool = False,
|
mutations_observed: bool = False,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
# #698: infer review mutations only from authoritative evidence — an
|
|
||||||
# entry proves a mutation only when it affirmatively records
|
|
||||||
# performed=true and was not gated. Read-only diagnostics and pre-API
|
|
||||||
# rejections (entries without a performed flag) are not mutations.
|
|
||||||
performed = any(
|
performed = any(
|
||||||
e.get("performed") is True and not e.get("gated_rejected")
|
e.get("performed") is not False and not e.get("gated_rejected")
|
||||||
for e in _iter_action_entries(action_log)
|
for e in (action_log or [])
|
||||||
)
|
)
|
||||||
if not (mutations_observed or performed):
|
if not (mutations_observed or performed):
|
||||||
return []
|
return []
|
||||||
@@ -599,7 +536,7 @@ def _rule_reviewer_git_fetch_readonly(
|
|||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
fetch_observed = any(
|
fetch_observed = any(
|
||||||
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
||||||
for e in _iter_action_entries(action_log)
|
for e in (action_log or [])
|
||||||
) or _GIT_FETCH_RE.search(text)
|
) or _GIT_FETCH_RE.search(text)
|
||||||
if not fetch_observed:
|
if not fetch_observed:
|
||||||
return []
|
return []
|
||||||
@@ -1040,7 +977,7 @@ def _rule_reviewer_target_branch_freshness(
|
|||||||
fields = _handoff_fields(text)
|
fields = _handoff_fields(text)
|
||||||
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
|
||||||
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
|
||||||
for e in _iter_action_entries(action_log)
|
for e in (action_log or [])
|
||||||
)
|
)
|
||||||
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
|
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
|
||||||
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
|
||||||
@@ -1798,12 +1735,6 @@ def assess_final_report_validator(
|
|||||||
checks: dict[str, Any] = {}
|
checks: dict[str, Any] = {}
|
||||||
findings: list[dict[str, str]] = []
|
findings: list[dict[str, str]] = []
|
||||||
|
|
||||||
# #698: malformed action_log data must never crash validation with a
|
|
||||||
# secondary exception; malformed entries surface as sanitized findings.
|
|
||||||
sanitized_action_log, action_log_findings = sanitize_action_log(action_log)
|
|
||||||
action_log = sanitized_action_log
|
|
||||||
findings.extend(action_log_findings)
|
|
||||||
|
|
||||||
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
|
||||||
checks["issue_filing"] = assess_issue_filing_final_report(
|
checks["issue_filing"] = assess_issue_filing_final_report(
|
||||||
report_text,
|
report_text,
|
||||||
@@ -1832,23 +1763,7 @@ def assess_final_report_validator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||||
try:
|
findings.extend(_call_rule(rule, report_text, normalized_kind, rule_kwargs))
|
||||||
findings.extend(
|
|
||||||
_call_rule(rule, report_text, normalized_kind, rule_kwargs)
|
|
||||||
)
|
|
||||||
except Exception as exc: # #698: fail closed with a sanitized error
|
|
||||||
findings.append(
|
|
||||||
validator_finding(
|
|
||||||
"shared.validator_rule_error",
|
|
||||||
"block",
|
|
||||||
"Validator",
|
|
||||||
f"validator rule '{getattr(rule, '__name__', 'unknown')}' "
|
|
||||||
f"failed with {type(exc).__name__} (details withheld; "
|
|
||||||
"sanitized)",
|
|
||||||
"file a validator defect with the rule name; do not "
|
|
||||||
"bypass final-report validation",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
grade, blocked, downgraded = _aggregate_grade(findings)
|
grade, blocked, downgraded = _aggregate_grade(findings)
|
||||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"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"]
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-47
@@ -182,51 +182,11 @@ 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_explicit = getattr(args, "org", None) is not None
|
org = args.org or profile["org"]
|
||||||
repo_explicit = getattr(args, "repo", None) is not None
|
repo = args.repo or profile["repo"]
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -610,10 +570,6 @@ 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"),
|
|
||||||
"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,33 +475,6 @@ 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 _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:
|
||||||
@@ -576,7 +549,6 @@ 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"))
|
|
||||||
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.
|
||||||
|
|||||||
+154
-694
File diff suppressed because it is too large
Load Diff
@@ -86,22 +86,6 @@ _WRONG_BRANCH_RE = re.compile(
|
|||||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #698: canonical reviewer lease lifecycle operations are NOT post-merge
|
|
||||||
# cleanup. Releasing a reviewer PR lease (or posting its terminal
|
|
||||||
# phase=released marker) happens after every review — merged or not — and
|
|
||||||
# must never trigger the post-merge branch/worktree cleanup checklist.
|
|
||||||
_LEASE_LIFECYCLE_RE = re.compile(
|
|
||||||
r"(?:gitea_release_reviewer_pr_lease|gitea_abandon_workflow_lease|"
|
|
||||||
r"release[d]? (?:the )?(?:reviewer|workflow) (?:pr )?lease|"
|
|
||||||
r"reviewer (?:pr )?lease release[d]?|"
|
|
||||||
r"lease (?:marker|comment).{0,40}phase\s*[:=]\s*released|"
|
|
||||||
r"phase\s*[:=]\s*released)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_CLEANUP_MUTATIONS_VALUE_RE = re.compile(
|
|
||||||
r"cleanup mutations\s*:\s*([^\n]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _claims_remote_delete(text: str) -> bool:
|
def _claims_remote_delete(text: str) -> bool:
|
||||||
@@ -220,19 +204,16 @@ def assess_post_merge_cleanup_proof(
|
|||||||
for field in _worktree_cleanup_fields_present(text)
|
for field in _worktree_cleanup_fields_present(text)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||||
|
pass
|
||||||
|
|
||||||
if not remote_delete and not worktree_remove:
|
if not remote_delete and not worktree_remove:
|
||||||
value_match = _CLEANUP_MUTATIONS_VALUE_RE.search(text)
|
cleanup_mutations = re.search(
|
||||||
value = (value_match.group(1).strip() if value_match else "")
|
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||||
value_lower = value.lower()
|
text,
|
||||||
substantive = bool(value) and value_lower not in {
|
re.IGNORECASE,
|
||||||
"none", "n/a", "not applicable",
|
|
||||||
}
|
|
||||||
# #698: reviewer lease release / terminal lease markers are lease
|
|
||||||
# lifecycle, not post-merge cleanup — no checklist owed.
|
|
||||||
lease_lifecycle_only = substantive and bool(
|
|
||||||
_LEASE_LIFECYCLE_RE.search(value)
|
|
||||||
)
|
)
|
||||||
if substantive and not lease_lifecycle_only:
|
if cleanup_mutations:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-67
@@ -403,37 +403,10 @@ _REVIEWER_ACTIVE_RE = re.compile(
|
|||||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #698 phase detection for phase-specific head proofs.
|
|
||||||
_NO_REVIEWED_HEAD_RE = re.compile(
|
|
||||||
r"(?:reviewed head sha|candidate head sha)\s*:\s*none\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_VERDICT_RECORDED_RE = re.compile(
|
|
||||||
r"review decision\s*:\s*(?:approve[d]?|request[_ ]changes)\b"
|
|
||||||
r"|review_status\s*:\s*(?:approved|request_changes)\b"
|
|
||||||
r"|terminal review mutation\s*:\s*(?!none\b)\S",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_MERGE_ATTEMPTED_RE = re.compile(
|
|
||||||
r"merge result\s*:\s*(?:merged|success|performed|failed|attempted)\b"
|
|
||||||
r"|merge mutations\s*:\s*(?!none\b|not applicable\b)\S",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_VALIDATION_STARTED_RE = re.compile(
|
|
||||||
r"validation\s*:\s*(?!none\b|not run\b|not applicable\b|not started\b)"
|
|
||||||
r"[^\n]*(?:pass|fail|ran|executed|\d+\s+passed)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||||
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6).
|
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
|
||||||
|
|
||||||
#698: head proofs are phase-specific. A legitimately blocked run that
|
|
||||||
never began validation (no reviewed head, no formal verdict, no merge)
|
|
||||||
owes none of them; approval-time and merge-time live-head proofs are
|
|
||||||
owed only once the corresponding phase actually begins.
|
|
||||||
"""
|
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||||
@@ -449,49 +422,15 @@ def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||||
|
|
||||||
# Phase detection from the report's own claims.
|
if not reviewed:
|
||||||
no_head_stated = bool(_NO_REVIEWED_HEAD_RE.search(text))
|
|
||||||
verdict_recorded = bool(_VERDICT_RECORDED_RE.search(text))
|
|
||||||
merge_attempted = bool(_MERGE_ATTEMPTED_RE.search(text))
|
|
||||||
validation_started = bool(reviewed) or bool(_VALIDATION_STARTED_RE.search(text))
|
|
||||||
blocked_before_validation = (
|
|
||||||
no_head_stated
|
|
||||||
and not reviewed
|
|
||||||
and not verdict_recorded
|
|
||||||
and not merge_attempted
|
|
||||||
and not validation_started
|
|
||||||
)
|
|
||||||
|
|
||||||
if blocked_before_validation:
|
|
||||||
return {
|
|
||||||
"proven": True,
|
|
||||||
"block": False,
|
|
||||||
"reasons": [],
|
|
||||||
"reviewed_head_sha": None,
|
|
||||||
"live_head_sha_before_approval": None,
|
|
||||||
"live_head_sha_before_merge": None,
|
|
||||||
"push_during_validation": (
|
|
||||||
push_during.group(1).lower() if push_during else None
|
|
||||||
),
|
|
||||||
"phase": "blocked_before_validation",
|
|
||||||
}
|
|
||||||
|
|
||||||
if not reviewed and not no_head_stated:
|
|
||||||
# The head must always be STATED — either a SHA or an explicit
|
|
||||||
# 'none'. Silence is not a phase claim and fails closed.
|
|
||||||
reasons.append(
|
|
||||||
"reviewed head SHA not stated in final report "
|
|
||||||
"(state the SHA or an explicit 'none')"
|
|
||||||
)
|
|
||||||
elif not reviewed and (validation_started or verdict_recorded or merge_attempted):
|
|
||||||
reasons.append("reviewed head SHA not stated in final report")
|
reasons.append("reviewed head SHA not stated in final report")
|
||||||
if verdict_recorded and not live_approval:
|
if not live_approval:
|
||||||
reasons.append("final live head SHA before approval not stated")
|
reasons.append("final live head SHA before approval not stated")
|
||||||
if merge_attempted and not live_merge:
|
if not live_merge:
|
||||||
reasons.append("final live head SHA before merge not stated")
|
reasons.append("final live head SHA before merge not stated")
|
||||||
if validation_started and not push_during:
|
if not push_during:
|
||||||
reasons.append("whether push occurred during validation not stated")
|
reasons.append("whether push occurred during validation not stated")
|
||||||
if reviewed and live_approval and reviewed != live_approval:
|
elif reviewed and live_approval and reviewed != live_approval:
|
||||||
reasons.append("live head before approval differs from reviewed head SHA")
|
reasons.append("live head before approval differs from reviewed head SHA")
|
||||||
elif reviewed and live_merge and reviewed != live_merge:
|
elif reviewed and live_merge and reviewed != live_merge:
|
||||||
reasons.append("live head before merge differs from reviewed head SHA")
|
reasons.append("live head before merge differs from reviewed head SHA")
|
||||||
|
|||||||
@@ -25,13 +25,8 @@ _REVIEWED_HEAD_RE = re.compile(
|
|||||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# #698: validation pass proof appears in several legitimate shapes —
|
|
||||||
# "Validation: pass", "Validation: focused 50 passed; full 2665 passed",
|
|
||||||
# or structured "validation_status: pass". Accept pass evidence anywhere in
|
|
||||||
# the Validation field's value, not only as its first token.
|
|
||||||
_VALIDATION_PASS_RE = re.compile(
|
_VALIDATION_PASS_RE = re.compile(
|
||||||
r"validation(?:_status)?\s*:[^\n]{0,300}?"
|
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
|
||||||
r"(?:\bpass(?:ed)?\b|\bstrong\b|\bok\b|\bgreen\b|\d+\s+passed)",
|
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_MERGED_CLAIM_RE = re.compile(
|
_MERGED_CLAIM_RE = re.compile(
|
||||||
|
|||||||
+9
-36
@@ -858,16 +858,9 @@ _WALKTHROUGH_ARTIFACT_RE = re.compile(r"walkthrough\.md", re.I)
|
|||||||
|
|
||||||
|
|
||||||
def _performed_file_mutations(action_log: list[dict] | None) -> list[dict]:
|
def _performed_file_mutations(action_log: list[dict] | None) -> list[dict]:
|
||||||
"""Return performed local file mutations, excluding gated rejections.
|
"""Return performed local file mutations, excluding gated rejections."""
|
||||||
|
|
||||||
Non-dict entries (malformed JSON, LLM mistakes) are ignored instead of
|
|
||||||
raising ``AttributeError`` (#698): a malformed ledger entry can never be
|
|
||||||
authoritative mutation evidence.
|
|
||||||
"""
|
|
||||||
performed: list[dict] = []
|
performed: list[dict] = []
|
||||||
for entry in action_log or []:
|
for entry in action_log or []:
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
if entry.get("gated_rejected") or entry.get("performed") is False:
|
if entry.get("gated_rejected") or entry.get("performed") is False:
|
||||||
continue
|
continue
|
||||||
action = (entry.get("action") or "").strip().lower()
|
action = (entry.get("action") or "").strip().lower()
|
||||||
@@ -2235,31 +2228,24 @@ HANDOFF_REVIEW_MUTATION_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
HANDOFF_ROLE_FIELDS = {
|
HANDOFF_ROLE_FIELDS = {
|
||||||
# #698: the review/merger required-field sets must stay aligned with the
|
|
||||||
# canonical schema (skills/llm-project-workflow/schemas/
|
|
||||||
# review-merge-final-report.md). The schema explicitly FORBIDS the legacy
|
|
||||||
# fields 'Pinned reviewed head', 'Scratch worktree used', and 'Workspace
|
|
||||||
# mutations' — a validator must never demand a field the schema bans.
|
|
||||||
"review": (
|
"review": (
|
||||||
("Selected PR", ("selected pr",)),
|
("Selected PR", ("selected pr",)),
|
||||||
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
||||||
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||||
("Review worktree path", ("review worktree path", "worktree path",
|
("Worktree path", ("worktree path", "starting worktree path")),
|
||||||
"starting worktree path")),
|
("Worktree dirty", ("worktree dirty", "whether worktree was dirty")),
|
||||||
("Review worktree dirty", ("review worktree dirty", "worktree dirty",
|
("Scratch worktree used", ("scratch worktree used", "scratch clone used",
|
||||||
"whether worktree was dirty")),
|
"scratch worktree")),
|
||||||
("Unrelated local mutations", ("unrelated local mutations",
|
("Unrelated local mutations", ("unrelated local mutations",
|
||||||
"unrelated files modified",
|
"unrelated files modified")),
|
||||||
"file edits by reviewer")),
|
|
||||||
("Review decision", ("review decision", "decision")),
|
("Review decision", ("review decision", "decision")),
|
||||||
("Merge result", ("merge result",)),
|
("Merge result", ("merge result",)),
|
||||||
("Linked issue status", ("linked issue status", "linked issue")),
|
("Linked issue status", ("linked issue status", "linked issue")),
|
||||||
("Cleanup status", ("cleanup status", "cleanup")),
|
("Cleanup status", ("cleanup status", "cleanup")),
|
||||||
("Safe next action", ("safe next action", "next")),
|
|
||||||
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
) + HANDOFF_REVIEW_MUTATION_FIELDS,
|
||||||
"merger": (
|
"merger": (
|
||||||
("Selected PR", ("selected pr",)),
|
("Selected PR", ("selected pr",)),
|
||||||
("Reviewed head SHA", ("reviewed head sha", "candidate head sha")),
|
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||||||
("Active profile", ("active profile",)),
|
("Active profile", ("active profile",)),
|
||||||
("Role kind", ("role kind",)),
|
("Role kind", ("role kind",)),
|
||||||
("Merge capability source", ("merge capability source",)),
|
("Merge capability source", ("merge capability source",)),
|
||||||
@@ -2447,22 +2433,9 @@ def assess_controller_handoff(report_text, role=None, local_edits=False):
|
|||||||
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
# Issue #320: reviewer and merger handoffs use the precise mutation categories
|
||||||
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
# in HANDOFF_REVIEW_MUTATION_FIELDS instead of the legacy ambiguous
|
||||||
# "Workspace mutations" field, which is rejected below.
|
# "Workspace mutations" field, which is rejected below.
|
||||||
# Issue #698: the canonical review-merge schema has no 'Mutations',
|
|
||||||
# 'Next', 'Issue/PR', 'Branch/SHA', or 'Files changed' fields — their
|
|
||||||
# content lives in the precise mutation categories, 'Safe next
|
|
||||||
# action', 'Selected PR'/'Linked issue', head-SHA fields, and 'Files
|
|
||||||
# reviewed'. Requiring the legacy names rejects canonical reports.
|
|
||||||
_non_canonical_for_review = {
|
|
||||||
"Workspace mutations",
|
|
||||||
"Mutations",
|
|
||||||
"Next",
|
|
||||||
"Issue/PR",
|
|
||||||
"Branch/SHA",
|
|
||||||
"Files changed",
|
|
||||||
}
|
|
||||||
required = [
|
required = [
|
||||||
field for field in required
|
field for field in required
|
||||||
if field[0] not in _non_canonical_for_review
|
if field[0] != "Workspace mutations"
|
||||||
]
|
]
|
||||||
if any(label.startswith("workspace mutations") for label in labels):
|
if any(label.startswith("workspace mutations") for label in labels):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 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",
|
|
||||||
) -> 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
) -> 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(),
|
|
||||||
)
|
|
||||||
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",
|
|
||||||
) -> 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,
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
|
|
||||||
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)"
|
|
||||||
)
|
|
||||||
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
+15
-47
@@ -26,14 +26,6 @@ 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",
|
||||||
@@ -88,15 +80,9 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
_state_tmp.cleanup()
|
||||||
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", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
@@ -127,37 +113,19 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
capability_stop_terminal.clear()
|
capability_stop_terminal.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
yield
|
||||||
try:
|
try:
|
||||||
yield
|
import capability_stop_terminal
|
||||||
finally:
|
capability_stop_terminal.clear()
|
||||||
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield
|
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()
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
"""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,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -69,9 +65,11 @@ 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 = shared_mutation_env(
|
ISSUE_WRITE_ENV = {
|
||||||
"test-author-prgs",
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
)
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
|
|||||||
+16
-46
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -165,23 +161,11 @@ class _AuditWiringBase(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _env(self, **extra):
|
def _env(self, **extra):
|
||||||
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
||||||
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
"GITEA_PROFILE_NAME", None
|
"read,merge,gitea.issue.create,gitea.issue.close")}
|
||||||
) 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):
|
||||||
@@ -212,7 +196,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.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
self.assertEqual(rec["profile_name"], "gitea-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")
|
||||||
@@ -255,11 +239,10 @@ 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(
|
with patch.dict(os.environ, {
|
||||||
os.environ,
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
shared_mutation_env("test-author-prgs"),
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||||
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"
|
||||||
@@ -359,7 +342,8 @@ 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_MCP_PROFILE="test-merger-prgs")
|
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||||
|
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",
|
||||||
@@ -378,7 +362,8 @@ 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_MCP_PROFILE="test-merger-prgs")
|
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||||
|
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")
|
||||||
@@ -400,23 +385,11 @@ 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_MCP_PROFILE="test-reviewer-prgs")
|
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||||
|
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",
|
||||||
)
|
)
|
||||||
@@ -425,10 +398,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
body="LGTM", remote="prgs",
|
body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(r["performed"])
|
||||||
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")
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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,
|
||||||
@@ -40,7 +39,6 @@ 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,
|
||||||
@@ -53,7 +51,6 @@ 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,7 +35,6 @@ 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,
|
||||||
@@ -50,7 +49,6 @@ 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,7 +35,6 @@ CONFIG = {
|
|||||||
"gitea.pr.review",
|
"gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -228,7 +227,6 @@ 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)
|
||||||
|
|
||||||
@@ -250,7 +248,6 @@ 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(
|
||||||
@@ -269,7 +266,6 @@ 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,7 +1,3 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
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
|
||||||
@@ -13,7 +12,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import patch, MagicMock, patch
|
from unittest.mock import 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))
|
||||||
@@ -93,26 +92,6 @@ 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)
|
||||||
@@ -163,7 +142,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/Gitea-Tools/issues",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-23
@@ -1,4 +1,3 @@
|
|||||||
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
|
||||||
@@ -9,7 +8,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import patch, MagicMock, patch
|
from unittest.mock import 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
|
||||||
@@ -75,26 +74,6 @@ 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):
|
||||||
@@ -134,7 +113,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/Gitea-Tools/pulls",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
"""Regression tests for #698: final-report validator vs canonical schema.
|
|
||||||
|
|
||||||
Covers the original #698 lead plus the independent reproduction recorded
|
|
||||||
during the PR #703 formal review (issue #698 comment 11246):
|
|
||||||
|
|
||||||
1. non-dict ``action_log`` entries must fail structured, never crash;
|
|
||||||
2. the validator must not demand legacy fields the canonical schema forbids
|
|
||||||
(``Pinned reviewed head``, ``Scratch worktree used``, ``Worktree path``,
|
|
||||||
``Worktree dirty``, ``Mutations``, ``Next``);
|
|
||||||
3. a legitimately blocked report (``Candidate head SHA: none``, no formal
|
|
||||||
verdict) must not owe approval/merge live-head proofs;
|
|
||||||
4. canonical reviewer lease release must not be misclassified as post-merge
|
|
||||||
cleanup;
|
|
||||||
5. structured workflow-load and validation proof must be recognized;
|
|
||||||
6. review mutations are inferred only from authoritative evidence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
import final_report_validator as frv # noqa: E402
|
|
||||||
import post_merge_cleanup_proof as pmcp # noqa: E402
|
|
||||||
import pr_work_lease as pwl # noqa: E402
|
|
||||||
import review_proofs as rp # noqa: E402
|
|
||||||
from review_final_report_schema import ( # noqa: E402
|
|
||||||
assess_review_final_report_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
REVIEWED_HEAD = "a" * 40
|
|
||||||
LIVE_HEAD = "a" * 40
|
|
||||||
|
|
||||||
|
|
||||||
def _pr703_style_report(
|
|
||||||
*,
|
|
||||||
decision: str = "request_changes",
|
|
||||||
cleanup_mutations: str = (
|
|
||||||
"released reviewer PR lease via gitea_release_reviewer_pr_lease "
|
|
||||||
"(terminal lease marker phase=released posted)"
|
|
||||||
),
|
|
||||||
) -> str:
|
|
||||||
"""Canonical-schema report modeled on the PR #703 formal review handoff."""
|
|
||||||
return f"""
|
|
||||||
Formal review completed with a REQUEST_CHANGES verdict submitted and read
|
|
||||||
back via the native review API.
|
|
||||||
|
|
||||||
## Controller Handoff
|
|
||||||
|
|
||||||
- Task: review-merge-pr
|
|
||||||
- Repo: Scaled-Tech-Consulting/Gitea-Tools
|
|
||||||
- Role: reviewer
|
|
||||||
- Identity: sysadmin / prgs-reviewer
|
|
||||||
- Active profile: prgs-reviewer
|
|
||||||
- Runtime context: neutral workspace binding
|
|
||||||
- Selected PR: 703
|
|
||||||
- Linked issue: #702 open
|
|
||||||
- Eligibility class: reviewable
|
|
||||||
- Queue ordering policy: oldest eligible first
|
|
||||||
- Inventory pagination proof: has_more=false, total_count=8
|
|
||||||
- Earlier PRs skipped: none
|
|
||||||
- Candidate head SHA: {REVIEWED_HEAD}
|
|
||||||
- Reviewed head SHA: {REVIEWED_HEAD}
|
|
||||||
- Target branch: master
|
|
||||||
- Target branch SHA: {"2" * 40}
|
|
||||||
- Already-landed gate: not landed
|
|
||||||
- Author-safety result: pass (author differs from reviewer)
|
|
||||||
- Prior request-changes state: none
|
|
||||||
- Review worktree used: true
|
|
||||||
- Review worktree path: branches/review-pr-703-independent
|
|
||||||
- Review worktree inside branches: true
|
|
||||||
- Review worktree HEAD state: detached at pinned head
|
|
||||||
- Review worktree dirty before validation: clean
|
|
||||||
- Review worktree dirty after validation: clean
|
|
||||||
- Baseline worktree used: false
|
|
||||||
- Baseline worktree path: none
|
|
||||||
- Files reviewed: 4
|
|
||||||
- Validation: focused 50 passed; related 94 passed; full 2665 passed, 6 skipped
|
|
||||||
- Official validation integrity status: intact
|
|
||||||
- Terminal review mutation: one REQUEST_CHANGES review submitted and read back
|
|
||||||
- Review decision: {decision}
|
|
||||||
- Merge preflight: not run
|
|
||||||
- Merge result: none
|
|
||||||
- Linked issue status: open (live fetch proof: gitea_view_issue)
|
|
||||||
- Main checkout branch: master
|
|
||||||
- Main checkout dirty state: clean
|
|
||||||
- Main checkout updated: false
|
|
||||||
- File edits by reviewer: none
|
|
||||||
- Worktree/index mutations: none
|
|
||||||
- Git ref mutations: git fetch prgs (recorded)
|
|
||||||
- MCP/Gitea mutations: review submission and lease comments only
|
|
||||||
- Review mutations: one formal REQUEST_CHANGES verdict
|
|
||||||
- Merge mutations: none
|
|
||||||
- Cleanup mutations: {cleanup_mutations}
|
|
||||||
- External-state mutations: none
|
|
||||||
- Read-only diagnostics: gitea_view_pr, gitea_get_pr_review_feedback
|
|
||||||
- Blockers: findings F1-F6 recorded on the PR thread
|
|
||||||
- Current status: review complete; author remediation required
|
|
||||||
- Safe next action: author addresses findings and pushes a new head
|
|
||||||
- Safety statement: no merge attempted; no self-review; no root-checkout edits
|
|
||||||
- Workflow-load helper result: workflow_hash=da045d1e1f1f boundary_status=clean
|
|
||||||
- Live head SHA before approval: {LIVE_HEAD}
|
|
||||||
- Pushes occurred during validation: no
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _blocked_preflight_report() -> str:
|
|
||||||
"""Blocked-run report modeled on the #702 comment 11164 reproduction."""
|
|
||||||
return """
|
|
||||||
Fresh review preflight stopped before any worktree or validation work.
|
|
||||||
|
|
||||||
## Controller Handoff
|
|
||||||
|
|
||||||
- Task: review-merge-pr
|
|
||||||
- Repo: Scaled-Tech-Consulting/Gitea-Tools
|
|
||||||
- Role: reviewer
|
|
||||||
- Identity: sysadmin / prgs-reviewer
|
|
||||||
- Active profile: prgs-reviewer
|
|
||||||
- Runtime context: stale workspace binding detected
|
|
||||||
- Selected PR: 701
|
|
||||||
- Linked issue: #699 open
|
|
||||||
- Eligibility class: blocked-before-validation
|
|
||||||
- Queue ordering policy: oldest eligible first
|
|
||||||
- Inventory pagination proof: has_more=false, total_count=8
|
|
||||||
- Earlier PRs skipped: none
|
|
||||||
- Candidate head SHA: none
|
|
||||||
- Reviewed head SHA: none
|
|
||||||
- Target branch: master
|
|
||||||
- Target branch SHA: none
|
|
||||||
- Already-landed gate: not run
|
|
||||||
- Author-safety result: not run
|
|
||||||
- Prior request-changes state: none
|
|
||||||
- Review worktree used: false
|
|
||||||
- Review worktree path: none
|
|
||||||
- Review worktree inside branches: not applicable
|
|
||||||
- Review worktree HEAD state: not applicable
|
|
||||||
- Review worktree dirty before validation: not applicable
|
|
||||||
- Review worktree dirty after validation: not applicable
|
|
||||||
- Baseline worktree used: false
|
|
||||||
- Baseline worktree path: none
|
|
||||||
- Files reviewed: 0
|
|
||||||
- Validation: not run
|
|
||||||
- Official validation integrity status: not applicable
|
|
||||||
- Terminal review mutation: none
|
|
||||||
- Review decision: none
|
|
||||||
- Merge preflight: not run
|
|
||||||
- Merge result: none
|
|
||||||
- Linked issue status: open (live fetch proof: gitea_view_issue)
|
|
||||||
- Main checkout branch: master
|
|
||||||
- Main checkout dirty state: clean
|
|
||||||
- Main checkout updated: false
|
|
||||||
- File edits by reviewer: none
|
|
||||||
- Worktree/index mutations: none
|
|
||||||
- Git ref mutations: none
|
|
||||||
- MCP/Gitea mutations: none
|
|
||||||
- Review mutations: none
|
|
||||||
- Merge mutations: none
|
|
||||||
- Cleanup mutations: none
|
|
||||||
- External-state mutations: none
|
|
||||||
- Read-only diagnostics: gitea_view_pr, gitea_get_runtime_context
|
|
||||||
- Blockers: runtime bound to a foreign task worktree; mutation prohibited
|
|
||||||
- Current status: stopped before validation began
|
|
||||||
- Next actor: operator
|
|
||||||
- Next action: repair the runtime workspace binding, then rerun the full
|
|
||||||
review workflow in a fresh reviewer session
|
|
||||||
- Next prompt: Act as REVIEWER for PR 701 after the operator repairs the
|
|
||||||
runtime binding; acquire the lease before any validation.
|
|
||||||
- Safe next action: operator repairs runtime binding, then a fresh reviewer
|
|
||||||
reruns the full workflow
|
|
||||||
- Safety statement: no lease acquired; no verdict recorded; no source edits
|
|
||||||
- Workflow-load helper result: workflow_hash=da045d1e1f1f boundary_status=clean
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TestActionLogRobustness(unittest.TestCase):
|
|
||||||
"""#698 original lead: non-dict action_log must not crash validation."""
|
|
||||||
|
|
||||||
MALFORMED = [
|
|
||||||
"git fetch prgs",
|
|
||||||
42,
|
|
||||||
None,
|
|
||||||
{"action": "edit", "path": "x.py", "performed": True, "tracked": True},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_assess_final_report_validator_survives_malformed_entries(self):
|
|
||||||
result = frv.assess_final_report_validator(
|
|
||||||
_pr703_style_report(),
|
|
||||||
"review_pr",
|
|
||||||
action_log=self.MALFORMED,
|
|
||||||
)
|
|
||||||
self.assertIsInstance(result, dict)
|
|
||||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
|
||||||
self.assertIn("shared.action_log_malformed", rule_ids)
|
|
||||||
|
|
||||||
def test_malformed_entry_errors_are_sanitized(self):
|
|
||||||
_entries, findings = frv.sanitize_action_log(["secret-token-abc123"])
|
|
||||||
self.assertEqual(len(findings), 1)
|
|
||||||
reason = findings[0]["reason"]
|
|
||||||
self.assertNotIn("secret-token-abc123", reason)
|
|
||||||
self.assertIn("str", reason)
|
|
||||||
self.assertIn("entry 0", reason)
|
|
||||||
|
|
||||||
def test_non_list_action_log_is_reported_not_raised(self):
|
|
||||||
entries, findings = frv.sanitize_action_log("not-a-list")
|
|
||||||
self.assertEqual(entries, [])
|
|
||||||
self.assertEqual(len(findings), 1)
|
|
||||||
self.assertIn("not a list", findings[0]["reason"])
|
|
||||||
|
|
||||||
def test_performed_file_mutations_skips_non_dict_entries(self):
|
|
||||||
performed = rp._performed_file_mutations(
|
|
||||||
["oops", {"action": "edited", "path": "a.py"}]
|
|
||||||
)
|
|
||||||
self.assertEqual(len(performed), 1)
|
|
||||||
self.assertEqual(performed[0]["path"], "a.py")
|
|
||||||
|
|
||||||
def test_schema_entrypoint_survives_string_only_log(self):
|
|
||||||
result = assess_review_final_report_schema(
|
|
||||||
_pr703_style_report(),
|
|
||||||
action_log=["just a string", "another string"],
|
|
||||||
)
|
|
||||||
self.assertIsInstance(result, dict)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyFieldRequirementsRemoved(unittest.TestCase):
|
|
||||||
"""#698: prohibited legacy fields must not be REQUIRED of reports."""
|
|
||||||
|
|
||||||
PROHIBITED = (
|
|
||||||
"Pinned reviewed head",
|
|
||||||
"Scratch worktree used",
|
|
||||||
"Worktree path",
|
|
||||||
"Worktree dirty",
|
|
||||||
"Workspace mutations",
|
|
||||||
"Mutations",
|
|
||||||
"Next",
|
|
||||||
"Issue/PR",
|
|
||||||
"Branch/SHA",
|
|
||||||
"Files changed",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_review_role_field_table_has_no_prohibited_requirements(self):
|
|
||||||
names = [name for name, _ in rp.HANDOFF_ROLE_FIELDS["review"]]
|
|
||||||
for prohibited in ("Pinned reviewed head", "Scratch worktree used",
|
|
||||||
"Worktree path", "Worktree dirty"):
|
|
||||||
self.assertNotIn(prohibited, names)
|
|
||||||
|
|
||||||
def test_merger_role_field_table_has_no_pinned_reviewed_head(self):
|
|
||||||
names = [name for name, _ in rp.HANDOFF_ROLE_FIELDS["merger"]]
|
|
||||||
self.assertNotIn("Pinned reviewed head", names)
|
|
||||||
|
|
||||||
def test_canonical_report_missing_fields_never_include_prohibited(self):
|
|
||||||
result = rp.assess_controller_handoff(
|
|
||||||
_pr703_style_report(), role="review"
|
|
||||||
)
|
|
||||||
for prohibited in self.PROHIBITED:
|
|
||||||
self.assertNotIn(prohibited, result.get("missing_fields") or [])
|
|
||||||
|
|
||||||
def test_canonical_pr703_report_satisfies_required_fields(self):
|
|
||||||
result = rp.assess_controller_handoff(
|
|
||||||
_pr703_style_report(), role="review"
|
|
||||||
)
|
|
||||||
self.assertEqual(result.get("missing_fields") or [], [])
|
|
||||||
self.assertEqual(result.get("verdict"), "complete")
|
|
||||||
|
|
||||||
|
|
||||||
class TestBlockedReportAccepted(unittest.TestCase):
|
|
||||||
"""#698: blocked run with no reviewed head / verdict is legitimate."""
|
|
||||||
|
|
||||||
def test_stale_head_proof_waived_before_validation(self):
|
|
||||||
result = pwl.assess_reviewer_stale_head_final_report(
|
|
||||||
_blocked_preflight_report()
|
|
||||||
)
|
|
||||||
self.assertTrue(result["proven"])
|
|
||||||
self.assertEqual(result.get("phase"), "blocked_before_validation")
|
|
||||||
|
|
||||||
def test_blocked_report_passes_schema_validation(self):
|
|
||||||
result = assess_review_final_report_schema(_blocked_preflight_report())
|
|
||||||
blocking = [
|
|
||||||
f for f in result["findings"] if f["severity"] == "block"
|
|
||||||
]
|
|
||||||
self.assertEqual(blocking, [], blocking)
|
|
||||||
|
|
||||||
def test_verdict_phase_still_demands_approval_head_proof(self):
|
|
||||||
report = _blocked_preflight_report().replace(
|
|
||||||
"- Review decision: none",
|
|
||||||
"- Review decision: approve",
|
|
||||||
).replace(
|
|
||||||
"- Candidate head SHA: none",
|
|
||||||
f"- Candidate head SHA: {REVIEWED_HEAD}",
|
|
||||||
)
|
|
||||||
result = pwl.assess_reviewer_stale_head_final_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
joined = " ".join(result["reasons"])
|
|
||||||
self.assertIn("before approval", joined)
|
|
||||||
|
|
||||||
def test_merge_phase_still_demands_merge_head_proof(self):
|
|
||||||
report = _pr703_style_report().replace(
|
|
||||||
"- Merge result: none",
|
|
||||||
"- Merge result: merged",
|
|
||||||
)
|
|
||||||
result = pwl.assess_reviewer_stale_head_final_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertIn(
|
|
||||||
"final live head SHA before merge not stated",
|
|
||||||
result["reasons"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validation_phase_demands_push_disclosure(self):
|
|
||||||
report = _pr703_style_report().replace(
|
|
||||||
"- Pushes occurred during validation: no\n", ""
|
|
||||||
)
|
|
||||||
result = pwl.assess_reviewer_stale_head_final_report(report)
|
|
||||||
self.assertFalse(result["proven"])
|
|
||||||
self.assertIn(
|
|
||||||
"whether push occurred during validation not stated",
|
|
||||||
result["reasons"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLeaseReleaseVsPostMergeCleanup(unittest.TestCase):
|
|
||||||
"""#698 (PR #703 review reproduction): lease release is not cleanup."""
|
|
||||||
|
|
||||||
def test_lease_release_cleanup_mutations_do_not_demand_checklist(self):
|
|
||||||
result = pmcp.assess_post_merge_cleanup_proof(_pr703_style_report())
|
|
||||||
self.assertFalse(result["block"], result["reasons"])
|
|
||||||
|
|
||||||
def test_release_tool_name_alone_is_recognized(self):
|
|
||||||
report = _pr703_style_report(
|
|
||||||
cleanup_mutations="gitea_release_reviewer_pr_lease comment 11244"
|
|
||||||
)
|
|
||||||
result = pmcp.assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertFalse(result["block"], result["reasons"])
|
|
||||||
|
|
||||||
def test_substantive_non_lease_cleanup_still_demands_checklist(self):
|
|
||||||
report = _pr703_style_report(
|
|
||||||
cleanup_mutations="deleted stale scratch directory manually"
|
|
||||||
)
|
|
||||||
result = pmcp.assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
|
|
||||||
def test_remote_branch_delete_claims_still_demand_full_proof(self):
|
|
||||||
report = _pr703_style_report(
|
|
||||||
cleanup_mutations="gitea_delete_branch removed the remote branch"
|
|
||||||
)
|
|
||||||
result = pmcp.assess_post_merge_cleanup_proof(report)
|
|
||||||
self.assertTrue(result["block"])
|
|
||||||
self.assertTrue(
|
|
||||||
any("remote branch deletion missing" in r for r in result["reasons"])
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_full_schema_run_accepts_lease_release_report(self):
|
|
||||||
result = assess_review_final_report_schema(_pr703_style_report())
|
|
||||||
lease_cleanup_blocks = [
|
|
||||||
f for f in result["findings"]
|
|
||||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
|
||||||
]
|
|
||||||
self.assertEqual(lease_cleanup_blocks, [], lease_cleanup_blocks)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStructuredProofRecognition(unittest.TestCase):
|
|
||||||
"""#698: structured workflow-load and validation proof must be accepted."""
|
|
||||||
|
|
||||||
def test_key_value_workflow_proof_recognized(self):
|
|
||||||
findings = frv._rule_reviewer_workflow_load_boundary(
|
|
||||||
_pr703_style_report()
|
|
||||||
)
|
|
||||||
self.assertEqual(findings, [], findings)
|
|
||||||
|
|
||||||
def test_colon_form_workflow_proof_still_recognized(self):
|
|
||||||
report = _pr703_style_report().replace(
|
|
||||||
"- Workflow-load helper result: workflow_hash=da045d1e1f1f "
|
|
||||||
"boundary_status=clean",
|
|
||||||
"- Workflow-load helper result: workflow_hash: da045d1e1f1f, "
|
|
||||||
"boundary_status: clean",
|
|
||||||
)
|
|
||||||
findings = frv._rule_reviewer_workflow_load_boundary(report)
|
|
||||||
self.assertEqual(findings, [], findings)
|
|
||||||
|
|
||||||
def test_incomplete_structured_proof_still_blocks(self):
|
|
||||||
report = _pr703_style_report().replace(
|
|
||||||
"workflow_hash=da045d1e1f1f boundary_status=clean",
|
|
||||||
"workflow_hash=da045d1e1f1f",
|
|
||||||
)
|
|
||||||
findings = frv._rule_reviewer_workflow_load_boundary(report)
|
|
||||||
self.assertTrue(findings)
|
|
||||||
self.assertIn("boundary_status", findings[0]["reason"])
|
|
||||||
|
|
||||||
def test_validation_counts_accepted_as_pass_proof(self):
|
|
||||||
# "Validation: focused 50 passed; ..." must satisfy the reviewed-head
|
|
||||||
# validation-proof rule (PR #703 reproduction).
|
|
||||||
result = assess_review_final_report_schema(_pr703_style_report())
|
|
||||||
head_blocks = [
|
|
||||||
f for f in result["findings"]
|
|
||||||
if f["rule_id"] == "reviewer.reviewed_head_without_validation"
|
|
||||||
]
|
|
||||||
self.assertEqual(head_blocks, [], head_blocks)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthoritativeMutationInference(unittest.TestCase):
|
|
||||||
"""#698: review mutations inferred only from authoritative evidence."""
|
|
||||||
|
|
||||||
def test_read_only_entries_do_not_imply_mutations(self):
|
|
||||||
report = "## Controller Handoff\n- Mutations: none\n"
|
|
||||||
findings = frv._rule_reviewer_vague_mutations_none(
|
|
||||||
report,
|
|
||||||
action_log=[
|
|
||||||
{"action": "gitea_view_pr"},
|
|
||||||
{"action": "gitea_get_pr_review_feedback", "performed": False},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.assertEqual(findings, [], findings)
|
|
||||||
|
|
||||||
def test_performed_mutation_still_blocks_vague_none(self):
|
|
||||||
report = "## Controller Handoff\n- Mutations: none\n"
|
|
||||||
findings = frv._rule_reviewer_vague_mutations_none(
|
|
||||||
report,
|
|
||||||
action_log=[{"action": "edit", "path": "a.py", "performed": True}],
|
|
||||||
)
|
|
||||||
self.assertTrue(findings)
|
|
||||||
|
|
||||||
def test_gated_rejection_is_not_a_mutation(self):
|
|
||||||
report = "## Controller Handoff\n- Mutations: none\n"
|
|
||||||
findings = frv._rule_reviewer_vague_mutations_none(
|
|
||||||
report,
|
|
||||||
action_log=[
|
|
||||||
{"action": "edit", "path": "a.py", "performed": True,
|
|
||||||
"gated_rejected": True},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.assertEqual(findings, [], findings)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidatorRuleErrorContainment(unittest.TestCase):
|
|
||||||
"""#698: a defective rule fails closed with a sanitized error."""
|
|
||||||
|
|
||||||
def test_rule_exception_becomes_sanitized_block_finding(self):
|
|
||||||
def _boom(report_text):
|
|
||||||
raise ValueError("raw secret detail that must not leak")
|
|
||||||
|
|
||||||
original = frv._RULES_BY_TASK["review_pr"]
|
|
||||||
frv._RULES_BY_TASK["review_pr"] = [_boom]
|
|
||||||
try:
|
|
||||||
result = frv.assess_final_report_validator(
|
|
||||||
"report body", "review_pr"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
frv._RULES_BY_TASK["review_pr"] = original
|
|
||||||
self.assertTrue(result["blocked"])
|
|
||||||
finding = next(
|
|
||||||
f for f in result["findings"]
|
|
||||||
if f["rule_id"] == "shared.validator_rule_error"
|
|
||||||
)
|
|
||||||
self.assertNotIn("raw secret detail", finding["reason"])
|
|
||||||
self.assertIn("ValueError", finding["reason"])
|
|
||||||
self.assertIn("_boom", finding["reason"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
"""#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()
|
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
"""#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
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
mcp_server.gitea_resolve_task_capability(
|
|
||||||
task="reopen_issue", remote="dadeschools"
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
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,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -19,10 +15,9 @@ 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 = shared_mutation_env(
|
ISSUE_WRITE_ENV = {
|
||||||
"test-author-prgs",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||||
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeAndPointers(unittest.TestCase):
|
class TestNormalizeAndPointers(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -23,10 +19,9 @@ 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 = shared_mutation_env(
|
ISSUE_WRITE_ENV = {
|
||||||
"test-author-prgs",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
||||||
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"
|
||||||
@@ -167,7 +162,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", remote="prgs")
|
result = gitea_create_issue(title="Unique new issue", body="body text")
|
||||||
self.assertEqual(result["number"], 1)
|
self.assertEqual(result["number"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -148,12 +144,10 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = shared_mutation_env(
|
with patch.dict(os.environ, {
|
||||||
"test-author-prgs",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||||
include_example_repo=True,
|
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
||||||
GITEA_ISSUE_LOCK_DIR=lock_dir,
|
}, clear=True):
|
||||||
)
|
|
||||||
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",
|
||||||
@@ -167,11 +161,7 @@ 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,
|
||||||
shared_mutation_env(
|
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
||||||
"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()
|
||||||
@@ -181,11 +171,6 @@ 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()
|
||||||
@@ -220,8 +205,6 @@ 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):
|
||||||
@@ -256,8 +239,6 @@ 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,8 +1,3 @@
|
|||||||
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
|
||||||
@@ -46,7 +41,6 @@ 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,7 +1,3 @@
|
|||||||
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,11 +1,7 @@
|
|||||||
|
"""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
|
||||||
@@ -17,20 +13,22 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
|
|||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = shared_mutation_env(
|
ISSUE_WRITE_ENV = {
|
||||||
"test-author-prgs",
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
)
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
CREATE_PR_ENV = shared_mutation_env(
|
CREATE_PR_ENV = {
|
||||||
"test-author-prgs",
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
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():
|
||||||
|
|||||||
+105
-151
@@ -1,11 +1,3 @@
|
|||||||
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
|
||||||
@@ -55,7 +47,6 @@ 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
|
||||||
|
|
||||||
@@ -230,26 +221,22 @@ def _seed_ready_review_decision(
|
|||||||
|
|
||||||
|
|
||||||
# Issue-write tools are profile-gated (#69).
|
# Issue-write tools are profile-gated (#69).
|
||||||
# Default remote is dadeschools — use the host-aligned config profile so
|
ISSUE_WRITE_ENV = {
|
||||||
# cross-host session binding does not fail closed (#714).
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
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).
|
|
||||||
# Default remote is dadeschools; use matching config-backed author profile.
|
|
||||||
CREATE_PR_ENV = shared_mutation_env(
|
|
||||||
"test-author-dadeschools",
|
|
||||||
GITEA_ALLOWED_OPERATIONS=(
|
|
||||||
"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.pr.approve,gitea.pr.merge,gitea.pr.review",
|
}
|
||||||
)
|
|
||||||
|
# create_pr is gated on author profile + namespace (#69, #209).
|
||||||
|
CREATE_PR_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.read,gitea.pr.create,gitea.branch.push"
|
||||||
|
),
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": (
|
||||||
|
"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"
|
||||||
|
|
||||||
@@ -300,7 +287,6 @@ 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",
|
||||||
@@ -310,7 +296,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
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)
|
||||||
@@ -328,7 +314,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
|
|
||||||
@@ -339,18 +325,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_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"}
|
||||||
env = {
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
**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]
|
||||||
@@ -363,7 +338,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
result = gitea_create_issue(
|
result = gitea_create_issue(
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
require_workflow_labels=True,
|
require_workflow_labels=True,
|
||||||
@@ -403,7 +378,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
_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",
|
||||||
@@ -446,7 +421,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
_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",
|
||||||
@@ -472,7 +447,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
_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",
|
||||||
@@ -495,7 +470,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
_bind_test_lock(
|
_bind_test_lock(
|
||||||
issue_number=123,
|
issue_number=123,
|
||||||
branch_name="feat/x",
|
branch_name="feat/x",
|
||||||
@@ -520,7 +495,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
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"])
|
||||||
@@ -626,7 +601,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
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"])
|
||||||
@@ -641,7 +616,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
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"])
|
||||||
@@ -654,7 +629,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_mark_issue(issue_number=5, action="start")
|
gitea_mark_issue(issue_number=5, action="start")
|
||||||
|
|
||||||
@@ -666,12 +641,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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
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=False):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
gitea_create_issue(title="test", remote="nonexistent")
|
gitea_create_issue(title="test", remote="nonexistent")
|
||||||
|
|
||||||
@@ -885,7 +860,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -918,7 +893,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"],
|
||||||
@@ -939,7 +914,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -970,7 +945,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -987,7 +962,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"]))
|
||||||
@@ -998,7 +973,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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()
|
||||||
@@ -1010,7 +985,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"
|
||||||
@@ -1024,7 +999,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1035,7 +1010,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1047,7 +1022,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1119,7 +1094,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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")
|
||||||
@@ -1135,7 +1110,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1149,7 +1124,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1163,7 +1138,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1181,7 +1156,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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")
|
||||||
@@ -1202,7 +1177,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"],
|
||||||
@@ -1235,7 +1210,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -1254,7 +1229,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -1277,7 +1252,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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)
|
||||||
@@ -1300,7 +1275,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -1332,7 +1307,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -1639,8 +1614,10 @@ class TestCommitFiles(unittest.TestCase):
|
|||||||
files = [
|
files = [
|
||||||
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
||||||
]
|
]
|
||||||
env = shared_mutation_env("test-author-dadeschools")
|
env = {
|
||||||
with patch.dict(os.environ, env, clear=False):
|
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
|
||||||
|
}
|
||||||
|
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",
|
||||||
@@ -1755,7 +1732,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1766,7 +1743,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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
|
||||||
@@ -1783,7 +1760,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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(
|
||||||
@@ -1804,7 +1781,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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)
|
||||||
@@ -1873,7 +1850,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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")
|
||||||
@@ -1889,7 +1866,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -1910,7 +1887,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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()):
|
||||||
@@ -1992,7 +1969,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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")
|
||||||
@@ -2005,7 +1982,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2016,7 +1993,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2031,7 +2008,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2043,7 +2020,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2055,7 +2032,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2066,7 +2043,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2075,7 +2052,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -2094,7 +2071,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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()):
|
||||||
@@ -2291,7 +2268,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2310,7 +2287,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2340,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2360,7 +2337,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2386,7 +2363,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -2407,7 +2384,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2428,7 +2405,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2446,7 +2423,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
)
|
)
|
||||||
@@ -2462,7 +2439,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2477,7 +2454,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2496,7 +2473,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -2520,7 +2497,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -2549,7 +2526,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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",
|
||||||
@@ -2569,7 +2546,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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 +2623,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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 +2656,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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,
|
||||||
@@ -2704,7 +2681,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
# 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")
|
||||||
|
|
||||||
@@ -2742,29 +2719,13 @@ 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={
|
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()
|
||||||
"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",
|
||||||
@@ -2772,11 +2733,6 @@ 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):
|
||||||
@@ -3182,7 +3138,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
result = gitea_get_profile(remote="prgs",
|
result = gitea_get_profile(remote="prgs",
|
||||||
resolve_identity=False)
|
resolve_identity=False)
|
||||||
blob = repr(result)
|
blob = repr(result)
|
||||||
@@ -3204,7 +3160,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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")
|
||||||
@@ -3216,7 +3172,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=False), \
|
with patch.dict(os.environ, env, clear=True), \
|
||||||
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:
|
||||||
@@ -3304,7 +3260,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
|
|
||||||
@@ -3313,7 +3269,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -3360,7 +3316,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -3372,7 +3328,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -3386,7 +3342,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -3522,7 +3478,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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"])
|
||||||
@@ -3707,12 +3663,11 @@ 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 = {
|
||||||
**shared_mutation_env("test-author-prgs"),
|
**ISSUE_WRITE_ENV,
|
||||||
"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=False)
|
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||||
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",
|
||||||
@@ -3726,9 +3681,8 @@ 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 {
|
||||||
**shared_mutation_env("test-author-prgs"),
|
**CREATE_PR_ENV,
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3747,7 +3701,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.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
|
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
||||||
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)
|
||||||
@@ -3866,7 +3820,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 = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||||
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",
|
||||||
@@ -3903,7 +3857,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 = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||||
# 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))
|
||||||
@@ -4097,12 +4051,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=False):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
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="Gitea-Tools", # #714 session-bound
|
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||||
issue_number=447,
|
issue_number=447,
|
||||||
lock_dir=lock_dir,
|
lock_dir=lock_dir,
|
||||||
),
|
),
|
||||||
@@ -4111,7 +4065,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="Gitea-Tools", # #714 session-bound
|
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||||
worktree_path=worktree,
|
worktree_path=worktree,
|
||||||
lock_provenance=None,
|
lock_provenance=None,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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,9 +1,4 @@
|
|||||||
"""Tests for operation-scoped role selection (#228, updated by #714).
|
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
|
||||||
|
|
||||||
#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
|
||||||
@@ -20,7 +15,6 @@ 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,
|
||||||
@@ -36,11 +30,9 @@ 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": {
|
||||||
@@ -48,11 +40,9 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,15 +57,6 @@ 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()
|
||||||
@@ -83,11 +64,6 @@ 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
|
||||||
@@ -109,71 +85,62 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_no_auto_switch_to_reviewer(self, mock_api):
|
def test_auto_switch_to_reviewer(self, mock_api):
|
||||||
# #714: capability resolution must not silently switch author → reviewer
|
# mock identity resolution to return username matching profile
|
||||||
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")
|
||||||
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
# verify it automatically switched to reviewer-profile
|
||||||
self.assertFalse(res["available_in_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
self.assertEqual(res["active_profile"], "author-profile")
|
self.assertTrue(res["available_in_session"])
|
||||||
self.assertEqual(res["active_identity"], "author-user")
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||||
self.assertIn("reviewer-profile", res["matching_configured_profile"])
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
self.assertFalse(res.get("auto_profile_substitution", True))
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_no_auto_switch_to_author(self, mock_api):
|
def test_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")
|
||||||
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
# verify it automatically switched to author-profile
|
||||||
self.assertFalse(res["available_in_session"])
|
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
|
||||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
|
||||||
self.assertIn("author-profile", res["matching_configured_profile"])
|
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
|
||||||
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.assertTrue(res["allowed_in_current_session"])
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
self.assertTrue(res["available_in_session"])
|
||||||
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
|
self.assertEqual(res["active_identity"], "author-user")
|
||||||
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_denied_when_matching_profile_token_missing(self, mock_api):
|
def test_restart_required_when_unattached(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.assertEqual(res["active_profile"], "author-profile")
|
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
|
||||||
|
|
||||||
def test_author_continuity_dirty_worktree(self):
|
def test_author_continuity_dirty_worktree(self):
|
||||||
# author is allowed to keep dirty worktree
|
# author is allowed to keep dirty worktree
|
||||||
@@ -194,21 +161,20 @@ 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_do_not_auto_switch(self, mock_api):
|
def test_mutating_actions_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")
|
||||||
|
|
||||||
blocked = mcp_server._profile_permission_block(
|
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
|
||||||
"gitea.pr.merge", remote="prgs"
|
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
|
||||||
)
|
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
|
||||||
self.assertIsNotNone(blocked)
|
|
||||||
self.assertFalse(blocked.get("success", True))
|
|
||||||
|
|
||||||
# profile must remain author — no silent substitution
|
# verify we dynamically switched to reviewer-profile
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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,7 +1,3 @@
|
|||||||
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,7 +1,3 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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``
|
||||||
@@ -166,12 +162,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_prefers_workspace_over_timesheet_default(self):
|
def test_resolve_blocks_on_default_repo_mismatch(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")
|
||||||
host, org, repo = self.server._resolve("prgs", None, None, None)
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
self.assertEqual(org, "Scaled-Tech-Consulting")
|
self.server._resolve("prgs", None, None, None)
|
||||||
self.assertEqual(repo, "Gitea-Tools")
|
self.assertIn("Timesheet", str(ctx.exception))
|
||||||
|
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": "reviewer-user"})
|
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-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(
|
||||||
|
|||||||
@@ -957,20 +957,17 @@ class TestControllerHandoff(unittest.TestCase):
|
|||||||
if not line.startswith("- Workspace mutations:"))
|
if not line.startswith("- Workspace mutations:"))
|
||||||
result = assess_controller_handoff(review_base, role="review")
|
result = assess_controller_handoff(review_base, role="review")
|
||||||
self.assertEqual(result["verdict"], "incomplete")
|
self.assertEqual(result["verdict"], "incomplete")
|
||||||
# #698: the canonical schema forbids the legacy fields, so the
|
self.assertIn("Pinned reviewed head", result["missing_fields"])
|
||||||
# validator must demand the canonical names instead.
|
self.assertIn("Worktree path", result["missing_fields"])
|
||||||
self.assertIn("Reviewed head SHA", result["missing_fields"])
|
|
||||||
self.assertIn("Review worktree path", result["missing_fields"])
|
|
||||||
self.assertIn("Merge result", result["missing_fields"])
|
self.assertIn("Merge result", result["missing_fields"])
|
||||||
for legacy in ("Pinned reviewed head", "Scratch worktree used"):
|
|
||||||
self.assertNotIn(legacy, result["missing_fields"])
|
|
||||||
|
|
||||||
complete = review_base + "\n" + "\n".join([
|
complete = review_base + "\n" + "\n".join([
|
||||||
"- Selected PR: #999",
|
"- Selected PR: #999",
|
||||||
"- Reviewer eligibility: passed",
|
"- Reviewer eligibility: passed",
|
||||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
"- Review worktree path: /repo/branches/review-pr-999",
|
"- Worktree path: /repo/branches/review-pr-999",
|
||||||
"- Review worktree dirty before validation: no",
|
"- Worktree dirty: no",
|
||||||
|
"- Scratch worktree used: yes (/repo/branches/review-pr-999)",
|
||||||
"- Unrelated local mutations: none",
|
"- Unrelated local mutations: none",
|
||||||
"- Review decision: approve",
|
"- Review decision: approve",
|
||||||
"- Merge result: merged",
|
"- Merge result: merged",
|
||||||
@@ -1128,9 +1125,10 @@ class TestReviewHandoffPreciseMutationCategories(unittest.TestCase):
|
|||||||
"- Safety: no self-review; no self-merge; no secrets",
|
"- Safety: no self-review; no self-merge; no secrets",
|
||||||
"- Selected PR: #999",
|
"- Selected PR: #999",
|
||||||
"- Reviewer eligibility: passed",
|
"- Reviewer eligibility: passed",
|
||||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||||
"- Worktree path: /repo/branches/review-pr-999",
|
"- Worktree path: /repo/branches/review-pr-999",
|
||||||
"- Worktree dirty: no",
|
"- Worktree dirty: no",
|
||||||
|
"- Scratch worktree used: yes (/repo/branches/review-pr-999)",
|
||||||
"- Unrelated local mutations: none",
|
"- Unrelated local mutations: none",
|
||||||
"- Review decision: approve",
|
"- Review decision: approve",
|
||||||
"- Merge result: none",
|
"- Merge result: none",
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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
|
||||||
@@ -40,7 +36,6 @@ 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,
|
||||||
@@ -56,7 +51,6 @@ 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,
|
||||||
@@ -69,13 +63,12 @@ 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 = {} # config-backed; operations come from profile allowlist (#714)
|
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
|
||||||
|
|
||||||
|
|
||||||
class NamespaceGateBase(unittest.TestCase):
|
class NamespaceGateBase(unittest.TestCase):
|
||||||
@@ -206,7 +199,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)
|
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
|
||||||
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,7 +36,6 @@ 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,
|
||||||
@@ -52,7 +51,6 @@ 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,
|
||||||
@@ -67,7 +65,6 @@ 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,
|
||||||
@@ -84,7 +81,6 @@ 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,8 +35,7 @@ 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,
|
||||||
@@ -46,8 +45,7 @@ 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,
|
||||||
@@ -57,8 +55,7 @@ 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,11 +1,15 @@
|
|||||||
|
"""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,11 +1,7 @@
|
|||||||
|
"""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