Organized
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user