835fbbf324
Add an interactive utility so users create/edit/validate canonical runtime profiles and generate safe LLM launcher snippets without hand-editing JSON or pasting tokens into Claude/Gemini/Codex configs. Run: `python gitea_config.py menu` (or `python gitea_config_menu.py`). gitea_config.py — pure, testable authoring helpers: - is_valid_profile_name, build_profile, keychain_auth/env_auth, empty_config - validate_config (reports missing base_url/auth, inline token/password — never echoing the secret value) - add_profile (preserves existing, rejects dup/invalid name/missing base_url), upsert_profile, remove_profile - save_config: mkdir parents + atomic temp-then-os.replace, pretty JSON - launcher_entry: thin MCP entry (command/args + GITEA_MCP_CONFIG/PROFILE only) - keychain_set: store a token via `security add-generic-password` (token passed as an arg, never returned/printed/logged; injectable runner) - `menu` __main__ dispatch gitea_config_menu.py — interactive loop with fully injectable IO/secret/HTTP/ keychain so it is testable without a real terminal, keychain, or network: - list / add / edit / remove / validate profiles - test authentication + show authenticated user (calls /user only on request) - reviewer-eligibility helper (authenticated user vs PR author, open state) — read-only, never approves/merges - launcher snippets for Claude / Gemini / Codex (no secrets) Security: tokens are never written to profiles.json, launcher snippets, logs, or errors — only keychain ids / env var names are stored. Backwards compatible: menu is optional; env-only mode and MCP server startup are unchanged. Tests: tests/test_config_menu.py (21 cases) — name validation, preserve-on-add, dup/invalid/missing-field rejection, atomic write (+ replace-failure leaves the original intact, no temp debris), keychain_set stores-without-printing, launcher snippets secret-free, eligibility eligible/self-author/closed, and a full menu add→list→quit flow proving the token value never reaches disk or stdout. Stacked on #30 (canonical profiles); base branch feat/json-runtime-profiles. Refs #10, #19. Closes #31. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
10 KiB
Python
236 lines
10 KiB
Python
"""Interactive setup menu for the canonical Gitea MCP profile config (#31).
|
|
|
|
Create / edit / remove / validate profiles, test a profile's authentication,
|
|
show the authenticated user, check reviewer eligibility for a PR, and print
|
|
thin launcher snippets for Claude / Gemini / Codex — without hand-editing JSON
|
|
and without ever putting a raw token in a launcher config.
|
|
|
|
Run: ``python gitea_config.py menu`` (or ``python gitea_config_menu.py``)
|
|
|
|
Every side effect (stdin, secret prompt, stdout, keychain store, token
|
|
resolution, HTTP) is injectable so the menu logic is testable without a real
|
|
keychain, terminal, or network.
|
|
"""
|
|
import sys
|
|
import json
|
|
import getpass
|
|
import urllib.parse
|
|
|
|
import gitea_config
|
|
import gitea_auth
|
|
|
|
|
|
def _host(base_url):
|
|
"""Extract the host from a profile base_url (e.g. https://gitea.x → gitea.x)."""
|
|
netloc = urllib.parse.urlparse(base_url or "").netloc
|
|
return netloc or (base_url or "").strip()
|
|
|
|
|
|
def resolve_identity(profile, *, resolve_token=gitea_config.resolve_token,
|
|
api_request=gitea_auth.api_request):
|
|
"""Return the authenticated username for *profile*, or raise ConfigError.
|
|
|
|
Resolves the profile's auth reference to a token (keychain/env), calls the
|
|
read-only current-user endpoint, and returns ``login``. Never returns or
|
|
logs the token.
|
|
"""
|
|
token = resolve_token(profile)
|
|
if not token:
|
|
raise gitea_config.ConfigError("no token resolved for this profile")
|
|
host = _host(profile.get("base_url"))
|
|
data = api_request("GET", f"https://{host}/api/v1/user", f"token {token}")
|
|
login = (data or {}).get("login")
|
|
if not login:
|
|
raise gitea_config.ConfigError("could not determine authenticated user")
|
|
return login
|
|
|
|
|
|
def check_eligibility(profile, pr_number, owner=None, repo=None, *,
|
|
resolve_token=gitea_config.resolve_token,
|
|
api_request=gitea_auth.api_request):
|
|
"""Report whether *profile* is reviewer-eligible for a PR. Read-only.
|
|
|
|
Eligible ≙ the PR is open AND the authenticated user is NOT the PR author.
|
|
Never approves or merges. Returns a dict with the facts and a verdict.
|
|
"""
|
|
owner = owner or profile.get("default_owner")
|
|
repo = repo or profile.get("default_repo")
|
|
if not owner or not repo:
|
|
raise gitea_config.ConfigError(
|
|
"owner/repo required (set default_owner/default_repo or pass them)"
|
|
)
|
|
token = resolve_token(profile)
|
|
if not token:
|
|
raise gitea_config.ConfigError("no token resolved for this profile")
|
|
auth = f"token {token}"
|
|
host = _host(profile.get("base_url"))
|
|
me = (api_request("GET", f"https://{host}/api/v1/user", auth) or {}).get("login")
|
|
pr = api_request(
|
|
"GET", f"https://{host}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}", auth
|
|
) or {}
|
|
author = (pr.get("user") or {}).get("login")
|
|
state = pr.get("state")
|
|
reasons = []
|
|
if not me:
|
|
reasons.append("authenticated user unknown")
|
|
if state != "open":
|
|
reasons.append(f"PR is not open (state={state})")
|
|
if me and author and me == author:
|
|
reasons.append("authenticated user is the PR author")
|
|
eligible = not reasons
|
|
return {
|
|
"authenticated_user": me,
|
|
"pr_author": author,
|
|
"pr_state": state,
|
|
"eligible": eligible,
|
|
"reasons": reasons or ["open PR and reviewer is not the author"],
|
|
}
|
|
|
|
|
|
def launcher_snippets(profile_name, config_path):
|
|
"""Return {client: pretty-JSON snippet} for Claude / Gemini / Codex.
|
|
|
|
All three MCP hosts consume the same server entry; the snippet carries only
|
|
command/args + GITEA_MCP_CONFIG/GITEA_MCP_PROFILE — never a secret.
|
|
"""
|
|
entry = gitea_config.launcher_entry(profile_name, config_path)
|
|
claude = json.dumps({"mcpServers": entry}, indent=2)
|
|
gemini = json.dumps({"mcpServers": entry}, indent=2)
|
|
codex = json.dumps(entry, indent=2)
|
|
return {"claude": claude, "gemini": gemini, "codex": codex}
|
|
|
|
|
|
# ── Interactive prompts (thin wrappers over injected IO) ────────────────────────
|
|
|
|
def _prompt_profile(input_fn, secret_fn, out, keychain_set):
|
|
"""Collect a new/edited profile from the user. Returns (name, profile).
|
|
|
|
Tokens are read with the secret prompt (no echo) and stored in the keychain;
|
|
they are never placed in the returned profile.
|
|
"""
|
|
name = input_fn("Profile name (e.g. prgs-reviewer): ").strip()
|
|
if not gitea_config.is_valid_profile_name(name):
|
|
raise gitea_config.ConfigError(f"invalid profile name {name!r}")
|
|
base_url = input_fn("Base URL (e.g. https://gitea.prgs.cc): ").strip()
|
|
username = input_fn("Gitea username: ").strip()
|
|
default_owner = input_fn("Default owner (optional): ").strip()
|
|
default_repo = input_fn("Default repo (optional): ").strip()
|
|
execution_profile = input_fn("Execution profile (optional): ").strip()
|
|
auth_type = input_fn("Auth type [keychain/env]: ").strip().lower()
|
|
|
|
if auth_type == "keychain":
|
|
default_id = f"{name}-gitea-token"
|
|
item_id = input_fn(f"Keychain item id [{default_id}]: ").strip() or default_id
|
|
store = input_fn("Store a token in the keychain now? [y/N]: ").strip().lower()
|
|
if store == "y":
|
|
token = secret_fn("Gitea token (hidden): ")
|
|
keychain_set(item_id, token) # token never echoed
|
|
out(f"Stored token in keychain item '{item_id}'.")
|
|
auth = gitea_config.keychain_auth(item_id)
|
|
elif auth_type == "env":
|
|
default_var = f"GITEA_TOKEN_{name.upper().replace('-', '_').replace('.', '_')}"
|
|
var = input_fn(f"Env var name [{default_var}]: ").strip() or default_var
|
|
out(f"Set {var} in the environment; its value is never stored here.")
|
|
auth = gitea_config.env_auth(var)
|
|
else:
|
|
raise gitea_config.ConfigError("auth type must be 'keychain' or 'env'")
|
|
|
|
profile = gitea_config.build_profile(
|
|
base_url=base_url, auth=auth, username=username,
|
|
default_owner=default_owner, default_repo=default_repo,
|
|
execution_profile=execution_profile,
|
|
)
|
|
return name, profile
|
|
|
|
|
|
_MENU = """
|
|
Gitea MCP profile setup
|
|
1) list profiles
|
|
2) add profile
|
|
3) edit profile
|
|
4) remove profile
|
|
5) validate config
|
|
6) test profile authentication
|
|
7) show authenticated user
|
|
8) generate launcher snippets (Claude/Gemini/Codex)
|
|
9) check reviewer eligibility for a PR
|
|
0) quit
|
|
"""
|
|
|
|
|
|
def main(argv=None, *, input_fn=input, secret_fn=None, out=None, config_path=None,
|
|
resolve_token=gitea_config.resolve_token,
|
|
api_request=gitea_auth.api_request,
|
|
keychain_set=gitea_config.keychain_set):
|
|
"""Run the interactive menu loop. Returns a process exit code."""
|
|
secret_fn = secret_fn or getpass.getpass
|
|
out = out or (lambda *a: print(*a))
|
|
path = config_path or gitea_config.config_path() or gitea_config.DEFAULT_CONFIG_PATH
|
|
|
|
try:
|
|
config = gitea_config.load_config(path) or gitea_config.empty_config()
|
|
except gitea_config.ConfigError as exc:
|
|
out(f"config error: {exc}")
|
|
config = gitea_config.empty_config()
|
|
|
|
while True:
|
|
out(_MENU)
|
|
choice = input_fn("choice: ").strip()
|
|
try:
|
|
if choice == "0":
|
|
out("bye")
|
|
return 0
|
|
elif choice == "1":
|
|
names = sorted(config.get("profiles") or {})
|
|
out("profiles: " + (", ".join(names) if names else "(none)"))
|
|
elif choice == "2":
|
|
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
|
|
config = gitea_config.add_profile(config, name, profile)
|
|
saved = gitea_config.save_config(config, path)
|
|
out(f"added '{name}'; wrote {saved}")
|
|
elif choice == "3":
|
|
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
|
|
config = gitea_config.upsert_profile(config, name, profile)
|
|
saved = gitea_config.save_config(config, path)
|
|
out(f"saved '{name}'; wrote {saved}")
|
|
elif choice == "4":
|
|
name = input_fn("profile to remove: ").strip()
|
|
config = gitea_config.remove_profile(config, name)
|
|
saved = gitea_config.save_config(config, path)
|
|
out(f"removed '{name}'; wrote {saved}")
|
|
elif choice == "5":
|
|
problems = gitea_config.validate_config(config)
|
|
out("valid" if not problems else "problems:\n " + "\n ".join(problems))
|
|
elif choice in ("6", "7"):
|
|
name = input_fn("profile: ").strip()
|
|
profile = gitea_config.select_profile(config, name)
|
|
who = resolve_identity(profile, resolve_token=resolve_token,
|
|
api_request=api_request)
|
|
out(f"authenticated as: {who}")
|
|
elif choice == "8":
|
|
name = input_fn("profile: ").strip()
|
|
for client, snippet in launcher_snippets(name, path).items():
|
|
out(f"--- {client} ---\n{snippet}")
|
|
elif choice == "9":
|
|
name = input_fn("profile: ").strip()
|
|
pr_number = input_fn("PR number: ").strip()
|
|
profile = gitea_config.select_profile(config, name)
|
|
result = check_eligibility(
|
|
profile, pr_number, resolve_token=resolve_token,
|
|
api_request=api_request)
|
|
out(f"authenticated user: {result['authenticated_user']}")
|
|
out(f"PR author: {result['pr_author']}")
|
|
out("ELIGIBLE" if result["eligible"] else "INELIGIBLE")
|
|
for r in result["reasons"]:
|
|
out(f" - {r}")
|
|
else:
|
|
out("unknown choice")
|
|
except gitea_config.ConfigError as exc:
|
|
out(f"error: {exc}")
|
|
except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets
|
|
out(f"error: {type(exc).__name__}")
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(main(sys.argv[1:]))
|