#!/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") env_file = os.environ.get("NMI_ENV_FILE") runtime_profile = ( resolve_runtime_profile(mode=mode, env_file=env_file) if ch.nmi_brief and 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 runtime_profile.env_file else None 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)