"""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:]))