Files
Gitea-Tools/gitea_config_menu.py
T
sysadmin 69d4edf37d fix: single-key TTY menu UX for the Gitea config menu (#36)
Make the interactive profile menu feel like a real terminal menu, via a new
injectable MenuIO abstraction (no menu logic change, no auth/secret-storage
change).

- Single-key top-level actions in a TTY (termios/tty raw read); no Enter
  needed. Non-TTY / test runs fall back to line input.
- Enter backs out: Enter (or 0) on the main menu quits; Enter cancels any
  submenu/profile prompt and returns.
- Profile chooser: everywhere a profile is needed, show a numbered list and
  pick by key (1-9), with an explicit 'm) type a name manually' path and Enter
  to cancel. Empty config handled gracefully.
- Clear screen before redrawing the main menu and chooser — TTY only; never
  emits clear codes in non-TTY/test runs.
- Result actions (validate/test-auth/whoami/eligibility) print a concise result
  then pause for a keypress in a TTY; non-TTY never blocks.

Helpers: read_key (via default_io) / choose_menu_option / choose_profile /
clear_screen / pause_for_key, plus MenuIO(is_tty, clear_enabled). TTY detected
with sys.stdin.isatty() and sys.stdout.isatty(); stdlib only.

Safety unchanged: no tokens/passwords printed, no raw config dumps, no
.env.personal, no change to auth behavior or secret storage.

Tests: rewrote menu tests around a scripted _FakeIO (no real terminal): single-
key select + clear, main-menu Enter/0 quit, submenu Enter cancel (no change),
chooser lists/selects/no-profiles/manual/out-of-range, non-TTY line fallback,
clear-only-when-enabled, pause never hangs non-TTY, and add-flow proving the
token value never reaches disk or stdout.

Docs: runbook note on single-key nav / Enter back-out / numbered chooser.
scripts/gitea-config-menu unchanged.

Closes #36. Refs #31, #34.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:34:16 -04:00

423 lines
16 KiB
Python

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