"""Interactive setup menu for the canonical Gitea MCP profile config (#31, #36). 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: ``./scripts/gitea-config-menu`` (or ``python gitea_config.py menu``). UX (#36): in an interactive TTY the top-level menu and profile chooser take a single keypress (no Enter); Enter quits the main menu and cancels/back-outs of any submenu; the screen clears before redraws; results pause for a keypress. All of that runs through an injectable :class:`MenuIO`, so tests exercise the logic with scripted keys and no real terminal, keychain, 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} # ── Injectable menu IO ────────────────────────────────────────────────────────── CLEAR_SEQUENCE = "\x1b[2J\x1b[H" class MenuIO: """Terminal IO for the menu, fully injectable for tests. - ``read_key(prompt)`` → one keypress in a TTY, or the first character of a line otherwise. Enter / EOF return ``""`` (used as cancel/back/quit). - ``read_line(prompt)`` → a full line (for free text). EOF returns ``""``. - ``out(text)`` → write a line. - ``clear()`` → clear the screen, but only when ``clear_enabled``. ``is_tty`` gates single-key input and the pause prompt; ``clear_enabled`` gates screen clearing so automated/non-TTY runs never emit control codes. """ def __init__(self, *, read_key, read_line, write, is_tty=False, clear_enabled=False, clear_fn=None): self._read_key = read_key self._read_line = read_line self._write = write self.is_tty = is_tty self.clear_enabled = clear_enabled self._clear_fn = clear_fn def read_key(self, prompt=""): return self._read_key(prompt) def read_line(self, prompt=""): return self._read_line(prompt) def out(self, text=""): self._write(text) def clear(self): if self.clear_enabled and self._clear_fn: self._clear_fn() def _read_key_tty(): """Read a single keypress from a real TTY without requiring Enter. Returns ``""`` for Enter, raises KeyboardInterrupt on Ctrl-C. macOS/Linux only (termios/tty); callers use a line-input fallback when not a TTY. """ import termios import tty fd = sys.stdin.fileno() old = termios.tcgetattr(fd) try: tty.setraw(fd) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) if ch in ("\r", "\n", ""): return "" if ch == "\x03": raise KeyboardInterrupt return ch def default_io(): """Build the real MenuIO: single-key in a TTY, line-input otherwise.""" is_tty = bool(getattr(sys.stdin, "isatty", lambda: False)()) and \ bool(getattr(sys.stdout, "isatty", lambda: False)()) def write(text=""): print(text) def read_key(prompt=""): if prompt: sys.stdout.write(prompt) sys.stdout.flush() if is_tty: key = _read_key_tty() print() # move off the prompt line return key line = sys.stdin.readline() if line == "": return "" # EOF → back/quit return line.rstrip("\r\n")[:1] def read_line(prompt=""): try: return input(prompt) except EOFError: return "" def clear(): sys.stdout.write(CLEAR_SEQUENCE) sys.stdout.flush() return MenuIO(read_key=read_key, read_line=read_line, write=write, is_tty=is_tty, clear_enabled=is_tty, clear_fn=clear) # ── UX helpers ────────────────────────────────────────────────────────────────── _MENU_LINES = [ "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", " (Enter or 0) quit", ] def clear_screen(io): """Clear the terminal (no-op unless the IO has clearing enabled).""" io.clear() def pause_for_key(io): """Wait for a keypress in a TTY; return immediately otherwise (never hangs).""" if io.is_tty: io.out("Press any key to continue") io.read_key() def choose_menu_option(io): """Clear, draw the main menu, and return the pressed key ('' = quit).""" clear_screen(io) for line in _MENU_LINES: io.out(line) return io.read_key("choice: ") def choose_profile(io, config, purpose="profile"): """Show a numbered profile list and return the chosen name, or None. Enter cancels (returns None). Digits 1-9 select from the list. 'm' opens an explicit manual-entry path. Empty config is handled gracefully. """ names = sorted((config.get("profiles") or {})) clear_screen(io) if not names: io.out("(no profiles yet — add one first)") pause_for_key(io) return None io.out(f"Select {purpose} (Enter to cancel):") for i, name in enumerate(names[:9], start=1): io.out(f" {i}) {name}") io.out(" m) type a name manually") key = io.read_key("choose: ") if key == "": return None if key == "m": typed = io.read_line("profile name: ").strip() return typed or None if key.isdigit(): idx = int(key) if 1 <= idx <= min(len(names), 9): return names[idx - 1] io.out("invalid selection") pause_for_key(io) return None def _prompt_profile_fields(io, secret_fn, keychain_set, name): """Prompt for a profile's fields for *name*. Returns a profile dict or None. A blank Base URL cancels. Tokens are read with the secret prompt (no echo) and stored in the keychain; they are never placed in the returned profile. """ base_url = io.read_line("Base URL (e.g. https://gitea.prgs.cc; Enter cancels): ").strip() if not base_url: return None username = io.read_line("Gitea username: ").strip() default_owner = io.read_line("Default owner (optional): ").strip() default_repo = io.read_line("Default repo (optional): ").strip() execution_profile = io.read_line("Execution profile (optional): ").strip() auth_type = io.read_line("Auth type [keychain/env]: ").strip().lower() if auth_type == "keychain": default_id = f"{name}-gitea-token" item_id = io.read_line(f"Keychain item id [{default_id}]: ").strip() or default_id store = io.read_line("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 io.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 = io.read_line(f"Env var name [{default_var}]: ").strip() or default_var io.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'") return gitea_config.build_profile( base_url=base_url, auth=auth, username=username, default_owner=default_owner, default_repo=default_repo, execution_profile=execution_profile, ) def main(argv=None, *, io=None, secret_fn=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.""" io = io or default_io() secret_fn = secret_fn or getpass.getpass 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: io.out(f"config error: {exc}") config = gitea_config.empty_config() while True: key = choose_menu_option(io) if key in ("", "0"): # Enter or 0 → quit io.out("bye") return 0 try: if key == "1": names = sorted(config.get("profiles") or {}) clear_screen(io) io.out("profiles: " + (", ".join(names) if names else "(none)")) pause_for_key(io) elif key == "2": name = io.read_line("New profile name (Enter to cancel): ").strip() if not name: continue if not gitea_config.is_valid_profile_name(name): io.out(f"invalid profile name {name!r}") pause_for_key(io) continue profile = _prompt_profile_fields(io, secret_fn, keychain_set, name) if profile is None: continue config = gitea_config.add_profile(config, name, profile) saved = gitea_config.save_config(config, path) io.out(f"added '{name}'; wrote {saved}") pause_for_key(io) elif key == "3": name = choose_profile(io, config, "profile to edit") if not name: continue profile = _prompt_profile_fields(io, secret_fn, keychain_set, name) if profile is None: continue config = gitea_config.upsert_profile(config, name, profile) saved = gitea_config.save_config(config, path) io.out(f"saved '{name}'; wrote {saved}") pause_for_key(io) elif key == "4": name = choose_profile(io, config, "profile to remove") if not name: continue config = gitea_config.remove_profile(config, name) saved = gitea_config.save_config(config, path) io.out(f"removed '{name}'; wrote {saved}") pause_for_key(io) elif key == "5": problems = gitea_config.validate_config(config) clear_screen(io) io.out("valid" if not problems else "problems:\n " + "\n ".join(problems)) pause_for_key(io) elif key in ("6", "7"): name = choose_profile(io, config, "profile") if not name: continue profile = gitea_config.select_profile(config, name) who = resolve_identity(profile, resolve_token=resolve_token, api_request=api_request) io.out(f"authenticated as: {who}") pause_for_key(io) elif key == "8": name = choose_profile(io, config, "profile") if not name: continue clear_screen(io) for client, snippet in launcher_snippets(name, path).items(): io.out(f"--- {client} ---\n{snippet}") pause_for_key(io) elif key == "9": name = choose_profile(io, config, "profile") if not name: continue pr_number = io.read_line("PR number (Enter to cancel): ").strip() if not pr_number: continue profile = gitea_config.select_profile(config, name) result = check_eligibility( profile, pr_number, resolve_token=resolve_token, api_request=api_request) io.out(f"authenticated user: {result['authenticated_user']}") io.out(f"PR author: {result['pr_author']}") io.out("ELIGIBLE" if result["eligible"] else "INELIGIBLE") for r in result["reasons"]: io.out(f" - {r}") pause_for_key(io) else: io.out("unknown choice") pause_for_key(io) except gitea_config.ConfigError as exc: io.out(f"error: {exc}") pause_for_key(io) except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets io.out(f"error: {type(exc).__name__}") pause_for_key(io) if __name__ == "__main__": # pragma: no cover raise SystemExit(main(sys.argv[1:]))