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:
2026-07-02 02:34:16 -04:00
parent 5272e071e1
commit 69d4edf37d
3 changed files with 439 additions and 119 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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__":