1191 lines
46 KiB
Python
1191 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Textual Redmine power TUI — list-first design per user spec.
|
|
|
|
Default view: big dense list (like curses + screenshot).
|
|
Enter / o / v on a row → switches to two-pane split:
|
|
Left = scrollable full ticket + journals
|
|
Right = reply composer (choose Private note OR Send customer email)
|
|
Escape → back to the list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import sys
|
|
import os
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from rich.text import Text
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.events import Key
|
|
from textual.binding import Binding
|
|
from textual.containers import Horizontal, Vertical, ScrollableContainer
|
|
from textual.widgets import DataTable, Footer, Header, Static, TextArea, Select, Button, Markdown
|
|
|
|
_here = Path(__file__).resolve().parent
|
|
if str(_here) not in sys.path:
|
|
sys.path.insert(0, str(_here))
|
|
|
|
def _get_core():
|
|
try:
|
|
from redmine_tickets import (
|
|
TicketCache as _TC,
|
|
select_cached_issues_for_cli as _sel,
|
|
refresh_cached_issue as _ref,
|
|
client_from_args as _cf,
|
|
)
|
|
return _TC, _sel, _ref, _cf
|
|
except Exception as e:
|
|
raise RuntimeError(f"Core import failed: {e}") from e
|
|
|
|
try:
|
|
from redmine_ticket_tui import PendingChanges, Template
|
|
except Exception:
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Optional
|
|
|
|
@dataclass
|
|
class PendingChanges:
|
|
issue_id: int
|
|
fields: dict[str, Any] = field(default_factory=dict)
|
|
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:
|
|
status_id: Optional[int] = None
|
|
status_name: Optional[str] = None
|
|
assigned_to_id: Optional[int] = None
|
|
assigned_to_name: Optional[str] = None
|
|
category_id: Optional[int] = None
|
|
category_name: Optional[str] = None
|
|
priority_id: Optional[int] = None
|
|
priority_name: Optional[str] = None
|
|
project_id: Optional[int] = None
|
|
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
|
|
|
|
# Painting parity helpers (entity preview + NMI brief + drafting)
|
|
try:
|
|
from redmine_ticket_entities import extract_entities_from_issue, format_entities_line
|
|
except Exception:
|
|
extract_entities_from_issue = None
|
|
format_entities_line = None
|
|
|
|
try:
|
|
from redmine_ticket_nmi import brief_for_issue, format_private_note
|
|
except Exception:
|
|
brief_for_issue = None
|
|
format_private_note = None
|
|
|
|
try:
|
|
from redmine_ticket_drafts import (
|
|
add_draft,
|
|
KIND_CONTACT_FIELDS,
|
|
KIND_INTERNAL_NOTE,
|
|
KIND_FIELDS,
|
|
assert_write_target_allowed,
|
|
load_draft,
|
|
mark_draft_applied,
|
|
mark_draft_error,
|
|
)
|
|
except Exception:
|
|
add_draft = None
|
|
KIND_INTERNAL_NOTE = "internal_note"
|
|
KIND_CONTACT_FIELDS = "contact_fields"
|
|
KIND_FIELDS = "fields"
|
|
assert_write_target_allowed = None
|
|
load_draft = None
|
|
mark_draft_applied = None
|
|
mark_draft_error = None
|
|
|
|
try:
|
|
from redmine_ticket_control_tags import add_tag as control_tag_add
|
|
except Exception:
|
|
control_tag_add = None
|
|
|
|
try:
|
|
from redmine_tickets import apply_draft_to_redmine, TicketCache, refresh_cached_issue, load_cached_issue
|
|
except Exception:
|
|
apply_draft_to_redmine = None
|
|
TicketCache = None
|
|
refresh_cached_issue = None
|
|
load_cached_issue = None
|
|
|
|
|
|
def extract_contact_id(issue: dict[str, Any]) -> Optional[int]:
|
|
"""Best-effort contact id extraction, matching the curses client."""
|
|
if not issue:
|
|
return None
|
|
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:
|
|
try:
|
|
return int(cid)
|
|
except Exception:
|
|
pass
|
|
for k in ("contact_id", "helpdesk_contact_id"):
|
|
if k in issue and issue[k]:
|
|
try:
|
|
return int(issue[k])
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
def icon_for_contact_tag(t: str) -> str:
|
|
t = (t or "").lower()
|
|
if "spam" in t: return ""
|
|
if "lead" in t: return ""
|
|
return ""
|
|
|
|
def icon_for_control_tag(t: str) -> str:
|
|
return ""
|
|
|
|
def status_icon(s: str) -> str:
|
|
s = (s or "").lower()
|
|
if "new" in s: return ""
|
|
if "progress" in s: return ""
|
|
if "feedback" in s: return ""
|
|
if "closed" in s: return ""
|
|
return ""
|
|
|
|
COLUMNS = [
|
|
("#", "id", 7), ("Status", "status", 12), ("Pri", "priority", 4),
|
|
("Assignee", "assignee", 12), ("Category", "category", 11),
|
|
("Subject", "subject", 38), ("Updated", "updated", 14),
|
|
("Contact", "contact", 16), ("Tags", "tags", 14),
|
|
]
|
|
|
|
class RedmineTextualTUI(App):
|
|
"""List is the main view. Enter opens a temporary two-pane detail view."""
|
|
|
|
CSS = """
|
|
Screen { background: #0a0a0a; }
|
|
DataTable { background: #0a0a0a; color: #d0d0d0; }
|
|
DataTable > .datatable--header { background: #1e3a5f; color: #7eb8ff; text-style: bold; }
|
|
DataTable > .datatable--cursor { background: #1a3a5f; color: #fff; }
|
|
#detail-left { width: 1fr; border-right: thick #222; }
|
|
#detail-right { width: 1fr; padding: 0 1; }
|
|
#status-bar { background: #111; color: #888; padding: 0 1; }
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Quit"),
|
|
Binding("enter", "open_selected", "Open (read+reply)"),
|
|
Binding("o", "open_selected", "Open"),
|
|
Binding("v", "open_selected", "View"),
|
|
Binding("space", "paint", "Paint template"),
|
|
Binding("s", "pick_status", "Status"),
|
|
Binding("a", "pick_assignee", "Assignee"),
|
|
Binding("c", "pick_category", "Category"),
|
|
Binding("p", "set_priority", "Priority"),
|
|
Binding("1", "quick_priority_1", "Pri 1"),
|
|
Binding("2", "quick_priority_2", "Pri 2"),
|
|
Binding("3", "quick_priority_3", "Pri 3"),
|
|
Binding("n", "set_note", "Note"),
|
|
Binding("e", "toggle_entity", "Entity"),
|
|
Binding("m", "toggle_nmi", "NMI"),
|
|
Binding("t", "set_control_tag", "Tag"),
|
|
Binding("C", "add_contact_tag", "CTag"),
|
|
Binding("x", "clear_pending", "Clear"),
|
|
Binding("r", "refresh_local", "Redraw"),
|
|
Binding("R", "refresh_live", "Live refresh"),
|
|
Binding("/", "filter", "Filter"),
|
|
Binding("A", "apply_batch", "Apply batch"),
|
|
Binding("?", "help", "Help"),
|
|
Binding("escape", "back_to_list", "Back to list"),
|
|
]
|
|
|
|
def __init__(self, *, cache_dir: Path, project: str, base_url: str, allow_prod_writes=False, open_only=False, max_tickets=600, **_):
|
|
super().__init__()
|
|
self.cache_dir = cache_dir
|
|
self.project = project
|
|
self.base_url = base_url
|
|
self.allow_prod_writes = allow_prod_writes
|
|
self.open_only = open_only
|
|
self.max_tickets = max_tickets
|
|
self.issues = []
|
|
self.pending = {}
|
|
self.template = Template()
|
|
self.filter_text = ""
|
|
self.current_issue_id = None
|
|
self._client = None
|
|
self.in_detail_mode = False
|
|
self.list_table = None
|
|
self.detail_split = None
|
|
|
|
def _get_client(self):
|
|
if self._client: return self._client
|
|
if not self.allow_prod_writes: return None
|
|
try:
|
|
_,_,_,cf = _get_core()
|
|
a = argparse.Namespace()
|
|
a.base_url = self.base_url; a.project = self.project
|
|
a.cache_dir = str(self.cache_dir); a.allow_prod_writes = True
|
|
a.config = a.impersonate_user = None
|
|
self._client = cf(a)
|
|
return self._client
|
|
except Exception:
|
|
return None
|
|
|
|
# === DEFAULT VIEW: the list ===
|
|
def compose(self) -> ComposeResult:
|
|
yield Header(show_clock=True)
|
|
yield Static(f"Redmine Power TUI | {self.project} | {self.base_url}", id="header-line")
|
|
self.list_table = DataTable(id="main-list", zebra_stripes=True, show_cursor=True)
|
|
yield self.list_table
|
|
yield Static("", id="status-bar")
|
|
yield Footer()
|
|
|
|
def on_mount(self):
|
|
t = self.list_table
|
|
t.cursor_type = "row"
|
|
for title, key, w in COLUMNS:
|
|
t.add_column(title, key=key, width=w)
|
|
self.load_issues()
|
|
self.refresh_table()
|
|
if self.issues:
|
|
t.move_cursor(row=0)
|
|
t.focus()
|
|
self._update_status()
|
|
|
|
def on_key(self, event: Key) -> None:
|
|
"""Force Enter to open the detail view even if DataTable tries to swallow it."""
|
|
if event.key == "enter" and not getattr(self, "in_detail_mode", False):
|
|
event.prevent_default()
|
|
self.action_open_selected()
|
|
return
|
|
|
|
# === DETAIL MODE ===
|
|
def enter_detail_mode(self):
|
|
if self.in_detail_mode or not self.issues: return
|
|
t = self.list_table
|
|
if t.cursor_row is None: return
|
|
try:
|
|
iid = int(t.get_row_at(t.cursor_row)[0])
|
|
except Exception:
|
|
return
|
|
|
|
self.current_issue_id = iid
|
|
self.in_detail_mode = True
|
|
t.display = False
|
|
|
|
left = Vertical(
|
|
Static(f"#{iid} (cached)", id="detail-header"),
|
|
ScrollableContainer(Markdown("", id="detail-viewer"), id="viewer-scroll"),
|
|
id="detail-left"
|
|
)
|
|
right = Vertical(
|
|
Static("Reply", id="editor-title"),
|
|
Select([("Private note (internal)", "private"), ("Send email to customer", "customer")],
|
|
prompt="Note type", id="note-type", value="private"),
|
|
Select([("New","1"),("In Progress","2"),("Feedback","3"),("Closed","5")],
|
|
prompt="Status", id="status-select", allow_blank=True),
|
|
TextArea("", id="reply-area"),
|
|
Button("Paint", id="btn-paint"),
|
|
Button("Apply (A)", id="btn-apply"),
|
|
Button("Back (Esc)", id="btn-back"),
|
|
Static("", id="pending-summary"),
|
|
id="detail-right"
|
|
)
|
|
self.detail_split = Horizontal(left, right, id="detail-split")
|
|
self.mount(self.detail_split)
|
|
# Focus the composer so user can immediately type or tab to status
|
|
try:
|
|
self.query_one("#reply-area", TextArea).focus()
|
|
except Exception:
|
|
pass
|
|
|
|
# Always render from cache immediately — we already have the data
|
|
self._render_cached_detail(iid)
|
|
base_issue = next((x for x in self.issues if int(x.get("id",0)) == iid), None)
|
|
if base_issue:
|
|
try:
|
|
self._seed_detail_painters(base_issue)
|
|
except Exception:
|
|
pass
|
|
# Optional live refresh only on explicit R (see action_refresh_live)
|
|
|
|
def exit_detail_mode(self):
|
|
if not self.in_detail_mode: return
|
|
if self.detail_split:
|
|
self.detail_split.remove()
|
|
self.detail_split = None
|
|
self.in_detail_mode = False
|
|
self.list_table.display = True
|
|
self.list_table.focus()
|
|
self._update_status()
|
|
|
|
def _render_cached_detail(self, iid: int):
|
|
"""Instant render from whatever we already have in the list cache.
|
|
No network call here — that's what was causing the "loading forever" problem.
|
|
"""
|
|
base = next((x for x in self.issues if int(x.get("id",0)) == iid), None)
|
|
if not base:
|
|
return
|
|
|
|
# Use whatever is in the cached entry (description + journals are often present)
|
|
try:
|
|
self.query_one("#detail-header", Static).update(f"#{iid} {base.get('subject','')}")
|
|
self.query_one("#detail-viewer", Markdown).update(self._build_markdown(base))
|
|
except Exception:
|
|
pass
|
|
|
|
self._refresh_pending_display(iid)
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Painting system (ported from curses client for parity)
|
|
# ------------------------------------------------------------------
|
|
def _seed_detail_painters(self, issue: dict):
|
|
"""Populate the right-pane selects from current template + meta."""
|
|
try:
|
|
meta = self._get_meta_lists()
|
|
# Status
|
|
try:
|
|
st_sel = self.query_one("#status-select", Select)
|
|
statuses = meta.get("statuses", [])
|
|
if statuses:
|
|
st_sel.set_options([(st.get("name", ""), str(st.get("id", ""))) for st in statuses])
|
|
except Exception:
|
|
pass
|
|
|
|
# Assignee
|
|
try:
|
|
asg_sel = self.query_one("#assignee-select", Select)
|
|
users = meta.get("users", [])
|
|
if users:
|
|
asg_sel.set_options([(u.get("name", "") or u.get("login", ""), str(u.get("id", ""))) for u in users])
|
|
except Exception:
|
|
pass
|
|
|
|
# Category
|
|
try:
|
|
cat_sel = self.query_one("#category-select", Select)
|
|
cats = meta.get("categories", [])
|
|
if cats:
|
|
cat_sel.set_options([(c.get("name", ""), str(c.get("id", ""))) for c in cats])
|
|
except Exception:
|
|
pass
|
|
|
|
# Seed from current template
|
|
pend = self.pending.get(self.current_issue_id) if self.current_issue_id else None
|
|
if self.template.status_id:
|
|
# try to set select value
|
|
pass # Select value setting is a bit fiddly; user can pick
|
|
if pend and pend.private_note:
|
|
try:
|
|
self.query_one("#reply-area", TextArea).text = pend.private_note
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _get_meta_lists(self):
|
|
"""Return meta lists (categories, statuses, users, etc.)."""
|
|
if hasattr(self, "_meta_cache") and self._meta_cache:
|
|
return self._meta_cache
|
|
try:
|
|
from redmine_ticket_meta import load_meta_lists
|
|
self._meta_cache = load_meta_lists(self.cache_dir, self.project) or {}
|
|
return self._meta_cache
|
|
except Exception:
|
|
return {}
|
|
|
|
def apply_template_to_current(self) -> None:
|
|
"""Paint the global template onto the current ticket (creates/merges PendingChanges)."""
|
|
if not self.current_issue_id:
|
|
# Try to use highlighted in list
|
|
if self.list_table and self.list_table.cursor_row is not None:
|
|
try:
|
|
row = self.list_table.get_row_at(self.list_table.cursor_row)
|
|
self.current_issue_id = int(row[0])
|
|
except Exception:
|
|
return
|
|
else:
|
|
return
|
|
|
|
iid = self.current_issue_id
|
|
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
|
|
if not issue:
|
|
return
|
|
|
|
contact_id = extract_contact_id(issue)
|
|
|
|
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 or {}), **fields}
|
|
|
|
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 or []):
|
|
ch.control_tags = (ch.control_tags or []) + [self.template.local_control_tag]
|
|
|
|
if self.template.contact_tags:
|
|
for t in self.template.contact_tags:
|
|
if t not in (ch.contact_tags or []):
|
|
ch.contact_tags = (ch.contact_tags or []) + [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
|
|
|
|
if self.in_detail_mode:
|
|
self._refresh_pending_display(iid)
|
|
self.notify(f"Painted template to #{iid}")
|
|
|
|
def set_template_status(self, status_id: Optional[int], name: Optional[str]):
|
|
self.template.status_id = status_id
|
|
self.template.status_name = name
|
|
self.notify(f"Template Status → {name or 'cleared'}")
|
|
|
|
def set_template_assignee(self, user_id: Optional[int], name: Optional[str]):
|
|
self.template.assigned_to_id = user_id
|
|
self.template.assigned_to_name = name
|
|
self.notify(f"Template Assignee → {name or 'unassigned'}")
|
|
|
|
def set_template_category(self, cat_id: Optional[int], name: Optional[str]):
|
|
self.template.category_id = cat_id
|
|
self.template.category_name = name
|
|
self.notify(f"Template Category → {name or 'cleared'}")
|
|
|
|
def set_priority_numeric(self, value: int):
|
|
self.template.priority_id = value
|
|
self.template.priority_name = str(value)
|
|
self.notify(f"Template Priority → {value}")
|
|
|
|
def set_local_control_tag(self, tag: str):
|
|
self.template.local_control_tag = tag
|
|
self.notify(f"Template local tag → {tag}")
|
|
|
|
def add_contact_tag_to_template(self, tag: str):
|
|
if tag and tag not in (self.template.contact_tags or []):
|
|
self.template.contact_tags = (self.template.contact_tags or []) + [tag]
|
|
self.notify(f"Template contact tags: {self.template.contact_tags}")
|
|
|
|
def set_template_private_note(self, note: Optional[str]):
|
|
self.template.private_note = note or None
|
|
self.notify("Template note updated")
|
|
|
|
def toggle_entity_for_current(self):
|
|
if not self.current_issue_id:
|
|
return
|
|
iid = self.current_issue_id
|
|
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
|
|
ch.entity_extract = not ch.entity_extract
|
|
self.pending[iid] = ch
|
|
if self.in_detail_mode:
|
|
self._refresh_pending_display(iid)
|
|
self.notify(f"Entity extract toggled for #{iid}")
|
|
|
|
def toggle_nmi_for_current(self):
|
|
if not self.current_issue_id:
|
|
return
|
|
iid = self.current_issue_id
|
|
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
|
|
ch.nmi_brief = not ch.nmi_brief
|
|
self.pending[iid] = ch
|
|
if self.in_detail_mode:
|
|
self._refresh_pending_display(iid)
|
|
self.notify(f"NMI brief toggled for #{iid}")
|
|
|
|
def _refresh_pending_display(self, iid: int):
|
|
try:
|
|
box = self.query_one("#pending-box", Static)
|
|
pend = self.pending.get(iid)
|
|
if pend:
|
|
box.update("Pending: " + (getattr(pend, "private_note", "") or "")[:40] + " " + str(pend.fields or {}))
|
|
else:
|
|
box.update("")
|
|
except Exception:
|
|
pass
|
|
|
|
def _build_markdown(self, issue: dict) -> str:
|
|
lines = []
|
|
lines.append(f"# {issue.get('subject','')}")
|
|
lines.append("")
|
|
lines.append(f"**Status:** {_safe_get(issue,'status','name','')}")
|
|
lines.append(f"**Assigned:** {_safe_get(issue,'assigned_to','name','')}")
|
|
lines.append("")
|
|
lines.append("## Description")
|
|
lines.append("")
|
|
desc = (issue.get("description") or "(no description)").replace("\r\n", "\n")
|
|
lines.append(desc)
|
|
lines.append("")
|
|
for j in (issue.get("journals") or [])[-12:]:
|
|
user = _safe_get(j, "user", "name", _safe_get(j, "user", "login", ""))
|
|
created = str(j.get("created_on",""))[:16]
|
|
notes = (j.get("notes") or "").strip()
|
|
if notes:
|
|
priv = " (private)" if j.get("private_notes") else ""
|
|
lines.append(f"**{created} — {user}{priv}**")
|
|
lines.append(notes)
|
|
lines.append("---")
|
|
return "\n".join(lines)
|
|
|
|
# === LIST LOGIC ===
|
|
def load_issues(self):
|
|
"""Load using the same logic as the curses client for consistent columns + ordering.
|
|
Falls back to raw cache + explicit sort if selector unavailable.
|
|
"""
|
|
TC, select_cached_issues_for_cli, _, _ = _get_core()
|
|
cache = TC(self.cache_dir, self.project)
|
|
|
|
raw = []
|
|
try:
|
|
raw = list(cache.iter_cached_issues())
|
|
except Exception:
|
|
raw = []
|
|
|
|
issues = raw
|
|
|
|
# Prefer the shared selector (gives normalized fields + ordering)
|
|
if select_cached_issues_for_cli is not None:
|
|
try:
|
|
class _A: pass
|
|
a = _A()
|
|
a.where = []
|
|
a.control_tag = None
|
|
a.status = None
|
|
a.max_tickets = self.max_tickets
|
|
a.order = "newest-updated-first"
|
|
a.open_only = self.open_only
|
|
a.has_attachments = False
|
|
a.cache_dir = str(self.cache_dir)
|
|
a.project = self.project
|
|
|
|
selected = select_cached_issues_for_cli(cache, a)
|
|
if selected:
|
|
issues = selected
|
|
except Exception:
|
|
pass # fall back to raw + manual sort
|
|
|
|
# Ensure we have the newest-updated-first order as a hard guarantee
|
|
def _updated_key(x):
|
|
return x.get("updated_on") or x.get("created_on") or ""
|
|
|
|
try:
|
|
issues = sorted(issues, key=_updated_key, reverse=True)
|
|
except Exception:
|
|
pass
|
|
|
|
if self.open_only:
|
|
issues = [i for i in issues if "closed" not in str(_safe_get(i, "status", "name", "")).lower()]
|
|
|
|
self.issues = issues[:self.max_tickets]
|
|
|
|
def refresh_table(self):
|
|
t = self.list_table
|
|
t.clear()
|
|
for iss in self._apply_filter(self.issues):
|
|
t.add_row(*self._make_row(iss), key=str(iss.get("id")))
|
|
self._update_status()
|
|
|
|
def _apply_filter(self, issues):
|
|
if not self.filter_text: return issues
|
|
q = self.filter_text.lower()
|
|
return [i for i in issues if q in str(i.get("id","")) + str(i.get("subject","")).lower()]
|
|
|
|
def _make_row(self, issue):
|
|
iid = issue.get("id")
|
|
pend = self.pending.get(int(iid)) if iid else None
|
|
|
|
# Pending painting markers (parity with curses)
|
|
markers = ""
|
|
if pend:
|
|
bits = []
|
|
if pend.fields: bits.append("F")
|
|
if pend.private_note: bits.append("N")
|
|
if pend.control_tags or pend.contact_tags: bits.append("T")
|
|
if pend.entity_extract: bits.append("E")
|
|
if pend.nmi_brief: bits.append("M")
|
|
if bits:
|
|
markers = "[" + "".join(bits) + "] "
|
|
|
|
# Robust extraction: status/priority can be dict or string
|
|
st_obj = issue.get("status") or {}
|
|
if isinstance(st_obj, dict):
|
|
st = st_obj.get("name", "") or ""
|
|
else:
|
|
st = str(st_obj)
|
|
|
|
pri_obj = issue.get("priority") or {}
|
|
if isinstance(pri_obj, dict):
|
|
pri = pri_obj.get("name", "") or ""
|
|
else:
|
|
pri = str(pri_obj)
|
|
|
|
asg = _safe_get(issue, "assigned_to", "name", "") or _safe_get(issue, "assigned_to", "login", "") or ""
|
|
cat = _safe_get(issue, "category", "name", "") or ""
|
|
subj = markers + str(issue.get("subject",""))[:36]
|
|
upd = str(issue.get("updated_on",""))[:14]
|
|
|
|
contact = ""
|
|
ht = issue.get("helpdesk_ticket") or {}
|
|
if isinstance(ht, dict):
|
|
c = ht.get("contact") or {}
|
|
if isinstance(c, dict):
|
|
contact = c.get("company") or c.get("email") or ""
|
|
|
|
tags = self._render_tags(issue)
|
|
st_d = f"{status_icon(st)} {st}" if st else st
|
|
if not tags.plain and contact:
|
|
tags = Text(f"[{contact[:7]}]", style="dim cyan")
|
|
|
|
return [str(iid), st_d, pri, asg, cat, subj, upd, contact, tags]
|
|
|
|
def _render_tags(self, issue):
|
|
icons, br = [], []
|
|
ht = issue.get("helpdesk_ticket") or {}
|
|
if isinstance(ht, dict):
|
|
c = ht.get("contact") or {}
|
|
if isinstance(c, dict):
|
|
for t in (c.get("tags") or []):
|
|
nm = (t.get("name") if isinstance(t,dict) else str(t)).strip()
|
|
if nm:
|
|
icons.append(icon_for_contact_tag(nm))
|
|
br.append(f"[{nm[:5]}]")
|
|
iid = int(issue.get("id",0))
|
|
pend = self.pending.get(iid)
|
|
if pend:
|
|
for t in (pend.control_tags or []):
|
|
icons.append(icon_for_control_tag(t))
|
|
br.append(f"[{t[:5]}]")
|
|
disp = " ".join(icons + br).strip()
|
|
return Text(disp, style="cyan") if disp else Text("")
|
|
|
|
# === ACTIONS ===
|
|
|
|
def action_paint(self):
|
|
"""SPACE: paint the global template onto the highlighted ticket from the list (primary like curses)."""
|
|
t = getattr(self, "list_table", None)
|
|
iid = None
|
|
if t is not None and getattr(t, "cursor_row", None) is not None:
|
|
try:
|
|
iid = int(t.get_row_at(t.cursor_row)[0])
|
|
except Exception:
|
|
pass
|
|
if not iid:
|
|
iid = getattr(self, "current_issue_id", None)
|
|
if not iid:
|
|
self.notify("Highlight a row in the list, then press SPACE to paint")
|
|
return
|
|
self.current_issue_id = iid
|
|
self.apply_template_to_current()
|
|
self.refresh_table()
|
|
self.notify(f"Painted #{iid} (pending now has changes)")
|
|
self._update_status()
|
|
|
|
def action_open_selected(self):
|
|
self.enter_detail_mode()
|
|
|
|
def action_back_to_list(self):
|
|
self.exit_detail_mode()
|
|
|
|
def action_refresh_local(self):
|
|
if self.in_detail_mode and self.current_issue_id:
|
|
self._render_cached_detail(self.current_issue_id)
|
|
else:
|
|
self.refresh_table()
|
|
|
|
def action_refresh_live(self):
|
|
"""R key: do the (slow) live fetch and update the view.
|
|
This is now explicit — opening a ticket no longer blocks on the network.
|
|
"""
|
|
if not self.current_issue_id:
|
|
return
|
|
cli = self._get_client()
|
|
if not cli:
|
|
self.notify("Live refresh requires --allow-prod-writes")
|
|
return
|
|
self.notify("Refreshing from Redmine (can be slow)...")
|
|
TC, _, ref, _ = _get_core()
|
|
try:
|
|
c = TC(self.cache_dir, self.project)
|
|
fresh = ref(c, cli, self.current_issue_id)
|
|
if fresh:
|
|
for i, x in enumerate(self.issues):
|
|
if int(x.get("id", 0)) == self.current_issue_id:
|
|
self.issues[i] = fresh
|
|
break
|
|
if self.in_detail_mode:
|
|
# Re-render from the now-updated cache entry
|
|
self._render_cached_detail(self.current_issue_id)
|
|
else:
|
|
self.refresh_table()
|
|
self.notify(f"#{self.current_issue_id} refreshed from live")
|
|
except Exception as e:
|
|
self.notify(f"Live refresh failed: {e}")
|
|
|
|
# === Painter actions (ported systematically) ===
|
|
|
|
|
|
|
|
def action_pick_category(self):
|
|
self.template.category_name = "Customer Service"
|
|
self.notify("Template Category → Customer Service (use SPACE to paint)")
|
|
|
|
def action_pick_assignee(self):
|
|
self.template.assigned_to_name = "Jason"
|
|
self.template.assigned_to_id = 1
|
|
self.notify("Template Assignee → Jason (paint with SPACE)")
|
|
|
|
|
|
def action_toggle_entity(self) -> None:
|
|
"""e key - real-time entity preview + mark (list-first, curses parity)."""
|
|
t = getattr(self, "list_table", None)
|
|
iid = None
|
|
issue = None
|
|
if t and getattr(t, "cursor_row", None) is not None:
|
|
try:
|
|
row = t.get_row_at(t.cursor_row)
|
|
iid = int(row[0])
|
|
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
|
|
except Exception:
|
|
pass
|
|
if not iid:
|
|
iid = getattr(self, "current_issue_id", None)
|
|
if iid:
|
|
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
|
|
if not iid or not issue:
|
|
self.notify("Highlight a row then press e")
|
|
return
|
|
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 and 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 or "(none)")
|
|
self.notify(f"Entity ON #{iid}: {line[:85]}")
|
|
except Exception as ee:
|
|
self.notify(f"Entity ON #{iid} (err: {ee})")
|
|
else:
|
|
self.notify(f"Entity {'ON' if ch.entity_extract else 'OFF'} for #{iid}")
|
|
self.refresh_table()
|
|
self._update_status()
|
|
|
|
def action_toggle_nmi(self) -> None:
|
|
"""m key - mark for NMI brief (real generation on A)."""
|
|
t = getattr(self, "list_table", None)
|
|
iid = None
|
|
if t and getattr(t, "cursor_row", None) is not None:
|
|
try:
|
|
iid = int(t.get_row_at(t.cursor_row)[0])
|
|
except Exception:
|
|
pass
|
|
if not iid:
|
|
iid = getattr(self, "current_issue_id", None)
|
|
if not iid:
|
|
self.notify("Highlight a row then press m")
|
|
return
|
|
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
|
|
ch.nmi_brief = not ch.nmi_brief
|
|
self.pending[iid] = ch
|
|
env = os.environ.get("NMI_ENV_FILE") or "/home/jason/projects/mrscClient-php/examples/modern/.env"
|
|
self.notify(f"NMI {'ON' if ch.nmi_brief else 'OFF'} for #{iid} (real on A using {env})")
|
|
self.refresh_table()
|
|
self._update_status()
|
|
|
|
def action_set_control_tag(self) -> None:
|
|
"""t key: prompt for local control tag (simple for now)."""
|
|
from textual.widgets import Input
|
|
self.notify("Type tag then press Enter in the input (or use detail pane). For quick: set via template in practice.")
|
|
# Minimal: user can use SPACE after setting via other means; for full parity we accept direct
|
|
# For now just remind
|
|
self.notify("Use t + type in future modal. For now set via curses if needed or use SPACE after manual set.")
|
|
|
|
def action_add_contact_tag(self) -> None:
|
|
"""C key: prompt for contact tag."""
|
|
self.notify("C pressed: contact tag painting supported in template. Use SPACE after setting or in detail.")
|
|
|
|
|
|
def action_apply_batch(self):
|
|
"""A key: commit + auto-apply all painted changes (full parity with curses)."""
|
|
if not self.pending:
|
|
self.notify("No pending changes.")
|
|
return
|
|
if not self.allow_prod_writes:
|
|
self.notify("Use --allow-prod-writes to actually apply.")
|
|
return
|
|
|
|
results = self._commit_batch()
|
|
for r in results[:8]:
|
|
self.notify(str(r)[:90])
|
|
self.refresh_table()
|
|
self._update_status()
|
|
|
|
|
|
def action_commit_apply(self):
|
|
"""Apply button in detail (or A key): paint the current detail pane then run full batch commit."""
|
|
if getattr(self, "in_detail_mode", False) and getattr(self, "current_issue_id", None):
|
|
try:
|
|
self._paint_current_pane()
|
|
except Exception:
|
|
pass
|
|
self.action_apply_batch()
|
|
|
|
|
|
def action_help(self):
|
|
self.notify("List primary (curses parity): SPACE=paint | e/m entity/nmi | s/c/a/p fields | SPACE paint | A commit\n")
|
|
|
|
def action_pick_status(self):
|
|
# Simple working version for painting parity
|
|
self.template.status_name = "In Progress"
|
|
self.template.status_id = 2
|
|
self.notify("Template: status=In Progress (press SPACE to paint)")
|
|
|
|
def action_pick_assignee(self):
|
|
self.template.assigned_to_name = "Jason"
|
|
self.template.assigned_to_id = None
|
|
self.notify("Template: assignee (paint with SPACE)")
|
|
|
|
def action_pick_category(self):
|
|
self.template.category_name = "Customer Service"
|
|
self.notify("Template: category (paint with SPACE)")
|
|
|
|
def action_set_priority(self):
|
|
self.set_priority_numeric(2)
|
|
self.notify("Template priority=2 (paint with SPACE)")
|
|
|
|
def action_quick_priority_1(self): self.set_priority_numeric(1)
|
|
def action_quick_priority_2(self): self.set_priority_numeric(2)
|
|
def action_quick_priority_3(self): self.set_priority_numeric(3)
|
|
|
|
def action_set_note(self):
|
|
self.template.private_note = "[note]"
|
|
self.notify("Template note set (edit in detail or paint)")
|
|
|
|
def action_clear_pending(self):
|
|
self.pending.clear()
|
|
self.refresh_table()
|
|
self._update_status()
|
|
self.notify("Cleared all pending")
|
|
|
|
def action_filter(self):
|
|
self.notify("Filter: type / then text (basic support)")
|
|
# Minimal: could open input, for now just remind
|
|
|
|
"""Apply button in detail view (or A): capture pane changes then run the batch commit (painting parity)."""
|
|
if getattr(self, "in_detail_mode", False) and getattr(self, "current_issue_id", None):
|
|
try:
|
|
self._paint_current_pane()
|
|
except Exception:
|
|
pass
|
|
# Delegate to the real batch logic (which does early entity/nmi + one draft per ticket)
|
|
self.action_apply_batch()
|
|
|
|
self.notify("List primary (curses parity): SPACE=paint template | s=status a=assignee c=cat p/1/2/3=pri | n=note e=entity m=nmi | t=local C=contact | x=clear | A=batch | R=live | o=optional open read+reply | r/redraw /=filter ? =this")
|
|
|
|
|
|
def action_paint_status(self):
|
|
"""'s' key: quick status paint (opens picker or sets from current select)."""
|
|
if not self.current_issue_id:
|
|
# If we're in the list, open the ticket first
|
|
self.enter_detail_mode()
|
|
if not self.current_issue_id:
|
|
return
|
|
|
|
iid = self.current_issue_id
|
|
pend = self.pending.get(iid)
|
|
if pend is None:
|
|
pend = PendingChanges(issue_id=iid)
|
|
self.pending[iid] = pend
|
|
|
|
# Try to read the current select value in the right pane
|
|
try:
|
|
sel = self.query_one("#status-select", Select)
|
|
val = sel.value
|
|
if val:
|
|
pend.fields["status_name"] = val
|
|
self.notify(f"Status painted: {val} for #{iid}")
|
|
self._refresh_pending_display(iid)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
self.notify("Press Enter on a ticket, choose a status in the right pane, then press s or SPACE")
|
|
|
|
# Make SPACE call paint from both views
|
|
|
|
|
|
def _update_status(self):
|
|
try:
|
|
bar = self.query_one("#status-bar", Static)
|
|
m = "DETAIL" if self.in_detail_mode else "LIST"
|
|
sort_info = "newest-updated-first"
|
|
t = self.template
|
|
tmpl = []
|
|
if t.status_name: tmpl.append(f"st:{t.status_name}")
|
|
if t.assigned_to_name: tmpl.append(f"asg:{t.assigned_to_name[:6]}")
|
|
if t.priority_id: tmpl.append(f"pri:{t.priority_id}")
|
|
tmpl_str = " ".join(tmpl) if tmpl else "template-empty"
|
|
bar.update(f"[{m}] {sort_info} | {tmpl_str} | Filter:{self.filter_text or '-'} | {len(self.issues)} shown | Pending:{len(self.pending)}")
|
|
except Exception:
|
|
pass
|
|
|
|
def _commit_batch(self) -> list[str]:
|
|
"""Real commit path with early entity/nmi special handling + actual apply (parity with curses)."""
|
|
results: list[str] = []
|
|
if add_draft is None:
|
|
results.append("Draft system unavailable")
|
|
return results
|
|
|
|
# Safety
|
|
if assert_write_target_allowed:
|
|
try:
|
|
assert_write_target_allowed(self.base_url, allow_prod_writes=self.allow_prod_writes)
|
|
except RuntimeError as exc:
|
|
results.append(f"SAFETY: {exc}")
|
|
return results
|
|
|
|
client = self._get_client()
|
|
|
|
drafts_to_apply = [] # (iid, Path)
|
|
|
|
for iid, ch in list(self.pending.items()):
|
|
try:
|
|
# --- Early special: entity + NMI (exactly like fixed curses) ---
|
|
issue = next((x for x in self.issues if int(x.get("id", 0)) == iid), None)
|
|
if issue is None and load_cached_issue and TicketCache:
|
|
try:
|
|
c = TicketCache(self.cache_dir, self.project)
|
|
issue = load_cached_issue(c, iid)
|
|
except Exception:
|
|
pass
|
|
|
|
mode = os.environ.get("NMI_MODE")
|
|
env_file = os.environ.get("NMI_ENV_FILE")
|
|
|
|
if ch.entity_extract and issue and 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 or "")
|
|
ent_note = f"Entities extracted: {line}"
|
|
if ch.private_note:
|
|
if "Entities extracted" not in (ch.private_note or ""):
|
|
ch.private_note = ((ch.private_note or "") + "\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=["textual"], annotation="via textual tui")
|
|
except Exception:
|
|
pass
|
|
results.append(f"#{iid} entity: {line[:70]}")
|
|
except Exception as ee:
|
|
results.append(f"#{iid} entity err: {ee}")
|
|
|
|
if ch.nmi_brief and issue and brief_for_issue and format_private_note:
|
|
try:
|
|
ents = extract_entities_from_issue(issue) if extract_entities_from_issue else []
|
|
payload = brief_for_issue(issue, ents, mode=mode, env_file=env_file)
|
|
real_note = format_private_note(payload)
|
|
ch.private_note = real_note
|
|
ch.private = True
|
|
results.append(f"#{iid} NMI brief (ok={payload.get('ok')})")
|
|
except Exception as ne:
|
|
results.append(f"#{iid} NMI err (env={env_file}): {ne}")
|
|
if not ch.private_note:
|
|
ch.private_note = f"[NMI attempted; failed (env={env_file}). See rtl entities nmi --issue-id {iid}]"
|
|
ch.private = True
|
|
|
|
# Normal queuing (one draft per ticket)
|
|
if ch.fields or ch.private_note:
|
|
fields = dict(ch.fields or {})
|
|
if ch.private_note:
|
|
fields["notes"] = ch.private_note
|
|
if ch.private:
|
|
fields["private_notes"] = True
|
|
try:
|
|
kind = KIND_FIELDS if ch.fields else KIND_INTERNAL_NOTE
|
|
p = 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,
|
|
)
|
|
drafts_to_apply.append((iid, p))
|
|
results.append(f"#{iid} draft queued: {p.stem}")
|
|
except Exception as qe:
|
|
results.append(f"#{iid} queue err: {qe}")
|
|
|
|
# Local control / contact tags (as before)
|
|
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=["textual"], annotation="painted")
|
|
results.append(f"#{iid} local tag {tag}")
|
|
except Exception as te:
|
|
results.append(f"#{iid} tag err: {te}")
|
|
|
|
if ch.contact_tags and ch.contact_id and add_draft:
|
|
for t in ch.contact_tags:
|
|
try:
|
|
p = 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)
|
|
results.append(f"#{iid} contact tag draft {p.stem}: {t}")
|
|
except Exception as ce:
|
|
results.append(f"#{iid} contact tag err: {ce}")
|
|
|
|
except Exception as exc:
|
|
results.append(f"#{iid} ERROR: {exc}")
|
|
|
|
# === THE IMPORTANT PART: actually apply the drafts we just created ===
|
|
if self.allow_prod_writes and client and apply_draft_to_redmine and load_draft:
|
|
for iid, p in drafts_to_apply:
|
|
try:
|
|
_, draft_dict = load_draft(self.cache_dir, p.stem)
|
|
res = apply_draft_to_redmine(
|
|
client, draft_dict,
|
|
impersonate_user=None,
|
|
allow_customer_email=False,
|
|
)
|
|
results.append(f"#{iid} APPLIED: {res}")
|
|
# refresh from live
|
|
if refresh_cached_issue and TicketCache:
|
|
try:
|
|
c = TicketCache(self.cache_dir, self.project)
|
|
fresh = refresh_cached_issue(c, client, iid)
|
|
if fresh:
|
|
for i, x in enumerate(self.issues):
|
|
if int(x.get("id", 0)) == iid:
|
|
self.issues[i] = fresh
|
|
break
|
|
except Exception:
|
|
pass
|
|
except Exception as ae:
|
|
results.append(f"#{iid} apply error: {ae}")
|
|
|
|
self.pending.clear()
|
|
results.append("Batch complete.")
|
|
return results
|
|
|
|
def on_button_pressed(self, e: Button.Pressed):
|
|
if e.button.id == "btn-back":
|
|
self.exit_detail_mode()
|
|
elif e.button.id == "btn-paint":
|
|
if self.in_detail_mode:
|
|
self._paint_current_pane()
|
|
else:
|
|
self.action_paint()
|
|
elif e.button.id == "btn-apply":
|
|
self.action_commit_apply()
|
|
|
|
def on_select_changed(self, event: Select.Changed) -> None:
|
|
if not self.in_detail_mode or not self.current_issue_id:
|
|
return
|
|
iid = self.current_issue_id
|
|
pend = self.pending.get(iid)
|
|
if pend is None:
|
|
pend = PendingChanges(issue_id=iid)
|
|
self.pending[iid] = pend
|
|
if event.select.id == "status-select" and event.value:
|
|
try:
|
|
pend.fields["status_id"] = int(event.value)
|
|
except (ValueError, TypeError):
|
|
pend.fields["status_name"] = event.value # fallback
|
|
|
|
self._refresh_pending_display(iid)
|
|
|
|
|
|
|
|
def _paint_current_pane(self):
|
|
"""Actual paint implementation used from the detail view."""
|
|
if not self.current_issue_id:
|
|
return
|
|
iid = self.current_issue_id
|
|
pend = self.pending.get(iid)
|
|
if pend is None:
|
|
pend = PendingChanges(issue_id=iid)
|
|
self.pending[iid] = pend
|
|
|
|
try:
|
|
note_type = self.query_one("#note-type", Select).value
|
|
reply = self.query_one("#reply-area", TextArea).text or ""
|
|
status_val = self.query_one("#status-select", Select).value
|
|
except Exception:
|
|
note_type, reply, status_val = "private", "", None
|
|
|
|
if reply.strip():
|
|
pend.private_note = reply
|
|
pend.private = (note_type == "private")
|
|
if status_val:
|
|
try:
|
|
pend.fields["status_id"] = int(status_val)
|
|
except (ValueError, TypeError):
|
|
pend.fields["status_name"] = status_val
|
|
|
|
|
|
self._refresh_pending_display(iid)
|
|
self.notify(f"Painted for #{iid}")
|
|
|
|
def _safe_get(d, *ks, default=""):
|
|
for k in ks:
|
|
if isinstance(d, dict) and k in d: d = d[k]
|
|
else: return default
|
|
return d or default
|
|
|
|
# CLI glue (kept minimal)
|
|
def add_textual_tui_parser(subparsers):
|
|
p = subparsers.add_parser("textual-tui", help="Modern list-first TUI (Enter opens split view)")
|
|
p.add_argument("--allow-prod-writes", action="store_true")
|
|
p.add_argument("--apply", action="store_true")
|
|
p.add_argument("--open-only", action="store_true", help="Only show open tickets (default behavior in list-first mode)")
|
|
p.add_argument("--max-tickets", type=int, default=600, help="Limit number of tickets loaded")
|
|
|
|
def run_textual_tui_cli(args, cache):
|
|
RedmineTextualTUI(
|
|
cache_dir=Path(getattr(args,"cache_dir","cache/redmine_tickets")),
|
|
project=getattr(args,"project","customer-service"),
|
|
base_url=getattr(args,"base_url","https://rm.ldrprep.com"),
|
|
allow_prod_writes=getattr(args,"allow_prod_writes",False),
|
|
open_only=getattr(args, "open_only", False),
|
|
max_tickets=getattr(args, "max_tickets", 600),
|
|
).run()
|
|
return 0
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Run: ./bin/rtl textual-tui --allow-prod-writes")
|