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>
This commit is contained in:
@@ -115,6 +115,11 @@ Menu options: list / add / edit / remove profiles · validate config · test
|
|||||||
profile authentication · show authenticated user · generate launcher snippets
|
profile authentication · show authenticated user · generate launcher snippets
|
||||||
(Claude/Gemini/Codex) · check reviewer eligibility for a PR.
|
(Claude/Gemini/Codex) · check reviewer eligibility for a PR.
|
||||||
|
|
||||||
|
In a real terminal the menu takes a **single keypress** (no Enter), **Enter**
|
||||||
|
quits the main menu and cancels/back-outs of any submenu, and you pick a profile
|
||||||
|
from a **numbered list** instead of typing its name. Non-interactive runs
|
||||||
|
(pipes/tests) fall back to line input and never block.
|
||||||
|
|
||||||
**Create an author + a reviewer profile:**
|
**Create an author + a reviewer profile:**
|
||||||
|
|
||||||
1. `add profile` → name `prgs-author`, base URL, username, default owner/repo,
|
1. `add profile` → name `prgs-author`, base URL, username, default owner/repo,
|
||||||
|
|||||||
+265
-78
@@ -1,15 +1,17 @@
|
|||||||
"""Interactive setup menu for the canonical Gitea MCP profile config (#31).
|
"""Interactive setup menu for the canonical Gitea MCP profile config (#31, #36).
|
||||||
|
|
||||||
Create / edit / remove / validate profiles, test a profile's authentication,
|
Create / edit / remove / validate profiles, test a profile's authentication,
|
||||||
show the authenticated user, check reviewer eligibility for a PR, and print
|
show the authenticated user, check reviewer eligibility for a PR, and print
|
||||||
thin launcher snippets for Claude / Gemini / Codex — without hand-editing JSON
|
thin launcher snippets for Claude / Gemini / Codex — without hand-editing JSON
|
||||||
and without ever putting a raw token in a launcher config.
|
and without ever putting a raw token in a launcher config.
|
||||||
|
|
||||||
Run: ``python gitea_config.py menu`` (or ``python gitea_config_menu.py``)
|
Run: ``./scripts/gitea-config-menu`` (or ``python gitea_config.py menu``).
|
||||||
|
|
||||||
Every side effect (stdin, secret prompt, stdout, keychain store, token
|
UX (#36): in an interactive TTY the top-level menu and profile chooser take a
|
||||||
resolution, HTTP) is injectable so the menu logic is testable without a real
|
single keypress (no Enter); Enter quits the main menu and cancels/back-outs of
|
||||||
keychain, terminal, or network.
|
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 sys
|
||||||
import json
|
import json
|
||||||
@@ -100,135 +102,320 @@ def launcher_snippets(profile_name, config_path):
|
|||||||
return {"claude": claude, "gemini": gemini, "codex": codex}
|
return {"claude": claude, "gemini": gemini, "codex": codex}
|
||||||
|
|
||||||
|
|
||||||
# ── Interactive prompts (thin wrappers over injected IO) ────────────────────────
|
# ── Injectable menu IO ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _prompt_profile(input_fn, secret_fn, out, keychain_set):
|
CLEAR_SEQUENCE = "\x1b[2J\x1b[H"
|
||||||
"""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.
|
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.
|
||||||
"""
|
"""
|
||||||
name = input_fn("Profile name (e.g. prgs-reviewer): ").strip()
|
|
||||||
if not gitea_config.is_valid_profile_name(name):
|
def __init__(self, *, read_key, read_line, write, is_tty=False,
|
||||||
raise gitea_config.ConfigError(f"invalid profile name {name!r}")
|
clear_enabled=False, clear_fn=None):
|
||||||
base_url = input_fn("Base URL (e.g. https://gitea.prgs.cc): ").strip()
|
self._read_key = read_key
|
||||||
username = input_fn("Gitea username: ").strip()
|
self._read_line = read_line
|
||||||
default_owner = input_fn("Default owner (optional): ").strip()
|
self._write = write
|
||||||
default_repo = input_fn("Default repo (optional): ").strip()
|
self.is_tty = is_tty
|
||||||
execution_profile = input_fn("Execution profile (optional): ").strip()
|
self.clear_enabled = clear_enabled
|
||||||
auth_type = input_fn("Auth type [keychain/env]: ").strip().lower()
|
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":
|
if auth_type == "keychain":
|
||||||
default_id = f"{name}-gitea-token"
|
default_id = f"{name}-gitea-token"
|
||||||
item_id = input_fn(f"Keychain item id [{default_id}]: ").strip() or default_id
|
item_id = io.read_line(f"Keychain item id [{default_id}]: ").strip() or default_id
|
||||||
store = input_fn("Store a token in the keychain now? [y/N]: ").strip().lower()
|
store = io.read_line("Store a token in the keychain now? [y/N]: ").strip().lower()
|
||||||
if store == "y":
|
if store == "y":
|
||||||
token = secret_fn("Gitea token (hidden): ")
|
token = secret_fn("Gitea token (hidden): ")
|
||||||
keychain_set(item_id, token) # token never echoed
|
keychain_set(item_id, token) # token never echoed
|
||||||
out(f"Stored token in keychain item '{item_id}'.")
|
io.out(f"Stored token in keychain item '{item_id}'.")
|
||||||
auth = gitea_config.keychain_auth(item_id)
|
auth = gitea_config.keychain_auth(item_id)
|
||||||
elif auth_type == "env":
|
elif auth_type == "env":
|
||||||
default_var = f"GITEA_TOKEN_{name.upper().replace('-', '_').replace('.', '_')}"
|
default_var = f"GITEA_TOKEN_{name.upper().replace('-', '_').replace('.', '_')}"
|
||||||
var = input_fn(f"Env var name [{default_var}]: ").strip() or default_var
|
var = io.read_line(f"Env var name [{default_var}]: ").strip() or default_var
|
||||||
out(f"Set {var} in the environment; its value is never stored here.")
|
io.out(f"Set {var} in the environment; its value is never stored here.")
|
||||||
auth = gitea_config.env_auth(var)
|
auth = gitea_config.env_auth(var)
|
||||||
else:
|
else:
|
||||||
raise gitea_config.ConfigError("auth type must be 'keychain' or 'env'")
|
raise gitea_config.ConfigError("auth type must be 'keychain' or 'env'")
|
||||||
|
|
||||||
profile = gitea_config.build_profile(
|
return gitea_config.build_profile(
|
||||||
base_url=base_url, auth=auth, username=username,
|
base_url=base_url, auth=auth, username=username,
|
||||||
default_owner=default_owner, default_repo=default_repo,
|
default_owner=default_owner, default_repo=default_repo,
|
||||||
execution_profile=execution_profile,
|
execution_profile=execution_profile,
|
||||||
)
|
)
|
||||||
return name, profile
|
|
||||||
|
|
||||||
|
|
||||||
_MENU = """
|
def main(argv=None, *, io=None, secret_fn=None, config_path=None,
|
||||||
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,
|
resolve_token=gitea_config.resolve_token,
|
||||||
api_request=gitea_auth.api_request,
|
api_request=gitea_auth.api_request,
|
||||||
keychain_set=gitea_config.keychain_set):
|
keychain_set=gitea_config.keychain_set):
|
||||||
"""Run the interactive menu loop. Returns a process exit code."""
|
"""Run the interactive menu loop. Returns a process exit code."""
|
||||||
|
io = io or default_io()
|
||||||
secret_fn = secret_fn or getpass.getpass
|
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
|
path = config_path or gitea_config.config_path() or gitea_config.DEFAULT_CONFIG_PATH
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = gitea_config.load_config(path) or gitea_config.empty_config()
|
config = gitea_config.load_config(path) or gitea_config.empty_config()
|
||||||
except gitea_config.ConfigError as exc:
|
except gitea_config.ConfigError as exc:
|
||||||
out(f"config error: {exc}")
|
io.out(f"config error: {exc}")
|
||||||
config = gitea_config.empty_config()
|
config = gitea_config.empty_config()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
out(_MENU)
|
key = choose_menu_option(io)
|
||||||
choice = input_fn("choice: ").strip()
|
if key in ("", "0"): # Enter or 0 → quit
|
||||||
|
io.out("bye")
|
||||||
|
return 0
|
||||||
try:
|
try:
|
||||||
if choice == "0":
|
if key == "1":
|
||||||
out("bye")
|
|
||||||
return 0
|
|
||||||
elif choice == "1":
|
|
||||||
names = sorted(config.get("profiles") or {})
|
names = sorted(config.get("profiles") or {})
|
||||||
out("profiles: " + (", ".join(names) if names else "(none)"))
|
clear_screen(io)
|
||||||
elif choice == "2":
|
io.out("profiles: " + (", ".join(names) if names else "(none)"))
|
||||||
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
|
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)
|
config = gitea_config.add_profile(config, name, profile)
|
||||||
saved = gitea_config.save_config(config, path)
|
saved = gitea_config.save_config(config, path)
|
||||||
out(f"added '{name}'; wrote {saved}")
|
io.out(f"added '{name}'; wrote {saved}")
|
||||||
elif choice == "3":
|
pause_for_key(io)
|
||||||
name, profile = _prompt_profile(input_fn, secret_fn, out, keychain_set)
|
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)
|
config = gitea_config.upsert_profile(config, name, profile)
|
||||||
saved = gitea_config.save_config(config, path)
|
saved = gitea_config.save_config(config, path)
|
||||||
out(f"saved '{name}'; wrote {saved}")
|
io.out(f"saved '{name}'; wrote {saved}")
|
||||||
elif choice == "4":
|
pause_for_key(io)
|
||||||
name = input_fn("profile to remove: ").strip()
|
elif key == "4":
|
||||||
|
name = choose_profile(io, config, "profile to remove")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
config = gitea_config.remove_profile(config, name)
|
config = gitea_config.remove_profile(config, name)
|
||||||
saved = gitea_config.save_config(config, path)
|
saved = gitea_config.save_config(config, path)
|
||||||
out(f"removed '{name}'; wrote {saved}")
|
io.out(f"removed '{name}'; wrote {saved}")
|
||||||
elif choice == "5":
|
pause_for_key(io)
|
||||||
|
elif key == "5":
|
||||||
problems = gitea_config.validate_config(config)
|
problems = gitea_config.validate_config(config)
|
||||||
out("valid" if not problems else "problems:\n " + "\n ".join(problems))
|
clear_screen(io)
|
||||||
elif choice in ("6", "7"):
|
io.out("valid" if not problems
|
||||||
name = input_fn("profile: ").strip()
|
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)
|
profile = gitea_config.select_profile(config, name)
|
||||||
who = resolve_identity(profile, resolve_token=resolve_token,
|
who = resolve_identity(profile, resolve_token=resolve_token,
|
||||||
api_request=api_request)
|
api_request=api_request)
|
||||||
out(f"authenticated as: {who}")
|
io.out(f"authenticated as: {who}")
|
||||||
elif choice == "8":
|
pause_for_key(io)
|
||||||
name = input_fn("profile: ").strip()
|
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():
|
for client, snippet in launcher_snippets(name, path).items():
|
||||||
out(f"--- {client} ---\n{snippet}")
|
io.out(f"--- {client} ---\n{snippet}")
|
||||||
elif choice == "9":
|
pause_for_key(io)
|
||||||
name = input_fn("profile: ").strip()
|
elif key == "9":
|
||||||
pr_number = input_fn("PR number: ").strip()
|
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)
|
profile = gitea_config.select_profile(config, name)
|
||||||
result = check_eligibility(
|
result = check_eligibility(
|
||||||
profile, pr_number, resolve_token=resolve_token,
|
profile, pr_number, resolve_token=resolve_token,
|
||||||
api_request=api_request)
|
api_request=api_request)
|
||||||
out(f"authenticated user: {result['authenticated_user']}")
|
io.out(f"authenticated user: {result['authenticated_user']}")
|
||||||
out(f"PR author: {result['pr_author']}")
|
io.out(f"PR author: {result['pr_author']}")
|
||||||
out("ELIGIBLE" if result["eligible"] else "INELIGIBLE")
|
io.out("ELIGIBLE" if result["eligible"] else "INELIGIBLE")
|
||||||
for r in result["reasons"]:
|
for r in result["reasons"]:
|
||||||
out(f" - {r}")
|
io.out(f" - {r}")
|
||||||
|
pause_for_key(io)
|
||||||
else:
|
else:
|
||||||
out("unknown choice")
|
io.out("unknown choice")
|
||||||
|
pause_for_key(io)
|
||||||
except gitea_config.ConfigError as exc:
|
except gitea_config.ConfigError as exc:
|
||||||
out(f"error: {exc}")
|
io.out(f"error: {exc}")
|
||||||
|
pause_for_key(io)
|
||||||
except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets
|
except Exception as exc: # noqa: BLE001 - keep the menu alive, no secrets
|
||||||
out(f"error: {type(exc).__name__}")
|
io.out(f"error: {type(exc).__name__}")
|
||||||
|
pause_for_key(io)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
+169
-41
@@ -238,57 +238,185 @@ class TestIdentityAndEligibility(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prompt flow + full menu loop (no real IO/keychain/network)
|
# UX: single-key nav, Enter back-out, profile chooser, clear, pause (#36)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestMenuFlow(unittest.TestCase):
|
class _FakeIO:
|
||||||
|
"""Duck-typed MenuIO: scripted keys/lines, captured output, clear counter.
|
||||||
|
|
||||||
def test_prompt_keychain_stores_but_profile_has_no_token(self):
|
read_key/read_line pop from their queues; when a queue is exhausted they
|
||||||
stored = {}
|
return "" (Enter/EOF → cancel/quit), so a script can never hang the loop.
|
||||||
answers = ["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
"""
|
||||||
"Org", "Repo", "personal-prgs", "keychain",
|
|
||||||
"prgs-reviewer-token", "y"]
|
|
||||||
out = _Out()
|
|
||||||
name, profile = menu._prompt_profile(
|
|
||||||
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=out,
|
|
||||||
keychain_set=lambda i, t: stored.update({i: t}))
|
|
||||||
self.assertEqual(name, "prgs-reviewer")
|
|
||||||
self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"})
|
|
||||||
self.assertNotIn(SECRET, json.dumps(profile)) # token not in profile
|
|
||||||
self.assertNotIn(SECRET, out.text) # token not printed
|
|
||||||
self.assertEqual(stored, {"prgs-reviewer-token": SECRET}) # stored via keychain
|
|
||||||
|
|
||||||
def test_prompt_env_reference(self):
|
def __init__(self, keys=(), lines=(), is_tty=False, clear_enabled=None):
|
||||||
answers = ["prgs-author", "https://gitea.prgs.cc", "jcwalker3",
|
self.keys = list(keys)
|
||||||
"", "", "personal-prgs", "env", ""] # blank -> default var name
|
self.lines = list(lines)
|
||||||
name, profile = menu._prompt_profile(
|
self.out_lines = []
|
||||||
_InputQueue(answers), secret_fn=lambda _p: SECRET, out=_Out(),
|
self.clears = 0
|
||||||
keychain_set=lambda *a: None)
|
self.is_tty = is_tty
|
||||||
self.assertEqual(profile["auth"], {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"})
|
self.clear_enabled = is_tty if clear_enabled is None else clear_enabled
|
||||||
|
|
||||||
def test_menu_add_then_quit_writes_config_without_secrets(self):
|
def read_key(self, prompt=""):
|
||||||
|
return self.keys.pop(0) if self.keys else ""
|
||||||
|
|
||||||
|
def read_line(self, prompt=""):
|
||||||
|
return self.lines.pop(0) if self.lines else ""
|
||||||
|
|
||||||
|
def out(self, text=""):
|
||||||
|
self.out_lines.append(str(text))
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
if self.clear_enabled:
|
||||||
|
self.clears += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self):
|
||||||
|
return "\n".join(self.out_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(*names):
|
||||||
|
cfg = gitea_config.empty_config()
|
||||||
|
for n in names:
|
||||||
|
cfg["profiles"][n] = {"base_url": "https://x",
|
||||||
|
"auth": gitea_config.env_auth("V")}
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestChooser(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_menu_single_key_and_clears(self):
|
||||||
|
io = _FakeIO(keys=["3"], is_tty=True)
|
||||||
|
self.assertEqual(menu.choose_menu_option(io), "3") # one keypress, no Enter
|
||||||
|
self.assertEqual(io.clears, 1) # cleared before draw
|
||||||
|
self.assertIn("Gitea MCP profile setup", io.text)
|
||||||
|
|
||||||
|
def test_profile_chooser_lists_and_selects_by_key(self):
|
||||||
|
io = _FakeIO(keys=["2"])
|
||||||
|
self.assertEqual(menu.choose_profile(io, _cfg("aaa", "bbb", "ccc")), "bbb")
|
||||||
|
self.assertIn("1) aaa", io.text)
|
||||||
|
self.assertIn("2) bbb", io.text)
|
||||||
|
|
||||||
|
def test_profile_chooser_enter_cancels(self):
|
||||||
|
io = _FakeIO(keys=[""])
|
||||||
|
self.assertIsNone(menu.choose_profile(io, _cfg("aaa")))
|
||||||
|
|
||||||
|
def test_profile_chooser_no_profiles(self):
|
||||||
|
io = _FakeIO(keys=[])
|
||||||
|
self.assertIsNone(menu.choose_profile(io, gitea_config.empty_config()))
|
||||||
|
self.assertIn("no profiles", io.text)
|
||||||
|
|
||||||
|
def test_profile_chooser_manual_entry(self):
|
||||||
|
io = _FakeIO(keys=["m"], lines=["typed-name"])
|
||||||
|
self.assertEqual(menu.choose_profile(io, _cfg("aaa")), "typed-name")
|
||||||
|
|
||||||
|
def test_profile_chooser_out_of_range_key(self):
|
||||||
|
io = _FakeIO(keys=["9"])
|
||||||
|
self.assertIsNone(menu.choose_profile(io, _cfg("aaa")))
|
||||||
|
self.assertIn("invalid selection", io.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearAndPause(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_clear_only_when_enabled(self):
|
||||||
|
off = _FakeIO(keys=["x"], clear_enabled=False)
|
||||||
|
menu.choose_menu_option(off)
|
||||||
|
self.assertEqual(off.clears, 0)
|
||||||
|
on = _FakeIO(keys=["x"], clear_enabled=True)
|
||||||
|
menu.choose_menu_option(on)
|
||||||
|
self.assertEqual(on.clears, 1)
|
||||||
|
|
||||||
|
def test_pause_does_not_read_in_non_tty(self):
|
||||||
|
io = _FakeIO(keys=["a"], is_tty=False)
|
||||||
|
menu.pause_for_key(io)
|
||||||
|
self.assertEqual(io.keys, ["a"]) # nothing consumed, no hang
|
||||||
|
|
||||||
|
def test_pause_reads_one_key_in_tty(self):
|
||||||
|
io = _FakeIO(keys=["a"], is_tty=True)
|
||||||
|
menu.pause_for_key(io)
|
||||||
|
self.assertEqual(io.keys, []) # consumed the keypress
|
||||||
|
|
||||||
|
def test_default_io_non_tty_line_fallback(self):
|
||||||
|
import io as _io
|
||||||
|
with patch("gitea_config_menu.sys.stdin", _io.StringIO("7\nrest\n")):
|
||||||
|
mio = menu.default_io()
|
||||||
|
self.assertFalse(mio.is_tty)
|
||||||
|
self.assertEqual(mio.read_key(), "7") # first char of the line
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuNav(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_main_enter_quits(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
io = _FakeIO(keys=[""]) # Enter at main menu
|
||||||
|
rc = menu.main(io=io, config_path=os.path.join(d, "p.json"),
|
||||||
|
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None)
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("bye", io.text)
|
||||||
|
|
||||||
|
def test_main_zero_quits(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
io = _FakeIO(keys=["0"])
|
||||||
|
self.assertEqual(
|
||||||
|
menu.main(io=io, config_path=os.path.join(d, "p.json"),
|
||||||
|
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None), 0)
|
||||||
|
|
||||||
|
def test_submenu_enter_cancels_without_change(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = os.path.join(d, "p.json")
|
||||||
|
gitea_config.save_config(_cfg("keep-me"), path)
|
||||||
|
io = _FakeIO(keys=["4", ""]) # remove → chooser Enter cancels → quit
|
||||||
|
menu.main(io=io, config_path=path,
|
||||||
|
secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None)
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
self.assertIn("keep-me", fh.read()) # not removed
|
||||||
|
|
||||||
|
|
||||||
|
class TestMenuAddFlow(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_add_then_quit_writes_config_without_secrets(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
path = os.path.join(d, "profiles.json")
|
path = os.path.join(d, "profiles.json")
|
||||||
answers = [
|
io = _FakeIO(
|
||||||
"2", # add profile
|
keys=["2", ""], # add profile, then Enter → quit
|
||||||
"prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
lines=["prgs-reviewer", "https://gitea.prgs.cc", "jcwalker3",
|
||||||
"Org", "Repo", "personal-prgs", "keychain",
|
"Org", "Repo", "personal-prgs", "keychain",
|
||||||
"prgs-reviewer-token", "y",
|
"prgs-reviewer-token", "y"],
|
||||||
"1", # list
|
)
|
||||||
"0", # quit
|
rc = menu.main(io=io, config_path=path, secret_fn=lambda _p: SECRET,
|
||||||
]
|
keychain_set=lambda *a: None)
|
||||||
out = _Out()
|
|
||||||
rc = menu.main(
|
|
||||||
input_fn=_InputQueue(answers), secret_fn=lambda _p: SECRET,
|
|
||||||
out=out, config_path=path, keychain_set=lambda *a: None)
|
|
||||||
self.assertEqual(rc, 0)
|
self.assertEqual(rc, 0)
|
||||||
self.assertTrue(os.path.isfile(path))
|
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
body = fh.read()
|
body = fh.read()
|
||||||
self.assertIn("prgs-reviewer", body)
|
self.assertIn("prgs-reviewer", body)
|
||||||
self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret)
|
self.assertIn("prgs-reviewer-token", body) # keychain id (non-secret)
|
||||||
self.assertNotIn(SECRET, body) # never the token value
|
self.assertNotIn(SECRET, body) # token value never on disk
|
||||||
self.assertNotIn(SECRET, out.text)
|
self.assertNotIn(SECRET, io.text) # nor printed
|
||||||
self.assertIn("prgs-reviewer", out.text) # listed
|
|
||||||
|
def test_prompt_fields_keychain_no_token_leak(self):
|
||||||
|
stored = {}
|
||||||
|
io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "Org", "Repo",
|
||||||
|
"personal-prgs", "keychain", "prgs-reviewer-token", "y"])
|
||||||
|
profile = menu._prompt_profile_fields(
|
||||||
|
io, secret_fn=lambda _p: SECRET,
|
||||||
|
keychain_set=lambda i, t: stored.update({i: t}), name="prgs-reviewer")
|
||||||
|
self.assertEqual(profile["auth"], {"type": "keychain", "id": "prgs-reviewer-token"})
|
||||||
|
self.assertNotIn(SECRET, json.dumps(profile))
|
||||||
|
self.assertNotIn(SECRET, io.text)
|
||||||
|
self.assertEqual(stored, {"prgs-reviewer-token": SECRET})
|
||||||
|
|
||||||
|
def test_prompt_fields_env_reference(self):
|
||||||
|
io = _FakeIO(lines=["https://gitea.prgs.cc", "jcwalker3", "", "",
|
||||||
|
"personal-prgs", "env", ""])
|
||||||
|
profile = menu._prompt_profile_fields(
|
||||||
|
io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None,
|
||||||
|
name="prgs-author")
|
||||||
|
self.assertEqual(profile["auth"],
|
||||||
|
{"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"})
|
||||||
|
|
||||||
|
def test_prompt_fields_blank_base_url_cancels(self):
|
||||||
|
io = _FakeIO(lines=[""]) # blank Base URL → cancel
|
||||||
|
self.assertIsNone(menu._prompt_profile_fields(
|
||||||
|
io, secret_fn=lambda _p: SECRET, keychain_set=lambda *a: None,
|
||||||
|
name="x"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user