277 lines
9.1 KiB
Python
277 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Modern TUI for redmine_tickets using prompt_toolkit + rich.
|
|
|
|
Goal: btop / mynav / htop-style experience while keeping the same
|
|
workflow:
|
|
- Set a template once (status, assignee, category, priority, private note, tags, etc.)
|
|
- Paint with SPACE/Enter
|
|
- A = one-confirm batch queue + auto-apply (with cache refresh)
|
|
- R = live refresh of current ticket
|
|
- Good columns (contact, tags, updated, priority, related, etc.)
|
|
- Proper split panes, mouse, resize, theming
|
|
- Reuses existing domain logic (drafts, apply, cache refresh, select, meta)
|
|
|
|
This is the future implementation path. The curses version in redmine_ticket_tui.py
|
|
remains the current stable tool while we build this out.
|
|
|
|
Run (once wired):
|
|
./bin/rtl --allow-prod-writes tui --modern ...
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
# --- Rich (rendering) ---
|
|
try:
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from rich.layout import Layout
|
|
from rich.live import Live
|
|
from rich.box import ROUNDED
|
|
except ImportError:
|
|
print("rich is required for the modern TUI. (it should already be installed)", file=sys.stderr)
|
|
raise
|
|
|
|
# --- prompt_toolkit (input + app) ---
|
|
try:
|
|
from prompt_toolkit import Application
|
|
from prompt_toolkit.key_binding import KeyBindings
|
|
from prompt_toolkit.layout import Layout as PTKLayout, Window, HSplit, VSplit
|
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
from prompt_toolkit.layout.containers import Window
|
|
from prompt_toolkit.widgets import TextArea, Frame
|
|
from prompt_toolkit.application import get_app
|
|
except ImportError:
|
|
print("prompt_toolkit is required for the modern TUI.", file=sys.stderr)
|
|
raise
|
|
|
|
# --- Reuse existing domain code where possible ---
|
|
try:
|
|
from redmine_tickets import (
|
|
TicketCache,
|
|
select_cached_issues_for_cli,
|
|
refresh_cached_issue,
|
|
)
|
|
except Exception:
|
|
TicketCache = None # type: ignore
|
|
select_cached_issues_for_cli = None # type: ignore
|
|
refresh_cached_issue = None # type: ignore
|
|
|
|
try:
|
|
from redmine_ticket_drafts import (
|
|
add_draft,
|
|
load_draft,
|
|
mark_draft_applied,
|
|
mark_draft_error,
|
|
assert_write_target_allowed,
|
|
KIND_FIELDS,
|
|
KIND_INTERNAL_NOTE,
|
|
)
|
|
except Exception:
|
|
add_draft = None # type: ignore
|
|
# ... (fallbacks handled at runtime)
|
|
|
|
try:
|
|
from redmine_ticket_meta import load_meta_lists
|
|
except Exception:
|
|
load_meta_lists = None # type: ignore
|
|
|
|
try:
|
|
from redmine_ticket_control_tags import add_tag as control_tag_add
|
|
except Exception:
|
|
control_tag_add = None # type: ignore
|
|
|
|
|
|
@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
|
|
private_note: Optional[str] = None
|
|
private: bool = True
|
|
|
|
|
|
@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
|
|
|
|
|
|
class ModernRedmineTUI:
|
|
"""Prompt-toolkit + Rich based TUI.
|
|
|
|
This is intentionally a work-in-progress skeleton that mirrors the
|
|
established workflow while giving us proper panes, mouse, live updates,
|
|
and nice visuals.
|
|
"""
|
|
|
|
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,
|
|
):
|
|
self.issues = issues
|
|
self.cache_dir = cache_dir
|
|
self.project = project
|
|
self.base_url = base_url
|
|
self.allow_prod_writes = allow_prod_writes
|
|
self.auto_apply = auto_apply
|
|
|
|
self.template = Template()
|
|
self.pending: dict[int, PendingChanges] = {}
|
|
self.filter_text = ""
|
|
self.filtered_indices: list[int] = list(range(len(issues)))
|
|
self.current_idx = 0
|
|
self.status_msg = "Ready. SPACE = paint template. A = commit+apply (1 confirm). R = live refresh."
|
|
|
|
self.console = Console()
|
|
self._meta = {}
|
|
|
|
def _current_issue(self) -> Optional[dict[str, Any]]:
|
|
if not self.filtered_indices:
|
|
return None
|
|
idx = min(self.current_idx, len(self.filtered_indices) - 1)
|
|
real = self.filtered_indices[idx]
|
|
return self.issues[real]
|
|
|
|
def apply_template_to_current(self) -> None:
|
|
issue = self._current_issue()
|
|
if not issue:
|
|
return
|
|
iid = int(issue["id"])
|
|
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
|
|
|
|
if self.template.status_id is not None:
|
|
ch.fields["status_id"] = self.template.status_id
|
|
if self.template.assigned_to_id is not None:
|
|
ch.fields["assigned_to_id"] = self.template.assigned_to_id
|
|
if self.template.category_id is not None:
|
|
ch.fields["category_id"] = self.template.category_id
|
|
if self.template.priority_id is not None:
|
|
ch.fields["priority_id"] = self.template.priority_id
|
|
if self.template.project_id is not None:
|
|
ch.fields["project_id"] = self.template.project_id
|
|
if self.template.local_control_tag:
|
|
ch.control_tags = [self.template.local_control_tag]
|
|
if self.template.private_note:
|
|
ch.private_note = self.template.private_note
|
|
ch.private = self.template.private
|
|
|
|
self.pending[iid] = ch
|
|
self.status_msg = f"Painted template on #{iid}"
|
|
|
|
def _refresh_list_view(self) -> None:
|
|
# In a real ptk+rich version we would trigger a Live update here.
|
|
pass
|
|
|
|
def run(self) -> int:
|
|
"""Very early skeleton. Real keyboard + layout comes next."""
|
|
self.console.print(
|
|
Panel.fit(
|
|
"[bold]Modern Redmine TUI (prompt_toolkit + rich)[/bold]\n"
|
|
"Skeleton active. Full keyboard layout + panes coming in next iterations.\n"
|
|
"For now use the stable curses TUI (remove --modern when wired).\n\n"
|
|
"Current tickets in view: " + str(len(self.issues))
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
# --- Wiring helpers (will be called from redmine_tickets.py later) ---
|
|
|
|
def add_modern_tui_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
p = subparsers.add_parser(
|
|
"tui-modern",
|
|
help="EXPERIMENTAL: prompt_toolkit + rich TUI (btop-style).",
|
|
)
|
|
# Reuse the same global flags
|
|
p.add_argument("--allow-prod-writes", action="store_true", help=argparse.SUPPRESS)
|
|
p.add_argument("--apply", action="store_true", help=argparse.SUPPRESS)
|
|
p.add_argument("--where", action="append", default=[], help="Local property filter.")
|
|
p.add_argument("--max-tickets", type=int, default=100)
|
|
p.add_argument("--open-only", action="store_true")
|
|
p.add_argument("--impersonate-user", default="rebot")
|
|
|
|
|
|
def run_modern_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 = ModernRedmineTUI(
|
|
issues,
|
|
cache_dir=cache_dir,
|
|
project=project,
|
|
base_url=base_url,
|
|
allow_prod_writes=allow_prod_writes,
|
|
auto_apply=auto_apply,
|
|
)
|
|
return tui.run()
|
|
|
|
|
|
def main_cli(args: argparse.Namespace, cache: "TicketCache") -> int: # type: ignore
|
|
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, ""):
|
|
args.base_url = os.environ.get("REDMINE_BASE_URL", "https://rm.ldrprep.com")
|
|
|
|
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")
|
|
)
|
|
|
|
matches = select_cached_issues_for_cli(cache, args) if select_cached_issues_for_cli else []
|
|
if not matches:
|
|
print("No matching tickets for modern TUI.", file=sys.stderr)
|
|
return 0
|
|
|
|
return run_modern_tui(
|
|
matches,
|
|
cache_dir=Path(args.cache_dir),
|
|
project=args.project,
|
|
base_url=args.base_url,
|
|
allow_prod_writes=effective_allow,
|
|
auto_apply=getattr(args, "apply", False),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Run via the main redmine_tickets.py / bin/rtl once the subcommand is registered.")
|
|
sys.exit(0)
|