Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
+312
View File
@@ -0,0 +1,312 @@
#!/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