Files
redmine/redmine_ticket_tui.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

1142 lines
49 KiB
Python

#!/usr/bin/env python3
"""Curses-based fast TUI for batch ticket processing.
Designed for the requested workflow:
- Set a "current template" once (category / assignee / status / numeric priority / project / local control tag / contact tags / private note).
- e = entity extract (toggles + shows pattern hits immediately in status, e.g. tracking/order/SKU).
- m = NMI brief (marks for real generation; on A/commit it runs brief_for_issue + formats a useful private note instead of placeholder).
- Scroll or filter the list of tickets.
- SPACE on a row to paint the current template (including e/m flags) onto it (queues per-ticket PendingChanges).
- One key (A) to commit+auto-apply everything (one confirmation). Real entity lines and NMI briefs are generated and queued as private notes at this point.
- No need to run separate "draft apply" per ticket.
Uses only stdlib curses. Pickers support typing to filter.
Usage:
./bin/rtl tui --where 'assigned_to IS NULL' --max-tickets 60 --open-only
"""
from __future__ import annotations
import argparse
import curses
import json
import os
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
# Try to pull in existing helpers (graceful if not importable during dev)
try:
from redmine_ticket_meta import (
load_meta_lists,
refresh_meta_cache,
resolve_field_names,
)
except Exception: # pragma: no cover
load_meta_lists = None # type: ignore[assignment]
refresh_meta_cache = None # type: ignore[assignment]
resolve_field_names = None # type: ignore[assignment]
try:
from redmine_ticket_drafts import (
KIND_CONTACT_FIELDS,
KIND_FIELDS,
KIND_INTERNAL_NOTE,
add_draft,
assert_write_target_allowed,
load_draft,
mark_draft_applied,
mark_draft_error,
)
except Exception: # pragma: no cover
KIND_FIELDS = "fields"
KIND_INTERNAL_NOTE = "internal_note"
KIND_CONTACT_FIELDS = "contact_fields"
add_draft = None # type: ignore[assignment]
assert_write_target_allowed = None # type: ignore[assignment]
load_draft = None # type: ignore[assignment]
mark_draft_applied = None # type: ignore[assignment]
mark_draft_error = None # type: ignore[assignment]
# Do not import redmine_tickets here. The CLI imports this module to register
# `tui`, and importing the CLI back here created an order-dependent circular
# import: sometimes the parser had no `tui` command, sometimes the launcher had
# missing helpers. Imports from the parent CLI happen only inside the action
# that needs them, after startup is complete.
# Local control tag helpers
try:
from redmine_ticket_control_tags import add_tag as control_tag_add
except Exception:
control_tag_add = None # type: ignore[assignment]
# Entity extraction (for "e" key)
try:
from redmine_ticket_entities import extract_entities_from_issue, format_entities_line
except Exception: # pragma: no cover
extract_entities_from_issue = None
format_entities_line = None
# NMI brief generation (for "m" key) — shells out to bin/nmi
try:
from redmine_ticket_nmi import (
brief_for_issue,
format_private_note,
resolve_runtime_profile,
)
except Exception: # pragma: no cover
brief_for_issue = None
format_private_note = None
resolve_runtime_profile = None
@dataclass
class PendingChanges:
"""Per-ticket queued changes in this TUI session."""
issue_id: int
fields: dict[str, Any] = field(default_factory=dict) # Redmine fields payload
private_note: Optional[str] = None
private: bool = True
control_tags: list[str] = field(default_factory=list)
contact_tags: list[str] = field(default_factory=list)
entity_extract: bool = False
nmi_brief: bool = False
contact_id: Optional[int] = None
@dataclass
class Template:
"""Global current template the user is "painting" onto tickets."""
status_id: Optional[int] = None
status_name: Optional[str] = None
assigned_to_id: Optional[int] = None
assigned_to_name: Optional[str] = None
priority_id: Optional[int] = None
priority_name: Optional[str] = None
category_id: Optional[int] = None
category_name: Optional[str] = None
project_id: Optional[int] = None # for move
project_name: Optional[str] = None
local_control_tag: Optional[str] = None
contact_tags: list[str] = field(default_factory=list)
private_note: Optional[str] = None
entity_extract: bool = False
nmi_brief: bool = False
def to_summary(self) -> str:
parts = []
if self.category_name:
parts.append(f"cat:{self.category_name}")
if self.assigned_to_name:
parts.append(f"to:{self.assigned_to_name}")
if self.status_name:
parts.append(f"st:{self.status_name}")
if self.priority_id is not None:
parts.append(f"pri:{self.priority_id}")
if self.project_name:
parts.append(f"proj:{self.project_name}")
if self.local_control_tag:
parts.append(f"tag:{self.local_control_tag}")
if self.contact_tags:
parts.append(f"ctags:{','.join(self.contact_tags)}")
if self.private_note:
parts.append("note")
if self.entity_extract:
parts.append("entity")
if self.nmi_brief:
parts.append("nmi")
return " | ".join(parts) or "(empty template)"
def _safe_get(d: dict, *keys: str, default: Any = None) -> Any:
for k in keys:
if isinstance(d, dict) and k in d:
d = d[k]
else:
return default
return d
def extract_contact_id(issue: dict[str, Any]) -> Optional[int]:
ht = issue.get("helpdesk_ticket") or {}
if isinstance(ht, dict):
c = ht.get("contact") or {}
if isinstance(c, dict):
cid = c.get("id")
if cid is not None:
try:
return int(cid)
except (ValueError, TypeError):
pass
return None
def issue_display_line(issue: dict[str, Any], pending: Optional[PendingChanges] = None) -> str:
iid = issue.get("id")
status = _safe_get(issue, "status", "name", default="") or ""
assignee = _safe_get(issue, "assigned_to", "name", default="") or ""
cat = _safe_get(issue, "category", "name", default="") or ""
subj = str(issue.get("subject") or "").replace("\n", " ").replace("\t", " ")[:70]
marker = ""
if pending:
bits = []
if pending.fields:
bits.append("F")
if pending.private_note:
bits.append("N")
if pending.control_tags or pending.contact_tags:
bits.append("T")
if pending.entity_extract:
bits.append("E")
if pending.nmi_brief:
bits.append("M")
if bits:
marker = "[" + "".join(bits) + "] "
return f"{iid} {status[:10]:<10} {assignee[:12]:<12} {cat[:12]:<12} {marker}{subj}"
class RedmineTUI:
def __init__(
self,
issues: list[dict[str, Any]],
*,
cache_dir: Path,
project: str,
base_url: str,
allow_prod_writes: bool = False,
auto_apply: bool = False, # dangerous; off by default
impersonate: str = "rebot",
):
self.issues = issues
self.cache_dir = Path(cache_dir)
self.project = project
self.base_url = base_url
self.allow_prod_writes = allow_prod_writes
self.auto_apply = auto_apply
self.impersonate = impersonate
self.pending: dict[int, PendingChanges] = {}
self.template = Template()
self.current_idx = 0
self.filter_text = ""
self.filtered_indices: list[int] = list(range(len(issues)))
self.status_msg = "Ready. ? = help | e=entities (shows extract immediately) | m=NMI brief (real on A) | SPACE=paint | A=commit+apply"
self.meta_lists: dict[str, list[dict[str, Any]]] = {}
self.client: Optional[Any] = None # lazy
# Try load meta
if load_meta_lists:
try:
self.meta_lists = load_meta_lists(self.cache_dir, self.project)
except Exception:
self.meta_lists = {}
def _get_client(self) -> Any:
if self.client is None:
# Lazy; only if we need to refresh or write
try:
from redmine_tickets import client_from_args # type: ignore
# Fake minimal args
ns = argparse.Namespace(
base_url=self.base_url,
project=self.project,
cache_dir=self.cache_dir,
)
self.client = client_from_args(ns) # type: ignore
except Exception as e:
self.status_msg = f"Client init failed (writes may be limited): {e}"
return self.client
def refresh_meta(self, force: bool = False) -> None:
if not refresh_meta_cache:
self.status_msg = "Meta refresh helpers unavailable"
return
try:
cli = self._get_client()
if cli:
refresh_meta_cache(cli, self.cache_dir, self.project, force=force)
self.meta_lists = load_meta_lists(self.cache_dir, self.project) or {}
self.status_msg = "Meta refreshed"
except Exception as e:
self.status_msg = f"Meta refresh error: {e}"
def _current_issue(self) -> Optional[dict[str, Any]]:
if not self.filtered_indices or self.current_idx >= len(self.filtered_indices):
return None
real_idx = self.filtered_indices[self.current_idx]
return self.issues[real_idx]
def _current_pending(self) -> Optional[PendingChanges]:
issue = self._current_issue()
if not issue:
return None
iid = int(issue["id"])
return self.pending.get(iid)
def _replace_issue_in_list(self, issue_id: int, fresh: dict[str, Any]) -> None:
"""Replace the in-memory copy of an issue so the current TUI session reflects live data."""
for i, iss in enumerate(self.issues):
if int(iss.get("id", -1)) == int(issue_id):
self.issues[i] = fresh
break
# Rebuild filtered view if we have a filter
if self.filter_text:
self.apply_filter()
else:
# keep the filtered_indices valid
pass
def apply_template_to_current(self) -> None:
issue = self._current_issue()
if not issue:
return
iid = int(issue.get("id"))
contact_id = extract_contact_id(issue)
# Build fields from template
fields: dict[str, Any] = {}
if self.template.status_id is not None:
fields["status_id"] = self.template.status_id
if self.template.assigned_to_id is not None:
fields["assigned_to_id"] = None if self.template.assigned_to_id == 0 else self.template.assigned_to_id
if self.template.priority_id is not None:
fields["priority_id"] = self.template.priority_id
if self.template.category_id is not None:
fields["category_id"] = None if self.template.category_id == 0 else self.template.category_id
if self.template.project_id is not None:
fields["project_id"] = self.template.project_id
ch = self.pending.get(iid) or PendingChanges(issue_id=iid, contact_id=contact_id)
if fields:
ch.fields = {**ch.fields, **fields} # merge
if self.template.private_note:
ch.private_note = self.template.private_note
ch.private = True
if self.template.local_control_tag:
if self.template.local_control_tag not in ch.control_tags:
ch.control_tags.append(self.template.local_control_tag)
if self.template.contact_tags:
for t in self.template.contact_tags:
if t not in ch.contact_tags:
ch.contact_tags.append(t)
ch.entity_extract = ch.entity_extract or self.template.entity_extract
ch.nmi_brief = ch.nmi_brief or self.template.nmi_brief
ch.contact_id = contact_id or ch.contact_id
self.pending[iid] = ch
self.status_msg = f"Applied template to #{iid}. Pending: {len(self.pending)}"
def clear_template(self) -> None:
self.template = Template()
self.status_msg = "Template cleared"
def toggle_entity(self) -> None:
issue = self._current_issue()
if not issue:
return
iid = int(issue["id"])
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.entity_extract = not ch.entity_extract
self.pending[iid] = ch
if ch.entity_extract:
if extract_entities_from_issue:
try:
ents = extract_entities_from_issue(issue)
line = format_entities_line(ents) if format_entities_line and ents else (str(ents) if ents else "(none)")
self.status_msg = f"Entity extract ON for #{iid}: {line[:120]}"
except Exception:
self.status_msg = f"Entity extract ON for #{iid} (preview error)"
else:
self.status_msg = f"Entity extract ON for #{iid} (helpers unavailable)"
else:
self.status_msg = f"Entity extract OFF for #{iid}"
def toggle_nmi(self) -> None:
issue = self._current_issue()
if not issue:
return
iid = int(issue["id"])
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
ch.nmi_brief = not ch.nmi_brief
self.pending[iid] = ch
if ch.nmi_brief:
if resolve_runtime_profile is None:
self.status_msg = f"NMI brief ON for #{iid}, but the NMI runtime helper is unavailable"
return
profile = resolve_runtime_profile()
endpoint = profile.endpoint or "PHP-resolved endpoint"
if profile.ready:
self.status_msg = (
f"NMI brief ON for #{iid}: mode={profile.mode} env={profile.env_file} "
f"endpoint={endpoint}"
)
else:
self.status_msg = (
f"NMI brief BLOCKED for #{iid}: {profile.problem}. "
"Set NMI_ENV_FILE to a real configured env file."
)
else:
self.status_msg = f"NMI brief OFF for #{iid}"
def commit_all(self, stdscr: curses.window, *, apply_now: bool = False, auto_apply_drafts: bool = False) -> list[str]:
"""Queue (or apply) all pending changes.
Early special handling for entity_extract and nmi_brief is done first so that
ch.private_note is populated. The normal fields/note queuing block then creates
exactly one draft. This prevents the duplicate private notes problem.
"""
results: list[str] = []
if not self.pending:
results.append("No pending changes.")
return results
if add_draft is None:
results.append("Draft system unavailable — cannot queue.")
return results
if auto_apply_drafts:
if assert_write_target_allowed is not None:
try:
assert_write_target_allowed(self.base_url, allow_prod_writes=self.allow_prod_writes)
except RuntimeError as exc:
results.append(f"SAFETY BLOCK: {exc}")
return results
client = None
if apply_now or auto_apply_drafts:
try:
client = self._get_client()
except Exception as e:
results.append(f"Client unavailable: {e}")
if auto_apply_drafts:
return results
created_draft_ids: list[tuple[str, int]] = []
for iid, ch in list(self.pending.items()):
try:
# === Early special handling (e + m) - compute real data once ===
issue_for_special = None
for iss in self.issues:
if int(iss.get("id", -1)) == iid:
issue_for_special = iss
break
if issue_for_special is None:
try:
from redmine_tickets import TicketCache, load_cached_issue
c = TicketCache(self.cache_dir, self.project)
issue_for_special = load_cached_issue(c, iid)
except Exception:
pass
mode = os.environ.get("NMI_MODE", "dev")
env_file = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
runtime_profile = (
resolve_runtime_profile(mode=mode, env_file=env_file)
if resolve_runtime_profile is not None
else None
)
if runtime_profile is not None:
mode = runtime_profile.mode
env_file = str(runtime_profile.env_file)
if ch.entity_extract and issue_for_special and extract_entities_from_issue:
try:
ents = extract_entities_from_issue(issue_for_special)
line = format_entities_line(ents) if format_entities_line and ents else (str(ents) if ents else "(no hits)")
ent_note = f"Entities extracted: {line}"
if ch.private_note:
if "Entities extracted" not in ch.private_note:
ch.private_note = (ch.private_note + "\n" + ent_note).strip()
else:
ch.private_note = ent_note
ch.private = True
if control_tag_add:
try:
control_tag_add(self.cache_dir, tag="entity-extract", issue_id=iid, properties=["tui"], annotation="extracted via tui")
except Exception:
pass
results.append(f"#{iid} entity-extract: {line[:100]}")
except Exception as ee:
results.append(f"#{iid} entity error: {ee}")
if ch.nmi_brief and issue_for_special and brief_for_issue and format_private_note:
try:
ents = extract_entities_from_issue(issue_for_special) if extract_entities_from_issue else []
payload = brief_for_issue(issue_for_special, ents, mode=mode, env_file=env_file)
real_note = format_private_note(payload)
ch.private_note = real_note
ch.private = True
runtime = payload.get("runtime") or {}
runtime_label = (
f"mode={runtime.get('mode', mode)} "
f"env={runtime.get('env_file', env_file)} "
f"endpoint={runtime.get('endpoint') or 'PHP-resolved endpoint'}"
)
if not runtime.get("ready", True):
runtime_label += f" BLOCKED: {runtime.get('problem')}"
results.append(
f"#{iid} NMI brief generated (ok={payload.get('ok')}; {runtime_label})"
)
except Exception as ne:
results.append(f"#{iid} NMI generation error (env={env_file}): {ne}")
if not ch.private_note:
ch.private_note = f"[NMI brief attempted; lookup failed (env={env_file}). Run `rtl entities nmi --issue-id {iid} --apply-private-note` for details.]"
ch.private = True
elif ch.nmi_brief:
if not ch.private_note:
ch.private_note = "[NMI brief requested via TUI; NMI helpers/CLI not available in session]"
ch.private = True
results.append(f"#{iid} nmi_brief flag set (no generator)")
# === Normal queuing (this is what actually creates the draft - exactly once) ===
if ch.fields or ch.private_note:
fields = dict(ch.fields)
if ch.private_note:
fields["notes"] = ch.private_note
if ch.private:
fields["private_notes"] = True
if apply_now and client and not auto_apply_drafts:
client.update_issue(iid, fields, impersonate_user=self.impersonate)
results.append(f"#{iid} fields updated DIRECTLY")
try:
from redmine_tickets import TicketCache, refresh_cached_issue
c = TicketCache(self.cache_dir, self.project)
fresh = refresh_cached_issue(c, client, iid)
if fresh:
self._replace_issue_in_list(iid, fresh)
results.append(f"#{iid} cache synced from live")
except Exception:
pass
else:
kind = KIND_FIELDS if ch.fields else KIND_INTERNAL_NOTE
path = add_draft(
self.cache_dir,
issue_id=iid,
kind=kind,
body=ch.private_note or "",
fields=fields if ch.fields else None,
private=ch.private,
project=self.project,
base_url=self.base_url,
)
draft_id = path.stem
results.append(f"#{iid} draft queued: {draft_id}")
created_draft_ids.append((draft_id, iid))
# Local control tags (immediate)
if ch.control_tags and control_tag_add:
for tag in ch.control_tags:
try:
control_tag_add(self.cache_dir, tag=tag, issue_id=iid, properties=["tui-batch"], annotation="from fast-tui")
results.append(f"#{iid} local tag {tag}")
except Exception as te:
results.append(f"#{iid} tag {tag} error: {te}")
# Contact tags
if ch.contact_tags and ch.contact_id and add_draft:
for t in ch.contact_tags:
try:
path = add_draft(
self.cache_dir,
contact_id=ch.contact_id,
kind=KIND_CONTACT_FIELDS,
fields={"add_tag": t},
project=self.project,
base_url=self.base_url,
)
draft_id = path.stem
results.append(f"#{iid} contact tag draft {draft_id} for {ch.contact_id}: {t}")
created_draft_ids.append((draft_id, iid))
except Exception as ce:
results.append(f"#{iid} contact tag error: {ce}")
except Exception as exc:
results.append(f"#{iid} ERROR during queue: {exc}")
# Auto-apply path. Import only now: the parent CLI is fully initialized.
_apply_draft_to_redmine = None
if auto_apply_drafts and client:
try:
from redmine_tickets import apply_draft_to_redmine as _apply_draft_to_redmine
except Exception:
_apply_draft_to_redmine = None
if auto_apply_drafts and client and _apply_draft_to_redmine and load_draft and mark_draft_applied:
try:
stdscr.clear()
stdscr.addstr(2, 0, "Applying changes to Redmine... (this may take a moment)")
stdscr.refresh()
except Exception:
pass
for draft_id, iid in created_draft_ids:
try:
_, draft_data = load_draft(self.cache_dir, draft_id)
result = _apply_draft_to_redmine(client, draft_data, impersonate_user=self.impersonate, allow_customer_email=False)
mark_draft_applied(self.cache_dir, draft_id, result=result)
results.append(f"#{iid} APPLIED draft {draft_id}")
try:
from redmine_tickets import TicketCache, refresh_cached_issue
cobj = TicketCache(self.cache_dir, self.project)
ref = refresh_cached_issue(cobj, client, iid)
if ref:
results.append(f"#{iid} cache synced from live")
self._replace_issue_in_list(iid, ref)
except Exception:
pass
except Exception as ae:
if mark_draft_error:
try:
mark_draft_error(self.cache_dir, draft_id, str(ae))
except Exception:
pass
results.append(f"#{iid} APPLY FAILED for {draft_id}: {ae}")
if auto_apply_drafts:
results.append(f"Auto-applied changes for {len(self.pending)} ticket(s).")
elif apply_now:
results.append("Direct applies attempted (check safety).")
else:
results.append(f"Queued {len(self.pending)} ticket(s) as drafts.")
self.pending.clear()
return results
def picker(
self, stdscr: curses.window, title: str, items: list[tuple[int | str, str]], current: Any = None
) -> Optional[tuple[int | str, str]]:
"""Return (id, label) or None. items = [(id_or_0, "Human Name"), ...]"""
if not items:
self.status_msg = "No items for picker"
return None
filtered = list(items)
query = ""
sel = 0
for i, (iid, _lab) in enumerate(filtered):
if current is not None and iid == current:
sel = i
break
while True:
stdscr.clear()
h, w = stdscr.getmaxyx()
stdscr.addstr(0, 0, f"{title} (type to filter, Enter=choose, Esc=cancel)")
stdscr.addstr(1, 0, f"Filter: {query} ({len(filtered)}/{len(items)})")
start = max(0, sel - (h - 4) // 2)
for row, (iid, lab) in enumerate(filtered[start : start + h - 4]):
y = 3 + row
prefix = ">" if (start + row) == sel else " "
line = f"{prefix} {iid}: {lab}"[: w - 2]
if (start + row) == sel:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, 0, line)
if (start + row) == sel:
stdscr.attroff(curses.A_REVERSE)
stdscr.refresh()
ch = stdscr.getch()
if ch in (27, ord("q")): # ESC or q
return None
if ch in (curses.KEY_ENTER, 10, 13):
if filtered:
return filtered[sel]
return None
if ch in (curses.KEY_UP, ord("k")):
sel = max(0, sel - 1)
if ch in (curses.KEY_DOWN, ord("j")):
sel = min(len(filtered) - 1, sel + 1)
if ch in (curses.KEY_BACKSPACE, 127, 8):
query = query[:-1]
elif 32 <= ch <= 126:
query += chr(ch)
# Re-filter
if query:
q = query.lower()
filtered = [
(iid, lab) for iid, lab in items if q in str(lab).lower() or q in str(iid).lower()
]
else:
filtered = list(items)
if sel >= len(filtered):
sel = max(0, len(filtered) - 1)
def input_line(self, stdscr: curses.window, prompt: str, default: str = "") -> Optional[str]:
"""Simple single-line input."""
stdscr.clear()
stdscr.addstr(0, 0, prompt)
stdscr.addstr(2, 0, default)
stdscr.refresh()
curses.echo()
try:
s = stdscr.getstr(2, 0, 80).decode("utf-8", errors="replace").strip()
return s or default or None
except Exception:
return None
finally:
curses.noecho()
def confirm_batch_apply(self, stdscr: curses.window, n: int, summary_lines: list[str] | None = None) -> bool:
"""One confirmation for the entire batch commit+apply."""
stdscr.clear()
h, w = stdscr.getmaxyx()
stdscr.addstr(0, 0, "=== COMMIT + APPLY CHANGES ===")
stdscr.addstr(2, 0, f"About to queue drafts AND immediately APPLY for {n} ticket(s).")
stdscr.addstr(3, 0, "This will write to Redmine now (fields, private notes, tags).")
stdscr.addstr(4, 0, "Safety checks still apply.")
y = 6
if summary_lines:
stdscr.addstr(y, 0, "Sample tickets:")
y += 1
for line in summary_lines[:6]:
stdscr.addstr(y, 0, " " + line[:w-3])
y += 1
stdscr.addstr(h-5, 0, "Are you SURE?")
stdscr.addstr(h-4, 0, "Press y/Y to apply the batch now")
stdscr.addstr(h-3, 0, "Any other key to cancel")
stdscr.refresh()
ch = stdscr.getch()
return ch in (ord("y"), ord("Y"))
# ---------- Draw main screen ----------
def draw(self, stdscr: curses.window) -> None:
stdscr.clear()
h, w = stdscr.getmaxyx()
# Header
stdscr.addstr(0, 0, "Redmine Fast TUI | " + self.project + " | " + self.template.to_summary()[: w-50])
stdscr.addstr(1, 0, "-" * min(w-1, 100))
# Template quick view line 2
tmpl_line = f"Template: {self.template.to_summary()}"
stdscr.addstr(2, 0, tmpl_line[:w-1])
# List
list_start_y = 4
list_height = h - list_start_y - 3
# Filter header
stdscr.addstr(3, 0, f"Filter: {self.filter_text or '(none)'} | Pending in session: {len(self.pending)} | {self.current_idx+1}/{len(self.filtered_indices)}")
if not self.filtered_indices:
stdscr.addstr(list_start_y, 0, "(no matching tickets)")
else:
for i in range(list_height):
idx = self.current_idx - (list_height // 2) + i
if 0 <= idx < len(self.filtered_indices):
real = self.filtered_indices[idx]
issue = self.issues[real]
iid = int(issue.get("id", 0))
pend = self.pending.get(iid)
line = issue_display_line(issue, pend)
if idx == self.current_idx:
stdscr.attron(curses.A_REVERSE | curses.A_BOLD)
stdscr.addstr(list_start_y + i, 0, line[:w-1])
if idx == self.current_idx:
stdscr.attroff(curses.A_REVERSE | curses.A_BOLD)
# Status / help - protected against narrow/short terminals (common over ssh + Windows Terminal / split panes)
try:
status_y = h - 2
if status_y > 0:
stdscr.addstr(status_y, 0, self.status_msg[:w-1])
if h > 1:
help_line = "j/k/↑↓ nav SPACE/Enter=paint c=cat a=assignee s=status p=pri P=proj t=tag C=ctag n=note e=entity m=nmi A=commit+apply S=direct R=live-refresh r=redraw q=quit ?=help"
stdscr.addstr(h-1, 0, help_line[:w-1])
except curses.error:
# Terminal too small or constrained (ssh/Windows Terminal splits etc.) — just skip the bottom line
pass
stdscr.refresh()
def apply_filter(self) -> None:
if not self.filter_text:
self.filtered_indices = list(range(len(self.issues)))
return
q = self.filter_text.lower()
self.filtered_indices = []
for i, issue in enumerate(self.issues):
text = " ".join([
str(issue.get("id", "")),
str(_safe_get(issue, "status", "name", default="")),
str(_safe_get(issue, "assigned_to", "name", default="")),
str(_safe_get(issue, "category", "name", default="")),
str(issue.get("subject", "")),
]).lower()
if q in text:
self.filtered_indices.append(i)
self.current_idx = min(self.current_idx, max(0, len(self.filtered_indices) - 1))
def run(self, stdscr: curses.window) -> int:
curses.curs_set(0)
stdscr.keypad(True)
curses.noecho()
stdscr.clear()
# Optional initial meta refresh prompt? Skip for speed; user can trigger.
self.apply_filter()
while True:
self.draw(stdscr)
ch = stdscr.getch()
if ch in (ord("q"), ord("Q"), 27):
if self.pending:
stdscr.addstr(0, 0, "Pending changes exist. Commit before quit? (y/N) ")
stdscr.refresh()
k2 = stdscr.getch()
if k2 in (ord("y"), ord("Y")):
self.commit_all(stdscr)
return 0
if ch in (curses.KEY_DOWN, ord("j")):
if self.filtered_indices:
self.current_idx = min(len(self.filtered_indices) - 1, self.current_idx + 1)
elif ch in (curses.KEY_UP, ord("k")):
if self.filtered_indices:
self.current_idx = max(0, self.current_idx - 1)
elif ch == ord(" "): # space or enter
self.apply_template_to_current()
elif ch in (curses.KEY_ENTER, 10, 13):
self.apply_template_to_current()
elif ch == ord("/"):
stdscr.addstr(3, 0, "Filter: ")
curses.echo()
try:
self.filter_text = stdscr.getstr(3, 8, 60).decode("utf-8", "replace").strip()
finally:
curses.noecho()
self.apply_filter()
elif ch == ord("c"): # category
cats = [(c.get("id"), c.get("name") or str(c.get("id"))) for c in self.meta_lists.get("categories", [])]
if not cats and load_meta_lists:
self.refresh_meta(force=True)
cats = [(c.get("id"), c.get("name") or str(c.get("id"))) for c in self.meta_lists.get("categories", [])]
picked = self.picker(stdscr, "Select Category (0/none to clear)", [(0, "none/clear")] + cats, current=self.template.category_id)
if picked:
pid, pname = picked
self.template.category_id = 0 if pid in (0, "0", None) else int(pid)
self.template.category_name = None if pid in (0, "0") else str(pname)
self.status_msg = f"Category: {self.template.category_name or 'cleared'}"
elif ch == ord("a"): # assignee
users = [(u.get("id"), u.get("name") or u.get("login") or str(u.get("id"))) for u in self.meta_lists.get("users", [])]
picked = self.picker(stdscr, "Select Assignee (0/unassigned to clear)", [(0, "unassigned/clear")] + users, current=self.template.assigned_to_id)
if picked:
pid, pname = picked
self.template.assigned_to_id = 0 if pid in (0, "0") else int(pid)
self.template.assigned_to_name = None if pid in (0, "0") else str(pname)
self.status_msg = f"Assignee: {self.template.assigned_to_name or 'unassigned'}"
elif ch == ord("s"): # status
stats = [(st.get("id"), st.get("name") or str(st.get("id"))) for st in self.meta_lists.get("statuses", [])]
picked = self.picker(stdscr, "Select Status", stats, current=self.template.status_id)
if picked:
pid, pname = picked
self.template.status_id = int(pid)
self.template.status_name = str(pname)
self.status_msg = f"Status: {pname}"
elif ch == ord("p"): # priority numeric
val = self.input_line(stdscr, "Priority number (e.g. 1=Low,2=Normal,3=High,4=Urgent): ", str(self.template.priority_id or ""))
if val:
try:
self.set_priority_numeric(int(val))
except ValueError:
self.status_msg = "Invalid priority number"
elif ch == ord("P"): # project move (advanced, list if available)
# For simplicity, numeric or name input
val = self.input_line(stdscr, "Project ID to move ticket to (or name if meta has it): ")
if val:
try:
self.template.project_id = int(val)
self.template.project_name = val
self.status_msg = f"Project move to {val}"
except ValueError:
self.template.project_name = val
self.status_msg = f"Project set to {val} (resolve on commit if needed)"
elif ch == ord("t"): # local control tag
tag = self.input_line(stdscr, "Local control tag to set in template: ")
if tag:
self.template.local_control_tag = tag.strip()
self.status_msg = f"Local tag: {tag}"
elif ch == ord("C"): # contact tag (add to template list)
tag = self.input_line(stdscr, "Contact tag to ADD (repeat to add more): ")
if tag:
t = tag.strip()
if t and t not in self.template.contact_tags:
self.template.contact_tags.append(t)
self.status_msg = f"Contact tags: {self.template.contact_tags}"
elif ch == ord("n"): # private note
note = self.input_line(stdscr, "Private note (template): ", self.template.private_note or "")
if note is not None:
self.template.private_note = note or None
self.status_msg = "Note set" if note else "Note cleared"
elif ch == ord("e"):
self.toggle_entity()
elif ch == ord("m"):
self.toggle_nmi()
elif ch == ord("A"): # commit + auto apply (with one confirmation)
if not self.pending:
self.status_msg = "No pending changes to commit."
continue
n = len(self.pending)
# Build a tiny summary for the confirm dialog
sample = []
for iid, ch in list(self.pending.items())[:3]:
bits = []
if ch.fields: bits.append("fields")
if ch.private_note: bits.append("note")
if ch.control_tags or ch.contact_tags: bits.append("tags")
if ch.entity_extract: bits.append("entity")
env = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
if ch.nmi_brief: bits.append("nmi")
sample.append(f"#{iid}: {','.join(bits) or 'changes'}")
confirmed = self.confirm_batch_apply(stdscr, n, sample)
if not confirmed:
self.status_msg = "Batch apply cancelled by user."
continue
# Immediate visual feedback so it doesn't look hung
stdscr.clear()
stdscr.addstr(1, 0, "=== COMMIT + APPLY IN PROGRESS ===")
stdscr.addstr(3, 0, "Queuing drafts and applying to Redmine now...")
stdscr.refresh()
lines = self.commit_all(stdscr, auto_apply_drafts=True)
# Show results
stdscr.clear()
y = 0
for line in lines[: stdscr.getmaxyx()[0] - 4]:
stdscr.addstr(y, 0, line[:stdscr.getmaxyx()[1]-1])
y += 1
stdscr.addstr(y + 1, 0, "Batch complete. Press any key...")
stdscr.refresh()
stdscr.getch()
self.status_msg = "Applied. Pending cleared."
elif ch == ord("S"): # direct apply (bypass drafts) + cache refresh
apply_now = self.auto_apply or self.allow_prod_writes
if not apply_now:
self.status_msg = "Direct apply requires --allow-prod-writes (flag not active)"
continue
# Perform direct write
lines = self.commit_all(stdscr, apply_now=True)
# Show results safely
stdscr.clear()
y = 0
max_lines = max(1, stdscr.getmaxyx()[0] - 3)
for line in (lines or [])[:max_lines]:
stdscr.addstr(y, 0, str(line)[:stdscr.getmaxyx()[1]-1])
y += 1
stdscr.addstr(y + 1, 0, "Press any key to continue...")
stdscr.refresh()
stdscr.getch()
self.status_msg = "Direct commit complete."
elif ch == ord("r"): # redraw / refresh list (local)
self.apply_filter()
self.status_msg = "Redrawn (local)"
elif ch == ord("R"): # force live refresh of the *current* ticket from Redmine
issue = self._current_issue()
if not issue:
self.status_msg = "No current ticket to refresh"
else:
iid = int(issue["id"])
try:
client = self._get_client()
from redmine_tickets import TicketCache, refresh_cached_issue
c = TicketCache(self.cache_dir, self.project)
fresh = refresh_cached_issue(c, client, iid)
if fresh:
self._replace_issue_in_list(iid, fresh)
self.status_msg = f"#{iid} live-refreshed from Redmine"
else:
self.status_msg = f"#{iid} live refresh returned no data (kept cache)"
except Exception as e:
self.status_msg = f"Live refresh failed: {e}"
elif ch == ord("?"):
self.status_msg = "SPACE=paint | e=entity extract (immediate preview) | m=NMI brief (real note on A) | A=commit+APPLY (1 confirm) | s/a/c/p/t/C tags | R=refresh | q quit"
elif ch == ord("x"): # clear pending on current
issue = self._current_issue()
if issue:
iid = int(issue["id"])
if iid in self.pending:
del self.pending[iid]
self.status_msg = f"Cleared pending for #{iid}"
# Simple numeric priority hotkeys example (optional)
elif ch == ord("1"):
self.set_priority_numeric(1)
elif ch == ord("2"):
self.set_priority_numeric(2)
elif ch == ord("3"):
self.set_priority_numeric(3)
return 0
def run_tui(
issues: list[dict[str, Any]],
*,
cache_dir: Path,
project: str,
base_url: str,
allow_prod_writes: bool = False,
auto_apply: bool = False,
) -> int:
tui = RedmineTUI(
issues,
cache_dir=cache_dir,
project=project,
base_url=base_url,
allow_prod_writes=allow_prod_writes,
auto_apply=auto_apply,
)
return curses.wrapper(tui.run)
# CLI glue (called from redmine_tickets.py)
def add_tui_parser(subparsers: argparse._SubParsersAction) -> None:
tui_parser = subparsers.add_parser(
"tui",
help="Curses TUI: set template once, paint with SPACE, A=commit+auto-apply (one confirm for batch).",
)
# Declare parent-level globals here too so they are accepted after the subcommand verb.
# This lets users do: rtl tui --allow-prod-writes ...
# (The main parser also defines them for "before verb" placement.)
tui_parser.add_argument("--base-url", default=None, help=argparse.SUPPRESS)
tui_parser.add_argument("--project", default=None, help=argparse.SUPPRESS)
tui_parser.add_argument("--cache-dir", type=str, default=None, help=argparse.SUPPRESS)
tui_parser.add_argument(
"--allow-prod-writes",
action="store_true",
help="Allow write/apply operations against non-LAN hosts (e.g. rm.ldrprep.com).",
)
tui_parser.add_argument("--config", default=None, help=argparse.SUPPRESS)
tui_parser.add_argument("--where", action="append", default=[], help="Local property filter (repeatable AND).")
tui_parser.add_argument("--control-tag", help="Local control-tag filter.")
tui_parser.add_argument("--status", help="Status name filter.")
tui_parser.add_argument("--max-tickets", type=int, default=100)
tui_parser.add_argument("--order", choices=["newest-updated-first", "oldest-updated-first"], default="newest-updated-first")
tui_parser.add_argument("--open-only", action="store_true", help="Exclude closed-ish statuses.")
tui_parser.add_argument("--has-attachments", action="store_true")
tui_parser.add_argument(
"--apply",
action="store_true",
help="DANGEROUS: attempt direct writes instead of drafting (requires --allow-prod-writes for prod).",
)
tui_parser.add_argument("--impersonate-user", default="rebot")
def main_cli(args: argparse.Namespace, cache: "TicketCache") -> int: # type: ignore
# `curses.wrapper()` fails at cbreak/nocbreak when launched through a pipe,
# captured runner, or WebUI. Refuse before touching cache/network state and
# make the failure understandable instead of silently returning to a prompt.
if not os.isatty(0) or not os.isatty(1):
print(
"ERROR: curses TUI requires an interactive terminal (stdin and stdout must be TTYs).",
file=sys.stderr,
)
print(
"Run `rtl tui` directly in an onyx shell, not through a pipe, capture, or WebUI runner.",
file=sys.stderr,
)
return 1
# Robust normalization so --allow-prod-writes works whether before or after "tui"
if getattr(args, "cache_dir", None) in (None, ""):
args.cache_dir = getattr(cache, "cache_dir", None) or Path(".cache/redmine_tickets")
if getattr(args, "project", None) in (None, ""):
args.project = getattr(cache, "project", None) or "customer-service"
if getattr(args, "base_url", None) in (None, ""):
import os as _os
args.base_url = _os.environ.get("REDMINE_BASE_URL") or "https://rm.ldrprep.com"
# Make write flags reliable regardless of position on command line
effective_allow = bool(
getattr(args, "allow_prod_writes", False)
or getattr(args, "apply", False)
or os.environ.get("REDMINE_ALLOW_PROD_WRITES", "").lower() in ("1", "true", "yes")
)
args.allow_prod_writes = effective_allow
if not getattr(args, "apply", False):
args.apply = effective_allow
# Late import: the top-level try: from redmine_tickets import ... can fail
# due to circular import while redmine_tickets is still executing its module body.
try:
from redmine_tickets import select_cached_issues_for_cli as _select_cached_issues
except Exception:
_select_cached_issues = None
if _select_cached_issues is None:
# Fallback: import the module again now that it should be fully loaded
import redmine_tickets as _rt
_select_cached_issues = _rt.select_cached_issues_for_cli
# Robust: import the already-loaded parent module to dodge circular import timing
import redmine_tickets as _rt
matches = _rt.select_cached_issues_for_cli(cache, args)
if not matches:
print("No matching tickets for TUI.", file=sys.stderr)
return 0
# Ensure some meta is present
if load_meta_lists:
try:
lists = load_meta_lists(Path(args.cache_dir), args.project)
if not any(lists.values()):
print("Meta cache looks empty — consider running meta-refresh first for best picker UX.", file=sys.stderr)
except Exception:
pass
return run_tui(
matches,
cache_dir=Path(args.cache_dir),
project=args.project,
base_url=args.base_url,
allow_prod_writes=bool(args.allow_prod_writes),
auto_apply=bool(getattr(args, "apply", False)),
)
if __name__ == "__main__":
# Standalone test (needs issues list)
print("Run via: ./redmine_tickets.py tui ... or ./bin/rtl tui ...")
sys.exit(0)