1142 lines
49 KiB
Python
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")
|
|
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)
|