feat: interactive setup menu for canonical Gitea MCP profiles (#31)

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>
This commit is contained in:
2026-07-01 23:32:24 -04:00
parent b88ca0c929
commit 835fbbf324
4 changed files with 756 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
"""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:]))