#!/usr/bin/env python3 """Interactive browse loop: fzf → pager → action menu → back to fzf. Flow: 1. Present filtered tickets in fzf (or numbered list fallback) 2. On select: open ticket Markdown in glow/bat/less 3. After pager exits: action menu for that ticket 4. Return to list until user quits (Esc/q) Actions queue local drafts by default (same safety model as CLI); optional apply is left to `draft apply` so browse never silently emails/writes prod. """ from __future__ import annotations import json import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Any, Callable def nested_name(issue: dict[str, Any], key: str) -> str: obj = issue.get(key) if isinstance(obj, dict): return str(obj.get("name") or "") return "" def issue_line(issue: dict[str, Any]) -> str: iid = issue.get("id") status = nested_name(issue, "status") or "" assignee = nested_name(issue, "assigned_to") or "" subj = str(issue.get("subject") or "").replace("\n", " ").replace("\t", " ")[:90] tags = issue.get("_control_tags") tag_s = ",".join(tags) if isinstance(tags, list) and tags else "" return f"{iid}\t{status}\t{assignee}\t{tag_s}\t{subj}" def build_lines(issues: list[dict[str, Any]]) -> list[str]: return [issue_line(i) for i in issues if i.get("id") is not None] def parse_id_from_line(line: str) -> int | None: line = (line or "").strip() if not line: return None head = line.split("\t", 1)[0].strip() try: return int(head) except ValueError: return None # preview helper written at runtime under /tmp by pick_with_fzf def pick_with_fzf( lines: list[str], *, query: str | None = None, cache_dir: Path | None = None, project: str | None = None, preview: bool = True, ) -> int | None: fzf = shutil.which("fzf") if not fzf: return None header = ( "Enter=open · Esc=quit · after open: n/p note · f fields · t tag · " "c contact · e entities · m NMI brief · i id · l list · q quit" ) cmd = [ fzf, "--height=90%", "--reverse", "--prompt=ticket> ", f"--header={header}", "--info=inline", "--bind=ctrl-c:abort", ] preview_script: Path | None = None if preview and cache_dir is not None and project: # Write a tiny preview helper so quoting stays sane preview_script = Path(tempfile.gettempdir()) / "rtl_browse_preview.py" preview_script.write_text( "#!/usr/bin/env python3\n" "import json, pathlib, sys\n" f"cache = pathlib.Path({str(Path(cache_dir))!r})\n" f"proj = {project!r}\n" "line = sys.argv[1] if len(sys.argv) > 1 else ''\n" "iid = line.split('\\t')[0].strip()\n" "md = cache / proj / 'markdown' / f'{iid}.md'\n" "jp = cache / proj / f'{iid}.json'\n" "print(f'# ticket {iid}')\n" "print()\n" "if md.is_file():\n" " print(md.read_text(encoding='utf-8')[:3500])\n" "elif jp.is_file():\n" " d = json.loads(jp.read_text(encoding='utf-8'))\n" " print(d.get('subject') or '')\n" " print()\n" " print(str(d.get('description') or '')[:2500])\n" "else:\n" " print('(no preview)')\n", encoding="utf-8", ) cmd.extend( [ "--preview", f"python3 {preview_script} {{}}", "--preview-window=right,50%,wrap", ] ) if query: cmd.extend(["--query", query]) try: proc = subprocess.run( cmd, input="\n".join(lines) + "\n", text=True, capture_output=True, ) finally: # leave preview script; cheap and reusable pass if proc.returncode != 0 or not proc.stdout.strip(): return None return parse_id_from_line(proc.stdout.strip().splitlines()[-1]) def pick_numbered(lines: list[str]) -> int | None: for i, line in enumerate(lines, 1): print(f"{i:3d}. {line}") if not sys.stdin.isatty(): return None raw = input("Pick # (or q to quit list): ").strip() if raw.lower() in {"q", "quit", ""}: return None try: idx = int(raw) except ValueError: print(f"Invalid selection: {raw!r}", file=sys.stderr) return None if idx < 1 or idx > len(lines): print("Out of range.", file=sys.stderr) return None return parse_id_from_line(lines[idx - 1]) def page_ticket_markdown( issue: dict[str, Any], *, cache_dir: Path, project: str, pager: str = "auto", ) -> None: from redmine_ticket_view import display_markdown_file, render_ticket_markdown, write_ticket_markdown try: path = write_ticket_markdown(issue, Path(cache_dir), project) display_markdown_file(path, pager=pager) except Exception: md = render_ticket_markdown(issue) with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as fh: fh.write(md) tmp = Path(fh.name) try: display_markdown_file(tmp, pager=pager) finally: try: tmp.unlink(missing_ok=True) except OSError: pass def prompt_multiline(label: str) -> str | None: print(f"{label} (end with a single '.' line, or empty first line to cancel):") lines: list[str] = [] while True: try: line = input() except EOFError: break if line == ".": break if not lines and line.strip() == "": return None lines.append(line) text = "\n".join(lines).strip() return text or None def announce_draft(path: Path) -> None: """Louder draft feedback for browse UX.""" draft_id = path.stem print() print("=" * 48) print(f" DRAFT QUEUED draft_id={draft_id}") print(f" path={path}") print(" apply later: draft apply " + draft_id) print(" (browse never auto-applies / never emails customers)") print("=" * 48) print() def action_menu( issue_id: int, issue: dict[str, Any], *, handlers: dict[str, Callable[[], None]], ) -> str: """Show action menu. Returns 'list' | 'quit' | 'view' | 'menu'.""" subj = str(issue.get("subject") or "")[:70] print() print(f"── ticket #{issue_id} ── {subj}") print(" [Enter/v] view again") print(" [n] queue internal note (no email)") print(" [p] queue private internal note") print(" [f] queue field update (status/assignee name)") print(" [t] local control-tag add") print(" [c] show ticket-contact handoff") print(" [e] extract entities (patterns)") print(" [m] NMI brief → queue as PRIVATE note draft") print(" [i] print issue id only") print(" [l] back to list") print(" [q] quit browse") raw = input("action> ").strip().lower() if raw in {"", "v", "view"}: return "view" if raw in {"l", "list", "b", "back"}: return "list" if raw in {"q", "quit"}: return "quit" if raw in handlers: try: handlers[raw]() except Exception as exc: # noqa: BLE001 — surface in TUI print(f"action failed: {exc}", file=sys.stderr) input("press Enter to continue…") return "menu" print(f"Unknown action: {raw!r}", file=sys.stderr) return "menu" def run_browse_loop( issues: list[dict[str, Any]], *, cache_dir: Path, project: str, pager: str = "auto", once: bool = False, preview: bool = True, action_factory: Callable[[int, dict[str, Any]], dict[str, Callable[[], None]]] | None = None, ) -> int: """Main browse loop. Returns process exit code.""" if not issues: print("No matching tickets.", file=sys.stderr) return 0 lines = build_lines(issues) by_id = {int(i["id"]): i for i in issues if i.get("id") is not None} use_fzf = bool(shutil.which("fzf") and sys.stdin.isatty()) if not sys.stdin.isatty(): for line in lines: print(line) return 0 while True: if use_fzf: selected = pick_with_fzf( lines, cache_dir=Path(cache_dir), project=project, preview=preview, ) else: selected = pick_numbered(lines) if selected is None: print("browse done", file=sys.stderr) return 0 issue = by_id.get(selected) if issue is None: print(f"#{selected} not in current result set", file=sys.stderr) if once: return 1 continue page_ticket_markdown(issue, cache_dir=cache_dir, project=project, pager=pager) if once: return 0 while True: handlers: dict[str, Callable[[], None]] = {} if action_factory: handlers = action_factory(selected, issue) choice = action_menu(selected, issue, handlers=handlers) if choice == "view": page_ticket_markdown(issue, cache_dir=cache_dir, project=project, pager=pager) continue if choice == "list": break if choice == "quit": print("browse done", file=sys.stderr) return 0 continue