diff --git a/backups/ocr-rename-silent-20260909-142627/redmine_ticket_attachments.py b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_attachments.py new file mode 100644 index 0000000..9908bf1 --- /dev/null +++ b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_attachments.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Attachment helpers for the power-user Redmine ticket client. + +List (cache-first), download to local dir, upload via Redmine REST: + POST /uploads.json?filename=… (raw body) + PUT /issues/:id.json with issue.uploads token +""" + +from __future__ import annotations + +import json +import mimetypes +import re +from pathlib import Path +from typing import Any +from urllib import error as urllib_error +from urllib import parse as urllib_parse +from urllib import request as urllib_request + + +def safe_filename(name: str) -> str: + name = (name or "attachment.bin").strip().replace("\\", "_").replace("/", "_") + name = re.sub(r"[^\w.\- ()\[\]+@]+", "_", name) + return name or "attachment.bin" + + +def attachments_from_issue(issue: dict[str, Any]) -> list[dict[str, Any]]: + atts = issue.get("attachments") + if isinstance(atts, list): + return [a for a in atts if isinstance(a, dict)] + return [] + + +def attachment_dir(cache_dir: Path, project: str, issue_id: int) -> Path: + path = Path(cache_dir) / project / "attachments" / str(int(issue_id)) + path.mkdir(parents=True, exist_ok=True) + return path + + +def pick_attachment(attachments: list[dict[str, Any]], attachment_id: int | None) -> dict[str, Any]: + if not attachments: + raise LookupError("Issue has no attachments.") + if attachment_id is None: + if len(attachments) == 1: + return attachments[0] + raise LookupError( + "Multiple attachments; pass --attachment-id. ids=" + + ",".join(str(a.get("id")) for a in attachments) + ) + for att in attachments: + if int(att.get("id") or 0) == int(attachment_id): + return att + raise LookupError(f"Attachment id {attachment_id} not found on issue.") + + +def download_url_for_attachment(att: dict[str, Any], base_url: str) -> str: + urls = download_url_candidates(att, base_url) + return urls[0] + + +def download_url_candidates(att: dict[str, Any], base_url: str) -> list[str]: + """Ordered download URL candidates (host rewrite + path variants).""" + base = base_url.rstrip("/") + aid = att.get("id") + filename = str(att.get("filename") or "").strip() + candidates: list[str] = [] + + def add(url: str) -> None: + if url and url not in candidates: + candidates.append(url) + + for key in ("content_url", "contentUrl"): + raw = att.get(key) + if not raw: + continue + raw = str(raw) + add(raw) + # rewrite host to active base_url (prod content_url on LAN client, etc.) + try: + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(raw) + base_p = urlparse(base if "://" in base else f"http://{base}") + rewritten = urlunparse( + ( + base_p.scheme or parsed.scheme, + base_p.netloc or parsed.netloc, + parsed.path, + "", + parsed.query, + "", + ) + ) + add(rewritten) + except Exception: + pass + + if aid is not None: + add(f"{base}/attachments/download/{int(aid)}") + if filename: + from urllib.parse import quote + + add(f"{base}/attachments/download/{int(aid)}/{quote(filename)}") + if not candidates: + raise LookupError("Attachment missing id and content_url.") + return candidates + + +def download_attachment_bytes( + *, + url: str, + api_key: str, + impersonate_user: str | None = None, + timeout: int = 120, + fallback_urls: list[str] | None = None, +) -> bytes: + headers = { + "X-Redmine-API-Key": api_key, + "Accept": "*/*", + } + if impersonate_user: + headers["X-Redmine-Switch-User"] = impersonate_user + urls = [url] + [u for u in (fallback_urls or []) if u != url] + last_err: Exception | None = None + for u in urls: + req = urllib_request.Request(u, headers=headers, method="GET") + try: + with urllib_request.urlopen(req, timeout=timeout) as resp: + return resp.read() + except urllib_error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:200] + last_err = RuntimeError(f"HTTP {exc.code} downloading attachment ({u}): {detail}") + continue + except urllib_error.URLError as exc: + last_err = RuntimeError(f"Could not download attachment ({u}): {exc.reason}") + continue + raise last_err or RuntimeError("Attachment download failed") + + +def upload_file_token( + *, + base_url: str, + api_key: str, + path: Path, + filename: str | None = None, + impersonate_user: str | None = None, + timeout: int = 120, +) -> dict[str, Any]: + """POST raw file to /uploads.json?filename=… Returns upload object with token.""" + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"File not found: {path}") + name = filename or path.name + data = path.read_bytes() + q = urllib_parse.urlencode({"filename": name}) + url = f"{base_url.rstrip('/')}/uploads.json?{q}" + headers = { + "X-Redmine-API-Key": api_key, + "Content-Type": "application/octet-stream", + "Accept": "application/json", + } + if impersonate_user: + headers["X-Redmine-Switch-User"] = impersonate_user + req = urllib_request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib_request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + except urllib_error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"HTTP {exc.code} uploading file: {detail}") from exc + except urllib_error.URLError as exc: + raise RuntimeError(f"Could not upload file: {exc.reason}") from exc + if not raw: + raise RuntimeError("Empty response from /uploads.json") + payload = json.loads(raw.decode("utf-8")) + upload = payload.get("upload") if isinstance(payload, dict) else None + if not isinstance(upload, dict) or not upload.get("token"): + raise RuntimeError(f"Unexpected upload response: {payload!r}") + content_type = mimetypes.guess_type(name)[0] or "application/octet-stream" + upload.setdefault("filename", name) + upload.setdefault("content_type", content_type) + return upload diff --git a/backups/ocr-rename-silent-20260909-142627/redmine_ticket_ocr.py b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_ocr.py new file mode 100644 index 0000000..e1a1896 --- /dev/null +++ b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_ocr.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""OCR / text extract helpers for scanned ticket attachments. + +Primary path: pdftotext (fast, works on digital PDFs). +Fallback: pdftoppm + tesseract for image-heavy scans. +Also handles common image attachments via tesseract. + +Does not write to Redmine — callers queue subject/notes drafts. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + + +def _run(cmd: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def extract_text_from_pdf(path: Path, *, max_chars: int = 12000) -> tuple[str, str]: + """Return (text, method). method is pdftotext|tesseract|empty.""" + path = Path(path) + text = "" + pdftotext = shutil.which("pdftotext") + if pdftotext: + proc = _run([pdftotext, "-layout", "-nopgbrk", str(path), "-"], timeout=60) + text = (proc.stdout or "").strip() + if len(re.sub(r"\s+", "", text)) >= 40: + return text[:max_chars], "pdftotext" + + # OCR fallback: first 2 pages as PNG + pdftoppm = shutil.which("pdftoppm") + tesseract = shutil.which("tesseract") + if not pdftoppm or not tesseract: + return text[:max_chars] if text else "", "empty" if not text else "pdftotext-thin" + + with tempfile.TemporaryDirectory(prefix="rtl-ocr-") as td: + out_prefix = Path(td) / "page" + _run([pdftoppm, "-png", "-f", "1", "-l", "2", "-r", "200", str(path), str(out_prefix)], timeout=90) + chunks: list[str] = [] + for png in sorted(Path(td).glob("page*.png")): + proc = _run([tesseract, str(png), "stdout", "-l", "eng"], timeout=90) + if proc.stdout: + chunks.append(proc.stdout.strip()) + ocr_text = "\n\n".join(chunks).strip() + if ocr_text: + return ocr_text[:max_chars], "tesseract" + return text[:max_chars] if text else "", "empty" + + +def extract_text_from_image(path: Path, *, max_chars: int = 12000) -> tuple[str, str]: + tesseract = shutil.which("tesseract") + if not tesseract: + return "", "empty" + proc = _run([tesseract, str(path), "stdout", "-l", "eng"], timeout=90) + text = (proc.stdout or "").strip() + return (text[:max_chars], "tesseract") if text else ("", "empty") + + +def extract_text_from_file(path: Path, *, max_chars: int = 12000) -> tuple[str, str]: + path = Path(path) + suffix = path.suffix.lower() + if suffix == ".pdf": + return extract_text_from_pdf(path, max_chars=max_chars) + if suffix in {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp", ".gif"}: + return extract_text_from_image(path, max_chars=max_chars) + if suffix in {".txt", ".md", ".csv"}: + try: + return path.read_text(encoding="utf-8", errors="replace")[:max_chars], "read" + except OSError: + return "", "empty" + return "", "empty" + + +_PERSON_LINE = re.compile( + r"^(?:name|employee|applicant|patient|to|for)\s*[:\-]\s*(.+)$", + re.I | re.M, +) +_COMPANY_LINE = re.compile( + r"^(?:company|organization|employer|vendor|client)\s*[:\-]\s*(.+)$", + re.I | re.M, +) +_DATE_LINE = re.compile( + r"\b((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\b", + re.I, +) +_DOC_HINTS = ( + (r"\bw-?2\b", "W-2"), + (r"\bw-?4\b", "W-4"), + (r"\bi-?9\b", "I-9"), + (r"\bdriver.?s?\s+license\b|\bdl\b", "Driver License"), + (r"\bpassport\b", "Passport"), + (r"\binvoice\b", "Invoice"), + (r"\bpacking\s+slip\b", "Packing Slip"), + (r"\bbill\s+of\s+lading\b|\bbol\b", "Bill of Lading"), + (r"\bresume\b|\bcv\b", "Resume"), + (r"\boffer\s+letter\b", "Offer Letter"), + (r"\bnda\b|non-?disclosure", "NDA"), + (r"\bhandbook\b", "Handbook"), + (r"\bdirect\s+deposit\b", "Direct Deposit Form"), +) + + +def suggest_subject_from_text( + text: str, + *, + original_subject: str = "", + max_len: int = 120, +) -> str: + """Heuristic subject for scanned-doc tickets.""" + clean = re.sub(r"[ \t]+", " ", text or "") + lines = [ln.strip() for ln in clean.splitlines() if ln.strip()] + # Drop very short / noise lines + content_lines = [ln for ln in lines if len(ln) >= 4 and not re.fullmatch(r"[\d\W]+", ln)][:40] + + doc_type = "Scanned document" + joined = "\n".join(content_lines[:30]) + for pat, label in _DOC_HINTS: + if re.search(pat, joined, re.I): + doc_type = label + break + + person = None + m = _PERSON_LINE.search(joined) + if m: + person = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60] + company = None + m = _COMPANY_LINE.search(joined) + if m: + company = re.sub(r"\s+", " ", m.group(1)).strip(" .,-")[:60] + + # Fallback: first substantial title-ish line + title_line = None + for ln in content_lines[:8]: + if len(ln) < 8 or len(ln) > 80: + continue + if re.search(r"https?://|@|page\s+\d", ln, re.I): + continue + title_line = ln + break + + date = None + dm = _DATE_LINE.search(joined) + if dm: + date = dm.group(1) + + parts: list[str] = [doc_type] + if person: + parts.append(f"— {person}") + elif company: + parts.append(f"— {company}") + elif title_line and title_line.lower() not in doc_type.lower(): + parts.append(f"— {title_line[:50]}") + if date and date not in " ".join(parts): + parts.append(f"({date})") + + subject = " ".join(parts) + subject = re.sub(r"\s+", " ", subject).strip() + if len(subject) > max_len: + subject = subject[: max_len - 1].rstrip() + "…" + if not subject or subject == "Scanned document": + # last resort: original if not generic + orig = (original_subject or "").strip() + if orig and not re.search(r"scanned\s+document", orig, re.I): + return orig[:max_len] + if title_line: + return f"Scanned document — {title_line[:80]}"[:max_len] + return subject + + +def attachment_is_ocr_candidate(att: dict[str, Any]) -> bool: + name = str(att.get("filename") or att.get("name") or "").lower() + ctype = str(att.get("content_type") or "").lower() + if name.endswith(".pdf") or "pdf" in ctype: + return True + if any(name.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp")): + return True + if ctype.startswith("image/"): + return True + return False + + +def looks_like_generic_scan_subject(subject: str) -> bool: + s = (subject or "").strip().lower() + if not s: + return True + return bool( + re.search( + r"scanned\s+document|scan\s+from|hp\s+scan|scanner|untitled|document\s+from\s+scan", + s, + ) + ) + + +def build_ocr_note( + *, + issue_id: int, + files: list[dict[str, str]], + proposed_subject: str, +) -> str: + lines = [ + "=== OCR / attachment review (agent) ===", + f"issue_id: {issue_id}", + f"proposed_subject: {proposed_subject}", + "", + ] + for f in files: + lines.append(f"## {f.get('filename')} ({f.get('method')})") + lines.append("") + snippet = (f.get("text") or "")[:2500].strip() + lines.append(snippet or "_(no extractable text)_") + lines.append("") + lines.append("_Generated by redmine_ticket_ocr; verify before customer-facing use._") + return "\n".join(lines) + "\n" diff --git a/backups/ocr-rename-silent-20260909-142627/redmine_ticket_view.py b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_view.py new file mode 100644 index 0000000..6067613 --- /dev/null +++ b/backups/ocr-rename-silent-20260909-142627/redmine_ticket_view.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Markdown rendering + pager helpers for cached Redmine tickets.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + + +def _nested_name(issue: dict[str, Any], key: str) -> str: + value = issue.get(key) + if isinstance(value, dict): + name = value.get("name") + return str(name) if name is not None else "" + return "" if value is None else str(value) + + +def _helpdesk(issue: dict[str, Any]) -> dict[str, Any]: + hd = issue.get("helpdesk_ticket") or {} + return hd if isinstance(hd, dict) else {} + + +def _contact(issue: dict[str, Any]) -> dict[str, Any]: + contact = _helpdesk(issue).get("contact") or {} + return contact if isinstance(contact, dict) else {} + + +def render_ticket_markdown(issue: dict[str, Any]) -> str: + issue_id = issue.get("id") + subject = str(issue.get("subject") or "").strip() + status = _nested_name(issue, "status") + project = _nested_name(issue, "project") + category = _nested_name(issue, "category") + assigned = _nested_name(issue, "assigned_to") or "(unassigned)" + author = _nested_name(issue, "author") + priority = _nested_name(issue, "priority") + created = issue.get("created_on") or "" + updated = issue.get("updated_on") or "" + hd = _helpdesk(issue) + contact = _contact(issue) + from_addr = hd.get("from_address") or contact.get("email") or "" + contact_id = contact.get("id") + company = contact.get("company") or "" + contact_name = contact.get("name") or "" + description = str(issue.get("description") or "").strip() + redmine_url = f"https://rm.ldrprep.com/issues/{issue_id}" if issue_id else "" + + lines = [ + f"# #{issue_id} — {subject}", + "", + "## Meta", + "", + f"- **Project:** {project}", + f"- **Status:** {status}", + f"- **Priority:** {priority}", + f"- **Category:** {category or '(none)'}", + f"- **Assignee:** {assigned}", + f"- **Author:** {author}", + f"- **Created:** {created}", + f"- **Updated:** {updated}", + f"- **Redmine:** {redmine_url}", + "", + "## Helpdesk / contact", + "", + f"- **From:** {from_addr}", + f"- **Contact ID:** {contact_id if contact_id is not None else '(none)'}", + f"- **Contact name:** {contact_name or '(none)'}", + f"- **Company:** {company or '(none)'}", + "", + "## Detected entities (heuristic)", + "", + ] + try: + from redmine_ticket_entities import extract_entities_from_issue, format_entities_line + + ents = extract_entities_from_issue(issue) + if ents: + for e in ents: + conf = e.get("confidence") or "high" + lines.append(f"- **{e.get('type')}:** `{e.get('value')}` ({conf})") + else: + lines.append("_(none)_") + except Exception: + lines.append("_(entity extractor unavailable)_") + lines.extend( + [ + "", + "## Description", + "", + description or "_(empty)_", + "", + "## Journals", + "", + ] + ) + + journals = issue.get("journals") or [] + if not journals: + lines.append("_(no journals)_") + else: + for journal in journals: + if not isinstance(journal, dict): + continue + jid = journal.get("id") + jcreated = journal.get("created_on") or "" + user = journal.get("user") or {} + uname = user.get("name") if isinstance(user, dict) else "" + notes = str(journal.get("notes") or "").strip() + details = journal.get("details") or [] + lines.append(f"### Journal #{jid} — {jcreated} — {uname}") + lines.append("") + if details: + for detail in details: + if not isinstance(detail, dict): + continue + lines.append( + f"- change: {detail.get('property')}/{detail.get('name')}: " + f"{detail.get('old_value')!r} → {detail.get('new_value')!r}" + ) + lines.append("") + if notes: + lines.append(notes) + else: + lines.append("_(no notes)_") + lines.append("") + + lines.append("---") + lines.append(f"_Rendered from cache; source updated_on={updated}_") + lines.append("") + return "\n".join(lines) + + +def markdown_cache_path(cache_dir: Path, project: str, issue_id: int | str) -> Path: + return Path(cache_dir) / project / "markdown" / f"{issue_id}.md" + + +def write_ticket_markdown( + issue: dict[str, Any], + cache_dir: Path, + project: str, + *, + force: bool = False, +) -> Path: + """Write markdown cache if missing or ticket updated_on changed (via sidecar stamp).""" + issue_id = issue["id"] + path = markdown_cache_path(cache_dir, project, issue_id) + stamp_path = path.with_suffix(".md.updated_on") + updated_on = str(issue.get("updated_on") or "") + path.parent.mkdir(parents=True, exist_ok=True) + if ( + not force + and path.is_file() + and stamp_path.is_file() + and stamp_path.read_text(encoding="utf-8").strip() == updated_on + ): + return path + path.write_text(render_ticket_markdown(issue), encoding="utf-8") + stamp_path.write_text(updated_on + "\n", encoding="utf-8") + return path + + +def resolve_pager(preferred: str = "auto") -> list[str]: + """Return argv prefix to display markdown (pager program + flags).""" + preferred = (preferred or "auto").lower() + if preferred == "cat": + return ["cat"] + if preferred in {"glow", "bat", "batcat"}: + path = shutil.which(preferred) + if not path: + raise FileNotFoundError(f"Pager {preferred!r} not found on PATH") + if preferred == "glow": + return [path] + return [path, "--language", "markdown", "--style", "plain"] + # auto + for name, args in ( + ("glow", []), + ("bat", ["--language", "markdown", "--style", "plain"]), + ("batcat", ["--language", "markdown", "--style", "plain"]), + ): + path = shutil.which(name) + if path: + return [path, *args] + return ["cat"] + + +def display_markdown_file(path: Path, pager: str = "auto") -> int: + cmd = resolve_pager(pager) + [str(path)] + # Prefer streaming to user terminal + env = os.environ.copy() + try: + completed = subprocess.run(cmd, check=False, env=env) + return int(completed.returncode) + except FileNotFoundError: + print(path.read_text(encoding="utf-8")) + return 0 diff --git a/backups/ocr-rename-silent-20260909-142627/redmine_tickets.py b/backups/ocr-rename-silent-20260909-142627/redmine_tickets.py new file mode 100755 index 0000000..da21bcf --- /dev/null +++ b/backups/ocr-rename-silent-20260909-142627/redmine_tickets.py @@ -0,0 +1,4886 @@ +#!/usr/bin/env python3 +"""Incremental Redmine issue sync/search foundation. + +This is the ticket-centric companion to redmine_contacts.py. It caches enriched +Redmine issue payloads locally using the standard Redmine issue API with the +local Helpdesk plugin's `include=helpdesk` support. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Iterable + +from redmine_contacts import ( + DEFAULT_BASE_URL, + DEFAULT_PROJECT, + RedmineClient, + RedmineError, + extract_tag_list, + merge_tags, +) + +try: + from redmine_ticket_query import QueryError, filter_by_query +except ImportError: # pragma: no cover + QueryError = ValueError # type: ignore[misc, assignment] + filter_by_query = None # type: ignore[assignment] + +try: + from redmine_ticket_view import display_markdown_file, render_ticket_markdown, write_ticket_markdown +except ImportError: # pragma: no cover + display_markdown_file = None # type: ignore[assignment] + render_ticket_markdown = None # type: ignore[assignment] + write_ticket_markdown = None # type: ignore[assignment] + +try: + from redmine_ticket_filters import ( + DEFAULT_SAVED_FILTERS_DIR, + expand_where_list, + load_queue_definitions, + materialize_queue, + ) +except ImportError: # pragma: no cover + DEFAULT_SAVED_FILTERS_DIR = Path("saved-filters") # type: ignore[misc, assignment] + expand_where_list = None # type: ignore[assignment] + load_queue_definitions = None # type: ignore[assignment] + materialize_queue = None # type: ignore[assignment] + +try: + from redmine_ticket_drafts import ( + KIND_CONTACT_FIELDS, + KIND_CUSTOMER_EMAIL, + KIND_FIELDS, + KIND_INTERNAL_NOTE, + add_draft, + assert_write_target_allowed, + cancel_all_pending, + cancel_draft, + kind_label, + list_drafts, + load_draft, + mark_draft_applied, + mark_draft_error, + normalize_kind, + ) +except ImportError: # pragma: no cover + KIND_CONTACT_FIELDS = "contact_fields" # type: ignore[misc, assignment] + KIND_CUSTOMER_EMAIL = "customer_email" # type: ignore[misc, assignment] + KIND_FIELDS = "fields" # type: ignore[misc, assignment] + KIND_INTERNAL_NOTE = "internal_note" # type: ignore[misc, assignment] + add_draft = None # type: ignore[assignment] + assert_write_target_allowed = None # type: ignore[assignment] + cancel_all_pending = None # type: ignore[assignment] + cancel_draft = None # type: ignore[assignment] + kind_label = None # type: ignore[assignment] + list_drafts = None # type: ignore[assignment] + load_draft = None # type: ignore[assignment] + mark_draft_applied = None # type: ignore[assignment] + mark_draft_error = None # type: ignore[assignment] + normalize_kind = None # type: ignore[assignment] + +try: + from redmine_ticket_control_tags import ( + add_tag as control_tag_add, + annotate_issues_with_control_tags, + filter_issues_by_control_tag, + list_tags as control_tag_list, + remove_tag as control_tag_remove, + tags_for_issue, + tickets_for_tag, + wipe_all as control_tag_wipe, + ) +except ImportError: # pragma: no cover + control_tag_add = None # type: ignore[assignment] + annotate_issues_with_control_tags = None # type: ignore[assignment] + filter_issues_by_control_tag = None # type: ignore[assignment] + control_tag_list = None # type: ignore[assignment] + control_tag_remove = None # type: ignore[assignment] + tags_for_issue = None # type: ignore[assignment] + tickets_for_tag = None # type: ignore[assignment] + control_tag_wipe = None # type: ignore[assignment] + +# Lazy import to avoid circular dependency with redmine_triage +add_triage_parser = None +def _get_add_triage_parser(): + global add_triage_parser + if add_triage_parser is None: + try: + from redmine_triage import add_triage_parser as _atp + add_triage_parser = _atp + except Exception: + add_triage_parser = None + return add_triage_parser + +try: + from redmine_ticket_meta import refresh_meta_cache, resolve_field_names +except ImportError: # pragma: no cover + refresh_meta_cache = None # type: ignore[assignment] + resolve_field_names = None # type: ignore[assignment] + +try: + from redmine_ticket_contacts import ( + ContactCache, + contact_display_name, + contact_id_from_issue, + filter_contacts, + primary_email, + sync_contacts, + ) +except ImportError: # pragma: no cover + ContactCache = None # type: ignore[misc, assignment] + contact_display_name = None # type: ignore[assignment] + contact_id_from_issue = None # type: ignore[assignment] + filter_contacts = None # type: ignore[assignment] + primary_email = None # type: ignore[assignment] + sync_contacts = None # type: ignore[assignment] + +try: + from redmine_ticket_attachments import ( + attachment_dir, + attachments_from_issue, + download_attachment_bytes, + download_url_for_attachment, + pick_attachment, + safe_filename, + upload_file_token, + ) +except ImportError: # pragma: no cover + attachment_dir = None # type: ignore[assignment] + attachments_from_issue = None # type: ignore[assignment] + download_attachment_bytes = None # type: ignore[assignment] + download_url_for_attachment = None # type: ignore[assignment] + pick_attachment = None # type: ignore[assignment] + safe_filename = None # type: ignore[assignment] + upload_file_token = None # type: ignore[assignment] + +try: + from redmine_ticket_entities import ( + extract_entities_from_issue, + format_entities_line, + ) + from redmine_ticket_nmi import brief_for_issue, format_private_note +except ImportError: # pragma: no cover + extract_entities_from_issue = None # type: ignore[assignment] + format_entities_line = None # type: ignore[assignment] + brief_for_issue = None # type: ignore[assignment] + format_private_note = None # type: ignore[assignment] + +try: + from redmine_ticket_control_export import export_control_tag_jsonl +except ImportError: # pragma: no cover + export_control_tag_jsonl = None # type: ignore[assignment] + +try: + from redmine_ticket_browse import announce_draft, prompt_multiline, run_browse_loop +except ImportError: # pragma: no cover + announce_draft = None # type: ignore[assignment] + prompt_multiline = None # type: ignore[assignment] + run_browse_loop = None # type: ignore[assignment] + +try: + from redmine_ticket_tui import add_tui_parser, main_cli as run_tui_cli +except ImportError: # pragma: no cover + add_tui_parser = None # type: ignore[assignment] + run_tui_cli = None # type: ignore[assignment] + +try: + from redmine_ticket_textual_tui import ( + add_textual_tui_parser, + run_textual_tui_cli, + ) +except ImportError: # pragma: no cover + add_textual_tui_parser = None # type: ignore[assignment] + run_textual_tui_cli = None # type: ignore[assignment] + +try: + from redmine_ticket_ocr import ( + attachment_is_ocr_candidate, + build_ocr_note, + extract_text_from_file, + looks_like_generic_scan_subject, + suggest_subject_from_text, + ) +except ImportError: # pragma: no cover + attachment_is_ocr_candidate = None # type: ignore[assignment] + build_ocr_note = None # type: ignore[assignment] + extract_text_from_file = None # type: ignore[assignment] + looks_like_generic_scan_subject = None # type: ignore[assignment] + suggest_subject_from_text = None # type: ignore[assignment] + +# Lazy import to avoid circular dependency with redmine_triage +add_triage_parser = None +def _get_add_triage_parser(): + global add_triage_parser + if add_triage_parser is None: + try: + from redmine_triage import add_triage_parser as _atp + add_triage_parser = _atp + except Exception: + add_triage_parser = None + return add_triage_parser + +try: + from redmine_ticket_meta import fetch_project_categories, load_meta_lists +except ImportError: # pragma: no cover + fetch_project_categories = None # type: ignore[assignment] + load_meta_lists = None # type: ignore[assignment] + +DEFAULT_CACHE_DIR = Path(".cache/redmine_tickets") +ORDER_CHOICES = ( + "as-is", + "oldest-updated-first", + "newest-updated-first", + "id-asc", + "id-desc", +) +SENDER_SPAM_HINTS = re.compile( + r"\b(no-?reply|noreply|deals?|promotions?|marketing|newsletter|offers?|sales?)\b", + re.IGNORECASE, +) +PROMO_LANGUAGE = re.compile( + r"\b(discount|coupon|deal|promo|sale|offer|free|save|% off|limited time|up to \d+% off)\b", + re.IGNORECASE, +) +SALES_PITCH_LANGUAGE = re.compile( + r"\b(commercial cleaning|janitorial|cleaning services|we would like to offer|trying to reach|lead generation|marketing services|advertis(?:e|ing)|staffing services|outsourcing|managed services)\b", + re.IGNORECASE, +) +AUTOMATED_OPERATIONAL = re.compile( + r"\b(fba inventory|unfulfillable fba|inventory removal|listings api|amazon seller|payment status|shipment status|tracking update|invoice|return notification|ereceipt|e-?receipt|money order|undelivered mail|returned to sender|message could not be delivered|delivery status notification|critical security alert|security alert|saved passwords|passwords were found|google account|call transcription|voicemail|new message in mailbox|message transcription|voip\.ms|voipinterface)\b", + re.IGNORECASE, +) +INBOUND_BUSINESS_INQUIRY = re.compile( + r"\b(we(?:'| a)?re interested in purchasing|interested in purchasing|do you have any available|already working with a buyer|still have the capacity to support|workflow we previously discussed|ready to activate|returns workflow we previously discussed|do you process|rate sheet|capabilities and pricing)\b", + re.IGNORECASE, +) +VAULT_ONYX_PREFIX = "https://onyx.int.ldrprep.com/projects/obsidian-ldr/" +ANALYSIS_MODES = ("deterministic", "llm") +# ============================================================================= +# Known Spam Profile System (Fast Path) +# ============================================================================= + +def load_known_spam_profiles(path: Path) -> dict[str, Any]: + """Load a known-spam profile JSON file.""" + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + data.setdefault("universal_safety_gates", {}) + data.setdefault("profiles", []) + return data + + +def _compile_profile_regexes(profile: dict[str, Any]) -> dict[str, list[re.Pattern]]: + """Compile sender and subject regexes for a single profile.""" + compiled = {"sender": [], "subject": []} + for pattern in profile.get("sender_regexes", []): + compiled["sender"].append(re.compile(pattern, re.IGNORECASE)) + for pattern in profile.get("subject_regexes", []): + compiled["subject"].append(re.compile(pattern, re.IGNORECASE)) + return compiled + + +def match_known_spam_profile(issue: dict[str, Any], profile: dict[str, Any], compiled: dict[str, list[re.Pattern]]) -> bool: + """Return True if the issue matches the given profile.""" + from_addr = ticket_from_address(issue) + subject = str(issue.get("subject") or "") + + sender_match = any(p.search(from_addr) for p in compiled["sender"]) + subject_match = any(p.search(subject) for p in compiled["subject"]) + + if compiled["sender"] and compiled["subject"]: + return sender_match and subject_match + return sender_match or subject_match + + +def get_known_spam_matches(issue: dict[str, Any], profiles: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return list of matching profiles for an issue.""" + matches = [] + for profile in profiles: + compiled = _compile_profile_regexes(profile) + if match_known_spam_profile(issue, profile, compiled): + matches.append(profile) + return matches + + +def ticket_was_reopened_after_close(issue: dict[str, Any]) -> bool: + """Check journals for evidence the ticket was closed then reopened by a new message.""" + for journal in issue.get("journals", []) or []: + details = journal.get("details") or [] + for detail in details: + if detail.get("property") == "attr" and detail.get("name") == "status_id": + old = str(detail.get("old_value") or "") + new = str(detail.get("new_value") or "") + if old in ("5", "Closed", "Resolved") and new not in ("5", "Closed", "Resolved"): + return True + return False + + +def should_skip_known_spam_close(issue: dict[str, Any], client: Any, safety_gates: dict[str, Any]) -> tuple[bool, str]: + """Return (should_skip, reason).""" + status_name = (nested_name(issue, "status") or "").lower() + if status_name in ("closed", "resolved"): + return True, "already_closed" + + contact_id = ticket_contact_id(issue) + if contact_id: + contact = client.get_contact(contact_id) + tag_list = [t.lower() for t in extract_tag_list(contact)] + for forbidden in safety_gates.get("skip_contact_tags", ["customer", "maybe-customer"]): + if forbidden.lower() in tag_list: + return True, f"contact_has_{forbidden.lower()}_tag" + + if ticket_was_reopened_after_close(issue): + return True, "reopened_after_close" + + return False, "" + + +def run_known_spam_scan( + cache: TicketCache, + profiles_path: Path, + max_tickets: int | None = None, +) -> list[dict[str, Any]]: + """Scan cached issues and return matches (dry-run).""" + profile_data = load_known_spam_profiles(profiles_path) + profiles = profile_data.get("profiles", []) + + results = [] + issues = list(cache.iter_cached_issues()) + if max_tickets: + issues = issues[:max_tickets] + + for issue in issues: + matches = get_known_spam_matches(issue, profiles) + if matches: + results.append({ + "issue_id": issue.get("id"), + "subject": issue.get("subject"), + "from": ticket_from_address(issue), + "contact_id": ticket_contact_id(issue), + "matched_profiles": [p["name"] for p in matches], + }) + return results + + +def run_known_spam_close( + client: Any, + cache: TicketCache, + profiles_path: Path, + close_status_id: int = 5, + apply: bool = False, + max_tickets: int | None = None, +) -> list[dict[str, Any]]: + """Close issues matching known spam profiles.""" + profile_data = load_known_spam_profiles(profiles_path) + profiles = profile_data.get("profiles", []) + safety_gates = profile_data.get("universal_safety_gates", {}) + impersonate = safety_gates.get("write_impersonate_user", "rebot") + + plans = [] + issues = list(cache.iter_cached_issues()) + if max_tickets: + issues = issues[:max_tickets] + + for issue in issues: + matches = get_known_spam_matches(issue, profiles) + if not matches: + continue + + issue_id = issue.get("id") + if issue_id is None: + continue + + skip, reason = should_skip_known_spam_close(issue, client, safety_gates) + if skip: + if apply: + cache.record_action(issue_id, "known-spam-close", f"skipped:{reason}") + continue + + if cache.has_action(issue_id, "known-spam-close"): + continue + + payload = { + "status_id": close_status_id, + "notes": f"Closed as known promotional spam. Profiles: {', '.join(p['name'] for p in matches)}", + } + + plan = { + "issue_id": issue_id, + "subject": issue.get("subject"), + "from": ticket_from_address(issue), + "matched_profiles": [p["name"] for p in matches], + "payload": payload, + } + plans.append(plan) + + if apply: + client.update_issue(int(issue_id), payload, impersonate_user=impersonate) + cache.record_action(issue_id, "known-spam-close", "closed", {"profiles": [p["name"] for p in matches]}) + + return plans + + +# ============================================================================= +# End Known Spam Profile System +# ============================================================================= + + + +INBOUND_BUSINESS_INQUIRY = re.compile( + r"\b(we(?:'| a)?re interested in purchasing|interested in purchasing|do you have any available|already working with a buyer|still have the capacity to support|workflow we previously discussed|ready to activate|returns workflow we previously discussed|do you process|rate sheet|capabilities and pricing)\b", + re.IGNORECASE, +) +VAULT_ONYX_PREFIX = "https://onyx.int.ldrprep.com/projects/obsidian-ldr/" + + +def slugify_note(value: str) -> str: + text = value.strip().lower() + text = re.sub(r"[’']", "", text) + text = re.sub(r"[^a-z0-9]+", "-", text) + return text.strip("-") or "untitled" + + +def first_sentence(value: str, max_len: int = 240) -> str: + text = re.sub(r"\s+", " ", value or "").strip() + if not text: + return "" + match = re.search(r"(?<=[.!?])\s+", text) + sentence = text[: match.start()] if match else text + if len(sentence) > max_len: + sentence = sentence[: max_len - 1].rstrip() + "…" + return sentence + + +def parse_dt(value: str | None) -> datetime | None: + if not value: + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + if "T" not in text and len(text) == 10: + text = text + "T00:00:00+00:00" + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +_RELATIVE_TIME_RE = re.compile( + r"^(?P\d+)\s*(?Pd|day|days|h|hour|hours|m|min|mins|minute|minutes|w|week|weeks)\s*(?Pago)?$", + re.IGNORECASE, +) +_REMOTE_OPERATOR_PREFIXES = (">=", "<=", "><", ">", "<", "=") + + +def format_redmine_time(dt: datetime, *, date_only: bool = False) -> str: + """Format a datetime for Redmine REST date filters.""" + utc = dt.astimezone(timezone.utc).replace(microsecond=0) + if date_only: + return utc.date().isoformat() + return utc.isoformat().replace("+00:00", "Z") + + +def coerce_time_value(value: str, *, now: datetime | None = None) -> tuple[datetime, bool]: + """Parse absolute or relative time into (datetime, is_date_only). + + Accepts: + - YYYY-MM-DD + - ISO datetime (with optional Z) + - relative: 30d, 2h, 15m, 1w, '2 days ago', '30 days ago' + """ + text = (value or "").strip() + if not text: + raise RedmineError("Empty time value.") + + relative = _RELATIVE_TIME_RE.fullmatch(text) + if relative: + amount = int(relative.group("n")) + unit = relative.group("unit").lower() + anchor = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if unit.startswith("w"): + delta = amount * 7 * 24 * 3600 + elif unit.startswith("d"): + delta = amount * 24 * 3600 + elif unit.startswith("h"): + delta = amount * 3600 + else: + delta = amount * 60 + return anchor - timedelta(seconds=delta), False + + # Pure calendar date → date-only Redmine filter (start-of-day semantics on server). + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text): + dt = parse_dt(text) + if dt is None: + raise RedmineError(f"Could not parse date: {value!r}") + return dt, True + + dt = parse_dt(text) + if dt is None: + raise RedmineError( + f"Could not parse time value {value!r}. " + "Use YYYY-MM-DD, ISO datetime, or relative forms like 30d / '2 days ago'." + ) + return dt, False + + +def build_remote_updated_on_value( + raw: str | None = None, + since: str | None = None, + before: str | None = None, + *, + now: datetime | None = None, +) -> str | None: + """Build Redmine `updated_on` API filter value. + + Raw form (official REST syntax) wins if provided: + >=2014-01-02T08:12:32Z + <=2012-03-07 + ><2012-03-01|2012-03-07 + + Convenience flags map to the same operators: + since only → >=... + before only → <=... + both → >= (common cron mistake). + if not raw_text.startswith(_REMOTE_OPERATOR_PREFIXES): + return f">={raw_text}" + return raw_text + + if not since_text and not before_text: + return None + + since_part = None + before_part = None + if since_text: + dt, date_only = coerce_time_value(since_text, now=now) + since_part = format_redmine_time(dt, date_only=date_only) + if before_text: + dt, date_only = coerce_time_value(before_text, now=now) + before_part = format_redmine_time(dt, date_only=date_only) + + if since_part and before_part: + return f"><{since_part}|{before_part}" + if since_part: + return f">={since_part}" + return f"<={before_part}" + + +def nested_name(issue: dict[str, Any], key: str) -> str | None: + value = issue.get(key) + if isinstance(value, dict): + name = value.get("name") + return str(name) if name is not None else None + if value is None: + return None + return str(value) + + +def ticket_contact(issue: dict[str, Any]) -> dict[str, Any]: + helpdesk = issue.get("helpdesk_ticket") or {} + contact = helpdesk.get("contact") or {} + return contact if isinstance(contact, dict) else {} + + +def ticket_from_address(issue: dict[str, Any]) -> str: + helpdesk = issue.get("helpdesk_ticket") or {} + return str(helpdesk.get("from_address") or ticket_contact(issue).get("email") or "") + + +def issue_text_for_decomposition(issue: dict[str, Any]) -> str: + parts = [str(issue.get("subject") or ""), str(issue.get("description") or "")] + for journal in issue.get("journals", []) or []: + notes = str(journal.get("notes") or "").strip() + if notes: + parts.append(notes) + return "\n".join(parts) + + +def clean_message_block(text: str) -> str: + lines: list[str] = [] + for raw_line in (text or "").replace("\r", "\n").split("\n"): + line = raw_line.strip() + if not line: + continue + if re.match(r"^(from|subject|to|date|phone):\s", line, re.IGNORECASE): + continue + if line.lower() == "message body:": + continue + if re.match(r"^-{5,}\s*forwarded message\s*-{5,}$", line, re.IGNORECASE): + continue + if line == "--" or line.startswith("-- "): + break + if line.startswith(">"): + continue + if "this e-mail was sent from a contact form" in line.lower(): + continue + if re.match(r"^on .+ wrote:$", line, re.IGNORECASE): + break + if line.startswith(" str: + """Return a compact source text suitable for decomposition prompts/plans.""" + parts: list[str] = [] + description = clean_message_block(str(issue.get("description") or "")) + if description: + parts.append(description) + for journal in issue.get("journals", []) or []: + notes = clean_message_block(str(journal.get("notes") or "")) + if notes: + user = (journal.get("user") or {}).get("name") if isinstance(journal.get("user"), dict) else None + prefix = f"{user}: " if user else "" + parts.append(prefix + notes) + return "\n".join(dict.fromkeys(parts)) + + +def extract_decomposition_analysis(issue: dict[str, Any], org_name: str, buckets: list[str], tags: list[str]) -> dict[str, Any]: + """Deterministic KarpathyWiki-style structured analysis for the first dry-run slice. + + This intentionally mirrors the plugin's source-analysis shape without calling an LLM yet. + """ + text = clean_ticket_text(issue) + text_l = text.lower() + entities = [ + { + "name": org_name, + "type": "organization", + "summary": f"Organization mentioned in Redmine #{issue.get('id')}.", + "mentions_in_source": [org_name], + "related_entities": [], + "related_concepts": [], + } + ] + concepts: list[dict[str, Any]] = [] + key_points: list[str] = [] + summary = first_sentence(text) or str(issue.get("subject") or "") + + if "gaylord" in text_l: + summary = f"{org_name} asked whether LDR has used Gaylord boxes available to purchase." + key_points.extend([ + "The sender wants to buy used Gaylord boxes from LDR, not sell LDR a generic service.", + "The inquiry may represent a buy-from-us or liquidation-adjacent opportunity.", + ]) + concepts.append({"name": "buy-from-us inquiry", "type": "term", "summary": "An inbound request to buy goods or assets from LDR."}) + if "fulfillment" in text_l or "returns workflow" in text_l or "fba returns" in text_l or "warehousing" in text_l: + summary = f"{org_name} raised a fulfillment/returns opportunity with LDR." + key_points.append("The ticket concerns warehousing, fulfillment, returns management, or FBA returns services.") + concepts.append({"name": "returns management", "type": "field", "summary": "Operational handling of returned goods and related customer workflows."}) + if "rate sheet" in text_l or "calculator" in text_l: + summary = f"{org_name} asked for a returns rate sheet after the pricing/calculator path did not answer their needs." + key_points.extend([ + "The contact reported that the linked calculator did not calculate what they needed.", + "The contact asked for a rate sheet, exposing a possible returns-pricing collateral gap.", + ]) + concepts.append({"name": "rate-sheet gap", "type": "term", "summary": "A sales-collateral gap where pricing must be explained ad hoc instead of through a reusable artifact."}) + + if not key_points and text: + key_points.append(first_sentence(text, 300)) + + # Deduplicate concepts by normalized name. + deduped_concepts: list[dict[str, Any]] = [] + seen: set[str] = set() + for concept in concepts: + key = slugify_note(str(concept.get("name", ""))) + if key and key not in seen: + seen.add(key) + deduped_concepts.append(concept) + + return { + "source_title": str(issue.get("subject") or ""), + "summary": summary, + "entities": entities, + "concepts": deduped_concepts, + "key_points": list(dict.fromkeys(key_points)), + "contradictions": [], + "related_pages": [], + "clean_text": text, + } + + +def issue_event_date(issue: dict[str, Any]) -> str: + journal_dates = [] + for journal in issue.get("journals", []) or []: + if str(journal.get("notes") or "").strip(): + dt = parse_dt(journal.get("created_on")) + if dt: + journal_dates.append(dt) + if journal_dates: + return max(journal_dates).date().isoformat() + dt = parse_dt(issue.get("created_on") or issue.get("updated_on")) or datetime.now(timezone.utc) + return dt.date().isoformat() + + +def infer_organization_name(issue: dict[str, Any]) -> str: + subject = str(issue.get("subject") or "") + sender = ticket_from_address(issue).lower() + text = issue_text_for_decomposition(issue).lower() + if "repackify" in subject.lower() or "repackify" in sender or "repackify" in text: + return "RePackify" + if "u2wear" in subject.lower() or "u2wear" in sender or "u2wear" in text or "elora" in text: + return "u2wear" + if "snugglepuppy" in sender or "snuggle puppy" in text or "snugglepuppy" in text: + return "Snuggle Puppy" + contact = ticket_contact(issue) + for key in ("company", "name", "email"): + value = str(contact.get(key) or "").strip() + if value: + if "@" in value: + value = value.split("@", 1)[1].split(".", 1)[0] + return value + return sender.split("@", 1)[-1].split(".", 1)[0] if sender else "Unknown Organization" + + +def classify_decomposition_buckets(issue: dict[str, Any]) -> tuple[list[str], list[str]]: + text = clean_ticket_text(issue).lower() + buckets: list[str] = [] + tags: list[str] = ["redmine"] + if any(term in text for term in ("interested in purchasing", "gaylord", "already working with a buyer")): + buckets.append("partnership-buy-from-us") + tags.extend(["partnership-opportunity", "buy-from-us", "gaylords"]) + if any(term in text for term in ("fulfillment", "returns workflow", "fba returns", "warehousing")): + buckets.extend(["customer-new-project", "meaningful-event"]) + tags.extend(["customer-inquiry", "returns-management"]) + if "rate sheet" in text or "calculator" in text or "pricing" in text: + buckets.append("meaningful-event") + tags.extend(["pricing", "rate-sheet-gap"]) + if not buckets: + buckets.append("meaningful-event") + return list(dict.fromkeys(buckets)), list(dict.fromkeys(tags)) + + +def decompose_issue_dry_run(issue: dict[str, Any], vault_prefix: str = VAULT_ONYX_PREFIX) -> dict[str, Any]: + """Build a reviewable decomposition plan for one Redmine issue. + + This is the first headless companion slice inspired by KarpathyWiki. It does + not write to the vault, does not update Redmine, and deliberately writes + planned paths outside the plugin-owned `wiki/` tree. + """ + issue_id = int(issue.get("id") or 0) + subject = str(issue.get("subject") or f"Redmine issue {issue_id}").strip().strip('"') + event_date = issue_event_date(issue) + ym = event_date[:7] + org_name = infer_organization_name(issue) + org_slug = slugify_note(org_name) + subject_for_slug = re.sub(re.escape(org_name), "", subject, flags=re.IGNORECASE).strip(" -_:") or subject + event_slug = slugify_note(f"{org_name} {subject_for_slug}") + timeline_rel = f"Timeline/{ym}/{event_date}_{event_slug}.md" + entity_rel = f"entities/organizations/{org_slug}.md" + buckets, tags = classify_decomposition_buckets(issue) + contact = ticket_contact(issue) + contact_id = contact.get("id") + text = issue_text_for_decomposition(issue) + from_address = ticket_from_address(issue) + analysis = extract_decomposition_analysis(issue, org_name, buckets, tags) + summary = analysis["summary"] + confidence = "high" if "customer-new-project" in buckets or "rate-sheet-gap" in tags else "medium" + suggested_contact_tags = [tag for tag in tags if tag not in {"redmine", "customer-inquiry"}] + if "customer-new-project" in buckets and "maybe-customer" not in suggested_contact_tags: + suggested_contact_tags.insert(0, "maybe-customer") + return { + "schema_version": 1, + "mode": "dry-run", + "source": { + "source_type": "redmine-ticket", + "redmine_issue_id": issue_id, + "redmine_url": f"https://rm.ldrprep.com/issues/{issue_id}" if issue_id else None, + "title": subject, + "date": event_date, + "sender": from_address, + "redmine_contact_id": contact_id, + }, + "buckets": buckets, + "tags": tags, + "confidence": confidence, + "analysis": analysis, + "review_required": True, + "timeline_events": [ + { + "path": timeline_rel, + "onyx_url": vault_prefix + timeline_rel, + "title": f"{event_date} — {org_name} {subject}", + "summary": summary, + "entity_links": [f"[[{entity_rel[:-3]}|{org_name}]]"], + "dialectic_sections": ["claim", "supporting_evidence", "counterpoints", "decision_outcome", "follow_up_candidates"], + } + ], + "entities": [ + { + "type": "organization", + "name": org_name, + "path": entity_rel, + "onyx_url": vault_prefix + entity_rel, + "redmine_contact_id": contact_id, + "aliases": [org_name], + "suggested_tags": suggested_contact_tags, + "source_analysis": analysis["entities"][0] if analysis.get("entities") else {}, + } + ], + "concepts": analysis.get("concepts", []), + "redmine_contact_updates": [ + { + "contact_id": contact_id, + "add_tags": suggested_contact_tags, + "obsidian_article_url": vault_prefix + entity_rel, + "apply": False, + } + ] if contact_id else [], + "evidence": { + "from": from_address, + "subject": subject, + "excerpt": first_sentence(analysis.get("clean_text", ""), 500), + }, + "compatibility_notes": [ + "Dry-run only: no vault files or Redmine contacts are modified.", + "Do not write into plugin-owned wiki/ by default; use Timeline/ and entities/organizations/.", + "Use explicit wikilinks if referencing plugin pages such as [[wiki/concepts/returns-management|returns management]].", + ], + } + + +def filter_issues( + issues: Iterable[dict[str, Any]], + updated_since: str | None = None, + updated_before: str | None = None, + status: str | None = None, + category: str | None = None, +) -> list[dict[str, Any]]: + since_dt = parse_dt(updated_since) + before_dt = parse_dt(updated_before) + status_l = status.lower() if status else None + category_l = category.lower() if category else None + filtered: list[dict[str, Any]] = [] + + for issue in issues: + updated_dt = parse_dt(issue.get("updated_on")) + if since_dt and (not updated_dt or updated_dt < since_dt): + continue + if before_dt and updated_dt and updated_dt > before_dt: + continue + if status_l: + issue_status = (nested_name(issue, "status") or "").lower() + if issue_status != status_l: + continue + if category_l: + issue_category = (nested_name(issue, "category") or "").lower() + if issue_category != category_l: + continue + filtered.append(issue) + return filtered + + +def select_issues( + issues: Iterable[dict[str, Any]], + order: str = "as-is", + max_tickets: int | None = None, +) -> list[dict[str, Any]]: + selected = list(issues) + if order == "oldest-updated-first": + selected.sort(key=lambda issue: parse_dt(issue.get("updated_on")) or datetime.min.replace(tzinfo=timezone.utc)) + elif order == "newest-updated-first": + selected.sort(key=lambda issue: parse_dt(issue.get("updated_on")) or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + elif order == "id-asc": + selected.sort(key=lambda issue: int(issue.get("id", 0))) + elif order == "id-desc": + selected.sort(key=lambda issue: int(issue.get("id", 0)), reverse=True) + elif order != "as-is": + raise ValueError(f"Unsupported order: {order}") + + if max_tickets is not None: + selected = selected[:max_tickets] + return selected + + +@dataclass +class SyncResult: + seen: int = 0 + fetched: int = 0 + skipped_cached: int = 0 + written: int = 0 + + +DEFAULT_RUN_LOG_KEEP = 3 +DEFAULT_RUN_LOG_NAME = "run-stats.json" + + +def resolve_run_log_path(raw: str | None, cache_dir: Path) -> Path | None: + """Return the run-log path when enabled, else None. + + CLI uses nargs='?' with const='__default__' so: + --run-log → /run-stats.json + --run-log /path/file.json → /path/file.json + (omitted) → None + """ + if raw is None: + return None + if raw in {"", "__default__"}: + return Path(cache_dir) / DEFAULT_RUN_LOG_NAME + return Path(raw) + + +def load_run_log(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []} + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []} + if not isinstance(data, dict): + return {"version": 1, "keep": DEFAULT_RUN_LOG_KEEP, "runs": []} + data.setdefault("version", 1) + data.setdefault("keep", DEFAULT_RUN_LOG_KEEP) + runs = data.get("runs") + if not isinstance(runs, list): + data["runs"] = [] + return data + + +def append_run_stats( + path: Path, + entry: dict[str, Any], + *, + keep: int = DEFAULT_RUN_LOG_KEEP, +) -> Path: + """Append one run entry and retain only the newest `keep` runs. + + Designed for cron: fixed small file, no rotation tooling required. + Newest run is last in the list. + """ + if keep < 1: + keep = DEFAULT_RUN_LOG_KEEP + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + data = load_run_log(path) + runs = [r for r in data.get("runs", []) if isinstance(r, dict)] + runs.append(entry) + data["version"] = 1 + data["keep"] = keep + data["runs"] = runs[-keep:] + data["updated_at"] = now_iso() + tmp_path = path.with_suffix(path.suffix + ".tmp") + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + tmp_path.replace(path) + return path + + +def build_sync_run_entry( + *, + started_at: str, + finished_at: str, + duration_seconds: float, + args: argparse.Namespace, + remote_updated_on: str | None, + result: SyncResult | None, + ok: bool, + error: str | None = None, +) -> dict[str, Any]: + return { + "started_at": started_at, + "finished_at": finished_at, + "duration_seconds": round(duration_seconds, 3), + "command": "sync", + "ok": ok, + "error": error, + "base_url": getattr(args, "base_url", None), + "project": getattr(args, "project", None), + "cache_dir": str(getattr(args, "cache_dir", "")), + "dry_run": bool(getattr(args, "dry_run", False)), + "filters": { + "remote_status_id": getattr(args, "status_id", None), + "remote_updated_on": remote_updated_on, + "remote_updated_since": getattr(args, "remote_updated_since", None), + "remote_updated_before": getattr(args, "remote_updated_before", None), + "local_updated_since": getattr(args, "updated_since", None), + "local_updated_before": getattr(args, "updated_before", None), + "local_status": getattr(args, "status", None), + "local_category": getattr(args, "category", None), + "max_tickets": getattr(args, "max_tickets", None), + "order": getattr(args, "order", None), + }, + "stats": asdict(result) if result is not None else None, + } + + +@dataclass +class SpamCheckResult: + issue_id: int + score: int + is_spam: bool + subject: str + from_address: str + reasons: list[str] + category: str = "unknown-review" + + +@dataclass +class CloseIfSpamPlan: + issue_id: int + payload: dict[str, Any] + contact_id: int | None + contact_review: dict[str, Any] + subject: str + + +class ManifestTicketStateStore: + """Manifest-backed ticket state store. + + This is intentionally a thin implementation behind the TicketCache state-store + boundary. A future SQLite store should only need to implement the same small + method set used here. + """ + + def __init__(self, cache_dir: Path, project: str): + self.cache_dir = cache_dir + self.project = project + self.manifest_path = cache_dir / "manifest.json" + self.manifest = self.load_manifest() + + def load_manifest(self) -> dict[str, Any]: + if not self.manifest_path.is_file(): + return {"version": 1, "tickets": {}} + with self.manifest_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + data.setdefault("version", 1) + data.setdefault("tickets", {}) + return data + + def save(self) -> None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + with self.manifest_path.open("w", encoding="utf-8") as handle: + json.dump(self.manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + + def get_ticket_state(self, issue_id: int | str) -> dict[str, Any] | None: + return self.manifest.get("tickets", {}).get(str(issue_id)) + + def upsert_ticket_state(self, issue_id: int | str, fields: dict[str, Any]) -> None: + ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) + ticket_entry.setdefault("project", self.project) + ticket_entry.setdefault("actions", {}) + ticket_entry.update(fields) + self.save() + + def has_action(self, issue_id: int | str, action_name: str) -> bool: + entry = self.get_ticket_state(issue_id) or {} + return action_name in entry.get("actions", {}) + + def record_action( + self, + issue_id: int | str, + action_name: str, + result: str, + metadata: dict[str, Any] | None = None, + ) -> None: + ticket_entry = self.manifest.setdefault("tickets", {}).setdefault(str(issue_id), {}) + ticket_entry.setdefault("project", self.project) + actions = ticket_entry.setdefault("actions", {}) + actions[action_name] = { + "last_run": now_iso(), + "result": result, + "metadata": metadata or {}, + } + self.save() + + +class TicketCache: + def __init__(self, cache_dir: Path, project: str, state_store: Any | None = None): + self.cache_dir = cache_dir + self.project = project + self.project_dir = cache_dir / project + self.state_store = state_store or ManifestTicketStateStore(cache_dir, project) + + @property + def manifest(self) -> dict[str, Any]: + return getattr(self.state_store, "manifest", {"version": 1, "tickets": {}}) + + @property + def manifest_path(self) -> Path | None: + return getattr(self.state_store, "manifest_path", None) + + def issue_path(self, issue_id: int | str) -> Path: + return self.project_dir / f"{issue_id}.json" + + def get_entry(self, issue_id: int | str) -> dict[str, Any] | None: + return self.state_store.get_ticket_state(issue_id) + + def should_fetch(self, issue_stub: dict[str, Any]) -> bool: + issue_id = issue_stub.get("id") + if issue_id is None: + return True + entry = self.get_entry(issue_id) + if not entry: + return True + if not self.issue_path(issue_id).is_file(): + return True + cached_updated = entry.get("updated_on") + current_updated = issue_stub.get("updated_on") + return bool(current_updated and cached_updated != current_updated) + + def write_issue(self, issue: dict[str, Any]) -> None: + issue_id = issue["id"] + self.project_dir.mkdir(parents=True, exist_ok=True) + with self.issue_path(issue_id).open("w", encoding="utf-8") as handle: + json.dump(issue, handle, indent=2, sort_keys=True) + handle.write("\n") + self.state_store.upsert_ticket_state( + issue_id, + { + "project": self.project, + "updated_on": issue.get("updated_on"), + "last_synced": now_iso(), + "path": str(self.issue_path(issue_id)), + }, + ) + + def iter_cached_issues(self) -> Iterable[dict[str, Any]]: + if not self.project_dir.is_dir(): + return [] + issues: list[dict[str, Any]] = [] + for path in sorted(self.project_dir.glob("*.json")): + with path.open("r", encoding="utf-8") as handle: + issues.append(json.load(handle)) + return issues + + def has_action(self, issue_id: int | str, action_name: str) -> bool: + return self.state_store.has_action(issue_id, action_name) + + def record_action( + self, + issue_id: int | str, + action_name: str, + result: str, + metadata: dict[str, Any] | None = None, + ) -> None: + self.state_store.record_action(issue_id, action_name, result, metadata) + + +class TicketRedmineClient(RedmineClient): + def list_issues( + self, + limit: int = 100, + status_id: str = "*", + include: str | None = None, + extra_params: dict[str, Any] | None = None, + progress: bool = False, + ) -> list[dict[str, Any]]: + """List issue stubs from Redmine with server-side filters. + + `status_id` and any keys in `extra_params` (e.g. updated_on, sort) are + sent as Redmine REST filters. Do not put heavy includes on the list + path when you plan to fetch full issues one-by-one. + """ + issues: list[dict[str, Any]] = [] + offset = 0 + total = None + page_num = 0 + while total is None or offset < total: + params: dict[str, Any] = { + "limit": limit, + "offset": offset, + "status_id": status_id, + } + if include: + params["include"] = include + if extra_params: + params.update(extra_params) + data = self.request("GET", f"/projects/{self.project}/issues.json", params) + page = data.get("issues", []) + issues.extend(page) + total = int(data.get("total_count", len(issues))) + received = len(page) + page_num += 1 + if progress: + print( + f" list page {page_num}: +{received} " + f"(collected={len(issues)}/{total}, offset={offset})", + flush=True, + ) + if received == 0: + break + offset += received + return issues + + def get_issue_with_includes(self, issue_id: int, includes: Iterable[str]) -> dict[str, Any]: + return self.get_issue(issue_id, includes=includes) + + def update_issue(self, issue_id: int, fields: dict[str, Any], impersonate_user: str | None = None) -> None: + self.request( + "PUT", + f"/issues/{issue_id}.json", + payload={"issue": fields}, + impersonate_user=impersonate_user, + ) + + def add_internal_note( + self, + issue_id: int, + notes: str, + *, + private: bool = False, + impersonate_user: str | None = None, + ) -> None: + """Add a Redmine journal note. Does NOT send Helpdesk/customer email.""" + text = (notes or "").strip() + if not text: + raise RedmineError("Internal note body is required.") + fields: dict[str, Any] = {"notes": text} + if private: + fields["private_notes"] = True + self.update_issue(issue_id, fields, impersonate_user=impersonate_user) + + def send_customer_helpdesk_email( + self, + issue_id: int, + content: str, + *, + status_id: int | None = None, + to_address: str | None = None, + cc_address: str | None = None, + bcc_address: str | None = None, + impersonate_user: str | None = None, + ) -> dict[str, Any]: + """Send a customer-visible Helpdesk email via POST /helpdesk/email_note. + + This is the RedmineUP Helpdesk "send note" path used by redMCP. + It emails the customer. Do not use for internal staff notes. + """ + text = (content or "").strip() + if not text: + raise RedmineError("Customer email body is required.") + message: dict[str, Any] = {"issue_id": int(issue_id), "content": text} + if status_id is not None: + message["status_id"] = int(status_id) + payload: dict[str, Any] = {"message": message} + if to_address: + payload["to_address"] = to_address + if cc_address: + payload["cc_address"] = cc_address + if bcc_address: + payload["bcc_address"] = bcc_address + return self.request( + "POST", + "/helpdesk/email_note.json", + payload=payload, + impersonate_user=impersonate_user, + ) + + def get_issue_attachments(self, issue_id: int) -> list[dict[str, Any]]: + issue = self.get_issue_with_includes(int(issue_id), ("attachments", "journals", "helpdesk")) + atts = issue.get("attachments") + return list(atts) if isinstance(atts, list) else [] + + def attach_upload_to_issue( + self, + issue_id: int, + upload: dict[str, Any], + *, + notes: str | None = None, + impersonate_user: str | None = None, + ) -> None: + """Attach a previously uploaded token to an issue (does not email).""" + token = upload.get("token") + if not token: + raise RedmineError("Upload token missing.") + entry: dict[str, Any] = { + "token": token, + "filename": upload.get("filename") or "attachment.bin", + "content_type": upload.get("content_type") or "application/octet-stream", + } + if upload.get("description"): + entry["description"] = upload["description"] + fields: dict[str, Any] = {"uploads": [entry]} + if notes: + fields["notes"] = notes + self.update_issue(int(issue_id), fields, impersonate_user=impersonate_user) + + def recent_issues_for_contact(self, contact_id: int, limit: int) -> list[dict[str, Any]]: + response = self.helpdesk_issues_by_contact(contact_id, limit) + summaries = response.get("issues", []) if isinstance(response, dict) else [] + issues: list[dict[str, Any]] = [] + for summary in summaries[:limit]: + issue_id = summary.get("issue_id") or summary.get("id") + if issue_id is None: + continue + issues.append(self.get_issue_with_includes(int(issue_id), ("journals", "helpdesk"))) + return issues + + +def sync_tickets( + client: Any, + cache: TicketCache, + updated_since: str | None = None, + updated_before: str | None = None, + status: str | None = None, + category: str | None = None, + max_tickets: int | None = None, + order: str = "as-is", + apply: bool = True, + include_journals: bool = True, + page_size: int = 100, + status_id: str = "*", + remote_updated_on: str | None = None, + progress: bool = True, +) -> SyncResult: + """Incrementally sync tickets. + + Server-side (Redmine API) filters: + - status_id + - updated_on (via remote_updated_on, official operators) + + Local post-filters still available via updated_since/updated_before/status/category. + Per-ticket should_fetch skips full downloads when cache already has same updated_on. + """ + extra_params: dict[str, Any] = {} + if remote_updated_on: + extra_params["updated_on"] = remote_updated_on + # Prefer newest first on the wire so --max-tickets is meaningful under remote filters. + if order == "newest-updated-first": + extra_params["sort"] = "updated_on:desc" + elif order == "oldest-updated-first": + extra_params["sort"] = "updated_on" + elif order == "id-desc": + extra_params["sort"] = "id:desc" + elif order == "id-asc": + extra_params["sort"] = "id" + + if progress: + print(f"Cache dir: {cache.cache_dir}", flush=True) + print(f"Manifest: {cache.manifest_path}", flush=True) + print(f"Project: {cache.project}", flush=True) + print( + f"Remote API filters: status_id={status_id!r} updated_on={remote_updated_on!r}", + flush=True, + ) + if updated_since or updated_before or status or category: + print( + "Local post-filters: " + f"updated_since={updated_since!r} updated_before={updated_before!r} " + f"status={status!r} category={category!r}", + flush=True, + ) + print("Listing issue stubs from Redmine...", flush=True) + + stubs = client.list_issues( + limit=page_size, + status_id=status_id, + include=None, + extra_params=extra_params or None, + progress=progress, + ) + if progress: + print(f"Listed {len(stubs)} stub(s) from API.", flush=True) + + selected = filter_issues( + stubs, + updated_since=updated_since, + updated_before=updated_before, + status=status, + category=category, + ) + selected = select_issues(selected, order=order, max_tickets=max_tickets) + includes = ("journals", "helpdesk", "attachments") if include_journals else ("helpdesk", "attachments") + result = SyncResult(seen=len(selected)) + + if progress: + print(f"Selected {len(selected)} ticket(s) after local filters/order/max.", flush=True) + print("Fetching full tickets (skipping unchanged cache entries)...", flush=True) + + for index, stub in enumerate(selected, start=1): + issue_id = stub.get("id") + if issue_id is None: + continue + if not cache.should_fetch(stub): + result.skipped_cached += 1 + if progress and (index == 1 or index == len(selected) or index % 25 == 0): + print( + f" [{index}/{len(selected)}] #{issue_id} cache-skip " + f"(fetched={result.fetched} skipped={result.skipped_cached} written={result.written})", + flush=True, + ) + continue + full_issue = client.get_issue_with_includes(int(issue_id), includes) + result.fetched += 1 + if apply: + cache.write_issue(full_issue) + result.written += 1 + if progress and (index == 1 or index == len(selected) or index % 10 == 0 or result.fetched <= 3): + print( + f" [{index}/{len(selected)}] #{issue_id} fetched " + f"(fetched={result.fetched} skipped={result.skipped_cached} written={result.written})", + flush=True, + ) + return result + + +def spam_check_text(issue: dict[str, Any]) -> str: + parts = [str(issue.get("subject", "")), str(issue.get("description", ""))] + helpdesk = issue.get("helpdesk_ticket") or {} + if isinstance(helpdesk, dict): + parts.append(str(helpdesk.get("from_address", ""))) + contact = helpdesk.get("contact") or {} + if isinstance(contact, dict): + parts.append(str(contact.get("email", ""))) + parts.append(str(contact.get("name", ""))) + parts.append(str(contact.get("company", ""))) + for journal in issue.get("journals", [])[:5]: + if isinstance(journal, dict): + user = journal.get("user") or {} + user_name = str(user.get("name", "")) if isinstance(user, dict) else "" + if user_name and user_name.lower() != "anonymous": + continue + parts.append(str(journal.get("notes", ""))[:1000]) + return "\n".join(parts) + + +def score_ticket_spam(issue: dict[str, Any], threshold: int = 2) -> SpamCheckResult: + text = spam_check_text(issue) + subject = str(issue.get("subject", "")) + helpdesk = issue.get("helpdesk_ticket") or {} + from_address = "" + if isinstance(helpdesk, dict): + from_address = str(helpdesk.get("from_address", "")) + reasons: list[str] = [] + score = 0 + text_l = text.lower() + + if AUTOMATED_OPERATIONAL.search(text): + return SpamCheckResult( + issue_id=int(issue.get("id", 0)), + score=0, + is_spam=False, + subject=subject, + from_address=from_address, + reasons=["automated operational notification"], + category="automated-operational", + ) + + if INBOUND_BUSINESS_INQUIRY.search(text): + return SpamCheckResult( + issue_id=int(issue.get("id", 0)), + score=0, + is_spam=False, + subject=subject, + from_address=from_address, + reasons=["inbound or established business inquiry"], + category="unknown-review", + ) + + if SENDER_SPAM_HINTS.search(from_address): + score += 1 + reasons.append("sender has promotional/automated hint") + if PROMO_LANGUAGE.search(subject): + score += 2 + reasons.append("subject contains promotional language") + elif PROMO_LANGUAGE.search(text): + score += 1 + reasons.append("body/journal contains promotional language") + if SALES_PITCH_LANGUAGE.search(text): + score += 2 + reasons.append("ticket contains unsolicited sales pitch language") + if SENDER_SPAM_HINTS.search(text): + score += 1 + reasons.append("ticket text contains promotional sender hint") + + is_spam = score >= threshold + category = "spam-promotional" if is_spam else "unknown-review" + return SpamCheckResult( + issue_id=int(issue.get("id", 0)), + score=score, + is_spam=is_spam, + subject=subject, + from_address=from_address, + reasons=reasons, + category=category, + ) + + +def run_spam_check(cache: TicketCache, apply: bool = False, threshold: int = 2) -> list[SpamCheckResult]: + results: list[SpamCheckResult] = [] + for issue in cache.iter_cached_issues(): + issue_id = issue.get("id") + if issue_id is None or cache.has_action(issue_id, "spam-check"): + continue + result = score_ticket_spam(issue, threshold=threshold) + results.append(result) + if apply: + cache.record_action( + issue_id, + "spam-check", + "spam" if result.is_spam else "not-spam", + { + "score": result.score, + "category": result.category, + "subject": result.subject, + "from_address": result.from_address, + "reasons": result.reasons, + }, + ) + return results + + +def print_spam_results(results: Iterable[SpamCheckResult]) -> None: + for result in results: + verdict = result.category if result.is_spam else result.category + reasons = "; ".join(result.reasons) if result.reasons else "no spam signals" + print( + f"#{result.issue_id}\t{verdict}\tscore={result.score}\t" + f"from={result.from_address}\t{result.subject}\t{reasons}" + ) + + +def ticket_contact_id(issue: dict[str, Any]) -> int | None: + helpdesk = issue.get("helpdesk_ticket") or {} + if not isinstance(helpdesk, dict): + return None + contact = helpdesk.get("contact") or {} + if isinstance(contact, dict) and contact.get("id") is not None: + return int(contact["id"]) + if helpdesk.get("contact_id") is not None: + return int(helpdesk["contact_id"]) + return None + + +def action_result(cache: TicketCache, issue_id: int | str, action_name: str) -> str | None: + entry = cache.get_entry(issue_id) or {} + action = entry.get("actions", {}).get(action_name) or {} + result = action.get("result") + return str(result) if result is not None else None + + +def action_category(cache: TicketCache, issue_id: int | str, action_name: str) -> str | None: + entry = cache.get_entry(issue_id) or {} + action = entry.get("actions", {}).get(action_name) or {} + metadata = action.get("metadata", {}) + if not isinstance(metadata, dict): + metadata = {} + category = metadata.get("category") or action.get("result") + return str(category) if category is not None else None + + +def action_metadata(cache: TicketCache, issue_id: int | str, action_name: str) -> dict[str, Any]: + entry = cache.get_entry(issue_id) or {} + action = entry.get("actions", {}).get(action_name) or {} + metadata = action.get("metadata", {}) + return metadata if isinstance(metadata, dict) else {} + + +def contact_has_tag(contact: dict[str, Any], tag: str) -> bool: + wanted = tag.strip().lower() + return wanted in {existing.strip().lower() for existing in extract_tag_list(contact)} + + +def contact_is_customer(client: Any, contact_id: int | None) -> bool: + if contact_id is None: + return False + contact = client.get_contact(contact_id) + return contact_has_tag(contact, "customer") + + +def analyze_contact_spam(issues: Iterable[dict[str, Any]], threshold: int = 2) -> dict[str, Any]: + scored = [score_ticket_spam(issue, threshold=threshold) for issue in issues] + spam_ids = [result.issue_id for result in scored if result.is_spam] + total = len(scored) + likely_spammer = bool(total and (len(spam_ids) >= 2 or len(spam_ids) / total >= 0.5)) + return { + "checked": total, + "spam_count": len(spam_ids), + "spam_issue_ids": spam_ids, + "likely_spammer": likely_spammer, + } + + +def build_close_payload(close_status_id: int, notes: str | None = None) -> dict[str, Any]: + return { + "status_id": close_status_id, + "notes": notes or "Closed as likely spam by redmine_tickets.py close-if-spam.", + } + + +def spam_check_reasons(cache: TicketCache, issue_id: int | str) -> list[str]: + metadata = action_metadata(cache, issue_id, "spam-check") + reasons = metadata.get("reasons", []) + return [str(reason) for reason in reasons] if isinstance(reasons, list) else [] + + +def run_tag_maybe_customer_contacts( + client: Any, + cache: TicketCache, + apply: bool = False, + tag_name: str = "maybe-customer", + impersonate_user: str = "rebot", +) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + seen_contacts: set[int] = set() + for issue in cache.iter_cached_issues(): + issue_id = issue.get("id") + if issue_id is None: + continue + reasons = spam_check_reasons(cache, issue_id) + if "inbound or established business inquiry" not in reasons: + continue + contact_id = ticket_contact_id(issue) + if contact_id is None or contact_id in seen_contacts: + continue + seen_contacts.add(contact_id) + contact = client.get_contact(contact_id) + if contact_has_tag(contact, "customer") or contact_has_tag(contact, tag_name): + continue + new_tags = merge_tags(extract_tag_list(contact), [tag_name], []) + result = {"contact_id": contact_id, "tag_list": new_tags, "source_issue_id": int(issue_id)} + results.append(result) + if apply: + client.update_contact(contact_id, {"tag_list": new_tags}, impersonate_user=impersonate_user) + cache.record_action( + issue_id, + "tag-maybe-customer", + "tagged", + {"contact_id": contact_id, "tag": tag_name}, + ) + return results + + +def run_close_if_spam( + client: Any, + cache: TicketCache, + close_status_id: int, + contact_ticket_limit: int = 10, + apply: bool = False, + threshold: int = 2, + impersonate_user: str = "rebot", + close_categories: tuple[str, ...] = ("spam-promotional", "spam"), +) -> list[CloseIfSpamPlan]: + plans: list[CloseIfSpamPlan] = [] + for issue in cache.iter_cached_issues(): + issue_id = issue.get("id") + if issue_id is None: + continue + if action_category(cache, issue_id, "spam-check") not in close_categories: + continue + if cache.has_action(issue_id, "close-if-spam"): + continue + + contact_id = ticket_contact_id(issue) + if contact_is_customer(client, contact_id): + if apply: + cache.record_action( + issue_id, + "close-if-spam", + "skipped-customer-contact", + {"contact_id": contact_id}, + ) + continue + recent_issues = client.recent_issues_for_contact(contact_id, contact_ticket_limit) if contact_id else [] + contact_review = analyze_contact_spam(recent_issues, threshold=threshold) + payload = build_close_payload(close_status_id) + plan = CloseIfSpamPlan( + issue_id=int(issue_id), + payload=payload, + contact_id=contact_id, + contact_review=contact_review, + subject=str(issue.get("subject", "")), + ) + plans.append(plan) + + if apply: + client.update_issue(int(issue_id), payload, impersonate_user=impersonate_user) + cache.record_action( + issue_id, + "close-if-spam", + "closed", + {"payload": payload, "contact_id": contact_id}, + ) + cache.record_action( + issue_id, + "contact-spam-review", + "likely-spammer" if contact_review.get("likely_spammer") else "single-ticket-spam", + contact_review, + ) + return plans + + +def print_close_if_spam_plans(plans: Iterable[CloseIfSpamPlan]) -> None: + for plan in plans: + review = plan.contact_review + print( + f"#{plan.issue_id}\tclose_payload={json.dumps(plan.payload, sort_keys=True)}\t" + f"contact_id={plan.contact_id}\tcontact_spam={review.get('spam_count')}/{review.get('checked')}\t" + f"likely_spammer={review.get('likely_spammer')}\t{plan.subject}" + ) + + +def print_issue_rows(issues: Iterable[dict[str, Any]]) -> None: + for issue in issues: + status = nested_name(issue, "status") or "" + category = nested_name(issue, "category") or "" + assignee = nested_name(issue, "assigned_to") or "" + contact = "" + helpdesk_raw = issue.get("helpdesk_ticket") + helpdesk = helpdesk_raw if isinstance(helpdesk_raw, dict) else {} + c = helpdesk.get("contact") if helpdesk else None + if isinstance(c, dict): + contact = str(c.get("email") or c.get("name") or c.get("company") or "") + elif helpdesk.get("from_address"): + contact = str(helpdesk.get("from_address")) + tags = issue.get("_control_tags") + tag_s = ",".join(tags) if isinstance(tags, list) and tags else "" + subject = str(issue.get("subject", "")).replace("\n", " ") + print( + f"#{issue.get('id')}\t{issue.get('updated_on', '')}\t{status}\t" + f"{assignee}\t{contact}\t{category}\t{tag_s}\t{subject}" + ) + + +def load_issue_for_decompose(args: argparse.Namespace, cache: TicketCache) -> dict[str, Any]: + if args.input_json: + with Path(args.input_json).open("r", encoding="utf-8") as handle: + return json.load(handle) + if args.issue_id is None: + raise RedmineError("decompose requires --issue-id or --input-json") + cached_path = cache.issue_path(args.issue_id) + if cached_path.is_file(): + with cached_path.open("r", encoding="utf-8") as handle: + return json.load(handle) + client = client_from_args(args) + return client.get_issue_with_includes(args.issue_id, ("journals", "helpdesk")) + + +def render_decomposition_markdown(plan: dict[str, Any]) -> str: + source = plan["source"] + lines = [ + f"# Decomposition dry-run — Redmine #{source.get('redmine_issue_id')}", + "", + "## Source", + "", + f"- Redmine: {source.get('redmine_url')}", + f"- Title: {source.get('title')}", + f"- Date: {source.get('date')}", + f"- Sender: {source.get('sender')}", + f"- Contact ID: {source.get('redmine_contact_id')}", + "", + "## Buckets", + "", + ] + lines.extend(f"- `{bucket}`" for bucket in plan.get("buckets", [])) + analysis = plan.get("analysis", {}) + lines.extend(["", "## Structured analysis", "", f"**Summary:** {analysis.get('summary', '')}", "", "**Key points:**", ""]) + lines.extend(f"- {point}" for point in analysis.get("key_points", [])) + concepts = plan.get("concepts", []) + if concepts: + lines.extend(["", "**Concepts:**", ""]) + for concept in concepts: + lines.append(f"- `{concept.get('name')}` ({concept.get('type')}) — {concept.get('summary')}") + lines.extend(["", "## Planned timeline events", ""]) + for event in plan.get("timeline_events", []): + lines.extend([ + f"- `{event['path']}`", + f" - Onyx: {event['onyx_url']}", + f" - Summary: {event['summary']}", + ]) + lines.extend(["", "## Planned entities", ""]) + for entity in plan.get("entities", []): + lines.extend([ + f"- `{entity['path']}` — {entity['name']}", + f" - Onyx: {entity['onyx_url']}", + f" - Suggested tags: {', '.join(entity.get('suggested_tags', []))}", + ]) + lines.extend(["", "## Redmine contact update proposals", ""]) + for update in plan.get("redmine_contact_updates", []): + lines.extend([ + f"- Contact `{update['contact_id']}`", + f" - Add tags: {', '.join(update.get('add_tags', []))}", + f" - Obsidian article: {update.get('obsidian_article_url')}", + " - Apply: false", + ]) + lines.extend(["", "## Evidence excerpt", "", plan.get("evidence", {}).get("excerpt", ""), "", "## Compatibility notes", ""]) + lines.extend(f"- {note}" for note in plan.get("compatibility_notes", [])) + return "\n".join(lines) + + +def allow_prod_writes_enabled(args: argparse.Namespace) -> bool: + """True if parent or subcommand requested prod writes.""" + return bool(getattr(args, "allow_prod_writes", False)) + + +def print_draft_queued( + path: Path, + *, + kind: str, + issue_id: int | None = None, + contact_id: int | None = None, + base_url: str | None, + emails_customer: bool = False, +) -> str: + """Print machine- and human-friendly draft queue info. Returns draft_id.""" + draft_id = path.stem + print(f"draft_id={draft_id}") + print(f"draft_path={path}") + if issue_id is not None: + print(f"issue_id={issue_id}") + if contact_id is not None: + print(f"contact_id={contact_id}") + print(f"kind={kind}") + print(f"base_url={base_url or ''}") + print(f"emails_customer={'YES' if emails_customer else 'NO'}") + if emails_customer: + print( + f"apply_hint=draft apply {draft_id} --i-understand-this-emails-the-customer" + + (" --allow-prod-writes" if base_url and "rm.ldrprep.com" in str(base_url) else "") + ) + else: + hint = f"apply_hint=draft apply {draft_id}" + if base_url and "rm.ldrprep.com" in str(base_url): + hint += " --allow-prod-writes" + print(hint) + return draft_id + + +def load_runtime_config(path: Path) -> dict[str, Any]: + """Load a minimal YAML or JSON config for global CLI defaults. + + Supported shapes: + {base-url, project, cache-dir, allow-prod-writes} + {redmine_tickets: { ... same keys ... }} + YAML requires PyYAML if available; otherwise JSON only. + """ + text = path.read_text(encoding="utf-8") + data: Any + if path.suffix.lower() in {".json"}: + data = json.loads(text) + else: + try: + import yaml # type: ignore + except ImportError as exc: + # try JSON anyway for .yaml misnamed + try: + data = json.loads(text) + except json.JSONDecodeError: + raise RedmineError( + f"Cannot load config {path}: need PyYAML for YAML, or use .json. ({exc})" + ) from exc + else: + data = yaml.safe_load(text) + if not isinstance(data, dict): + raise RedmineError(f"Config root must be a mapping: {path}") + if isinstance(data.get("redmine_tickets"), dict): + data = data["redmine_tickets"] + # normalize keys: base_url / base-url + out: dict[str, Any] = {} + for key, value in data.items(): + norm = str(key).replace("_", "-").lower() + if norm in {"base-url", "project", "cache-dir", "allow-prod-writes"}: + out[norm] = value + # ignore nested command sections for this minimal implementation + return out + + +def apply_runtime_config_to_argv(argv: list[str]) -> list[str]: + """If --config is present, inject missing parent defaults from the file. + + Does not override flags already on the command line. + """ + if "--config" not in argv: + return argv + try: + idx = argv.index("--config") + config_path = Path(argv[idx + 1]) + except (ValueError, IndexError): + return argv + if not config_path.is_file(): + # also try relative to script + alt = Path(__file__).resolve().parent / config_path + if alt.is_file(): + config_path = alt + else: + raise RedmineError(f"Config file not found: {argv[idx + 1]}") + cfg = load_runtime_config(config_path) + # strip --config from argv for argparse (we still re-add as optional known arg) + # Keep --config for argparse to record path; inject defaults before it. + injected: list[str] = [] + existing = set() + i = 0 + while i < len(argv): + a = argv[i] + if a.startswith("--") and "=" not in a: + existing.add(a) + elif a.startswith("--") and "=" in a: + existing.add(a.split("=", 1)[0]) + i += 1 + mapping = { + "base-url": "--base-url", + "project": "--project", + "cache-dir": "--cache-dir", + } + for key, flag in mapping.items(): + if key in cfg and flag not in existing: + injected.extend([flag, str(cfg[key])]) + if cfg.get("allow-prod-writes") in (True, "true", "yes", 1, "1") and "--allow-prod-writes" not in existing: + injected.append("--allow-prod-writes") + # place injected globals at the front (before subcommand) + return injected + argv + + +def resolve_apply_base_url(args: argparse.Namespace, draft: dict[str, Any]) -> str: + """Prefer draft-stored base_url so apply hits the host the draft was written for.""" + draft_url = (draft.get("base_url") or "").strip() + cli_url = (getattr(args, "base_url", None) or "").strip() + default_url = (os.getenv("REDMINE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + if draft_url: + # If user passed an explicit different --base-url, require override + cli_is_default = cli_url.rstrip("/") in {default_url.rstrip("/"), DEFAULT_BASE_URL.rstrip("/")} + # Also treat as explicit if it differs from draft + if cli_url and cli_url.rstrip("/") != draft_url.rstrip("/") and not cli_is_default: + if not getattr(args, "allow_base_url_override", False): + raise RedmineError( + f"Draft was created for base_url={draft_url!r} but CLI has --base-url={cli_url!r}. " + "Refusing mismatched apply. Re-run without overriding --base-url, " + "or pass --allow-base-url-override if intentional." + ) + print(f"WARNING: applying draft to CLI base_url={cli_url} (draft was {draft_url})", file=sys.stderr) + return cli_url + return draft_url + print( + "WARNING: draft has no stored base_url (legacy); using CLI --base-url=" + f"{cli_url or default_url}", + file=sys.stderr, + ) + return cli_url or default_url + + +def resolve_body_text(args: argparse.Namespace) -> str: + body = getattr(args, "body", None) + body_file = getattr(args, "body_file", None) + if body_file: + body = Path(body_file).read_text(encoding="utf-8") + if not body or not str(body).strip(): + raise RedmineError("Requires --body or --body-file with non-empty content.") + text = str(body) + if getattr(args, "to_textile", False): + text = maybe_convert_to_textile(text) + return text + + +def maybe_convert_to_textile(text: str) -> str: + """If pandoc is available, convert Markdown (or plain) → Textile for Redmine journals. + + Falls back to original text with a stderr warning when pandoc is missing/fails. + """ + import shutil + import subprocess + + pandoc = shutil.which("pandoc") + if not pandoc: + print( + "warning: --to-textile requested but pandoc not found; sending body unchanged", + file=sys.stderr, + ) + return text + try: + proc = subprocess.run( + [pandoc, "-f", "markdown", "-t", "textile"], + input=text, + text=True, + capture_output=True, + check=False, + timeout=30, + ) + except Exception as exc: # noqa: BLE001 + print(f"warning: pandoc failed ({exc}); sending body unchanged", file=sys.stderr) + return text + if proc.returncode != 0 or not (proc.stdout or "").strip(): + err = (proc.stderr or "").strip()[:200] + print(f"warning: pandoc conversion empty/failed ({err}); sending body unchanged", file=sys.stderr) + return text + return proc.stdout + + +def is_closed_status(issue: dict[str, Any]) -> bool: + status = issue.get("status") + if not isinstance(status, dict): + return False + if status.get("is_closed") is True: + return True + name = str(status.get("name") or "").strip().lower() + return name in {"closed", "rejected", "resolved", "cancelled", "canceled", "done"} + + +def build_fields_payload_from_args( + args: argparse.Namespace, + *, + resolved_ids: dict[str, int] | None = None, +) -> dict[str, Any]: + """Build Redmine issue field payload from set-fields CLI args. + + Prefer numeric *id flags; name flags should be pre-resolved into resolved_ids. + """ + payload: dict[str, Any] = {} + resolved_ids = resolved_ids or {} + if getattr(args, "field_status_id", None) is not None: + payload["status_id"] = int(args.field_status_id) + elif "status_id" in resolved_ids: + payload["status_id"] = int(resolved_ids["status_id"]) + if getattr(args, "assigned_to_id", None) is not None: + aid = int(args.assigned_to_id) + payload["assigned_to_id"] = None if aid == 0 else aid + elif "assigned_to_id" in resolved_ids: + aid = int(resolved_ids["assigned_to_id"]) + payload["assigned_to_id"] = None if aid == 0 else aid + if getattr(args, "priority_id", None) is not None: + payload["priority_id"] = int(args.priority_id) + elif "priority_id" in resolved_ids: + payload["priority_id"] = int(resolved_ids["priority_id"]) + if getattr(args, "category_id", None) is not None: + cid = int(args.category_id) + payload["category_id"] = None if cid == 0 else cid + elif "category_id" in resolved_ids: + cid = int(resolved_ids["category_id"]) + payload["category_id"] = None if cid == 0 else cid + if getattr(args, "due_date", None): + payload["due_date"] = str(args.due_date).strip() + if getattr(args, "subject", None): + payload["subject"] = str(args.subject) + if getattr(args, "notes", None): + payload["notes"] = str(args.notes) + if getattr(args, "private", False): + payload["private_notes"] = True + if not payload: + raise RedmineError( + "set-fields requires at least one of: --status/--status-id, " + "--assigned-to/--assigned-to-id, --priority/--priority-id, " + "--category/--category-id, --due-date, --subject, --notes" + ) + return payload + + +def resolve_set_fields_names(args: argparse.Namespace) -> dict[str, int]: + """Resolve --status/--assigned-to/--priority/--category name flags to IDs.""" + wants = any( + getattr(args, name, None) + for name in ("status_name", "assignee_name", "priority_name", "category_name") + ) + if not wants: + return {} + if resolve_field_names is None: + raise RedmineError("Name resolution helpers unavailable (redmine_ticket_meta.py).") + client = None + # Refresh meta if missing — needs API + try: + client = client_from_args(args) + except Exception: + client = None + try: + return resolve_field_names( + Path(args.cache_dir), + args.project, + status_name=getattr(args, "status_name", None), + assignee_name=getattr(args, "assignee_name", None), + priority_name=getattr(args, "priority_name", None), + category_name=getattr(args, "category_name", None), + client=client, + refresh=bool(getattr(args, "refresh_meta", False)), + ) + except LookupError as exc: + raise RedmineError(str(exc)) from exc + + +def apply_draft_to_redmine( + client: TicketRedmineClient, + draft: dict[str, Any], + *, + impersonate_user: str, + allow_customer_email: bool, +) -> dict[str, Any]: + kind = normalize_kind(str(draft.get("kind") or "")) if normalize_kind else str(draft.get("kind")) + body = str(draft.get("body") or "") + if kind == KIND_CONTACT_FIELDS: + contact_id = draft.get("contact_id") + if contact_id is None: + raise RedmineError("Contact draft missing contact_id.") + fields = draft.get("fields") if isinstance(draft.get("fields"), dict) else {} + if not fields: + raise RedmineError("Contact draft has empty fields payload.") + payload = dict(fields) + # Support TUI shorthand for contact tags (add_tag / remove_tag) by merging properly + if "add_tag" in payload: + tag = str(payload.pop("add_tag")).strip() + if tag: + try: + contact = client.get_contact(int(contact_id)) + current = extract_tag_list(contact) if extract_tag_list else [] + new_tags = merge_tags(current, [tag], []) if merge_tags else list(dict.fromkeys(current + [tag])) + payload = {"tag_list": new_tags} + except Exception: + payload = {"tag_list": [tag]} + if "remove_tag" in payload: + tag = str(payload.pop("remove_tag")).strip() + if tag: + try: + contact = client.get_contact(int(contact_id)) + current = extract_tag_list(contact) if extract_tag_list else [] + new_tags = merge_tags(current, [], [tag]) if merge_tags else [t for t in current if str(t).lower() != tag.lower()] + payload = {"tag_list": new_tags} + except Exception: + pass + client.update_contact(int(contact_id), payload, impersonate_user=impersonate_user) + return { + "kind": kind, + "contact_id": int(contact_id), + "emailed_customer": False, + "fields": fields, + } + if draft.get("issue_id") is None: + raise RedmineError(f"Draft kind {kind!r} requires issue_id.") + issue_id = int(draft["issue_id"]) + if kind == KIND_INTERNAL_NOTE: + client.add_internal_note( + issue_id, + body, + private=bool(draft.get("private")), + impersonate_user=impersonate_user, + ) + return {"kind": kind, "issue_id": issue_id, "emailed_customer": False} + if kind == KIND_CUSTOMER_EMAIL: + if not allow_customer_email: + raise RedmineError( + "Refusing to apply customer_email draft without " + "--i-understand-this-emails-the-customer. " + "This path SENDS email to the customer via Helpdesk." + ) + result = client.send_customer_helpdesk_email( + issue_id, + body, + impersonate_user=impersonate_user, + ) + return {"kind": kind, "issue_id": issue_id, "emailed_customer": True, "api_result": result} + if kind == KIND_FIELDS: + fields = draft.get("fields") if isinstance(draft.get("fields"), dict) else {} + if not fields: + raise RedmineError("Field draft has empty fields payload.") + client.update_issue(issue_id, dict(fields), impersonate_user=impersonate_user) + return {"kind": kind, "issue_id": issue_id, "emailed_customer": False, "fields": fields} + raise RedmineError(f"Cannot apply draft kind {kind!r}") + + +def load_cached_issue(cache: TicketCache, issue_id: int) -> dict[str, Any]: + path = cache.issue_path(issue_id) + if not path.is_file(): + raise RedmineError(f"No cached ticket #{issue_id} at {path}") + + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def refresh_cached_issue( + cache: "TicketCache", + client: Any, + issue_id: int, + *, + includes: list[str] | None = None, +) -> dict[str, Any] | None: + """Fetch the current issue from Redmine and overwrite the local cache file. + + Use after any field update / note / etc. so that the next + `rtl browse`, `rtl tui`, etc. see the fresh data (updated_on, journals, etc.) + instead of stale local copy. + """ + if client is None: + return None + try: + inc = includes or ["journals", "helpdesk", "attachments"] + if hasattr(client, "get_issue_with_includes"): + fresh = client.get_issue_with_includes(int(issue_id), inc) + elif hasattr(client, "get_issue"): + fresh = client.get_issue(int(issue_id), includes=inc) + else: + return None + if isinstance(fresh, dict) and fresh.get("id"): + cache.write_issue(fresh) + return fresh + except Exception: + # non-fatal; the user can always run a full sync + pass + return None + + +def select_cached_issues_for_cli(cache: TicketCache, args: argparse.Namespace) -> list[dict[str, Any]]: + issues = list(cache.iter_cached_issues()) + matches = filter_issues( + issues, + updated_since=getattr(args, "updated_since", None), + updated_before=getattr(args, "updated_before", None), + status=getattr(args, "status", None), + category=getattr(args, "category", None), + ) + where_raw = list(getattr(args, "where", None) or []) + if where_raw: + if filter_by_query is None: + raise RedmineError("Property query module redmine_ticket_query.py is unavailable.") + saved_dir = Path(getattr(args, "saved_filters_dir", None) or DEFAULT_SAVED_FILTERS_DIR) + # Prefer repo-relative saved-filters next to this script if cwd differs + script_saved = Path(__file__).resolve().parent / "saved-filters" + search_dirs = [saved_dir] + if script_saved not in search_dirs and script_saved.is_dir(): + search_dirs.append(script_saved) + try: + if expand_where_list is not None: + where_exprs = expand_where_list(where_raw, search_dirs) + else: + where_exprs = where_raw + matches = filter_by_query(matches, expressions=where_exprs) + except QueryError as exc: + raise RedmineError(f"Invalid --where expression: {exc}") from exc + + control_tag = getattr(args, "control_tag", None) + if control_tag: + if filter_issues_by_control_tag is None: + raise RedmineError("Control-tag helpers unavailable (redmine_ticket_control_tags.py).") + matches = filter_issues_by_control_tag(matches, Path(args.cache_dir), str(control_tag)) + + if annotate_issues_with_control_tags is not None: + matches = annotate_issues_with_control_tags(matches, Path(args.cache_dir)) + + if getattr(args, "has_attachments", False): + matches = [ + i + for i in matches + if isinstance(i.get("attachments"), list) and len(i.get("attachments") or []) > 0 + ] + if getattr(args, "open_only", False): + matches = [i for i in matches if not is_closed_status(i)] + order = getattr(args, "order", "newest-updated-first") + max_tickets = getattr(args, "max_tickets", None) + return select_issues(matches, order=order, max_tickets=max_tickets) + + +def run_walk_session(issues: list[dict[str, Any]], cache: TicketCache, pager: str = "auto") -> int: + if not issues: + print("No tickets to walk.") + return 0 + if render_ticket_markdown is None or write_ticket_markdown is None or display_markdown_file is None: + raise RedmineError("Ticket view helpers unavailable (redmine_ticket_view.py).") + + index = 0 + total = len(issues) + print(f"Walking {total} ticket(s). Commands: n/enter=next, p=prev, q=quit, r=redraw") + while True: + issue = issues[index] + issue_id = issue.get("id") + print(f"\n=== [{index + 1}/{total}] #{issue_id} ===") + md_path = write_ticket_markdown(issue, cache.cache_dir, cache.project) + display_markdown_file(md_path, pager=pager) + try: + raw = input("walk> ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return 0 + if raw in {"", "n", "next", "j"}: + if index + 1 >= total: + print("End of list.") + else: + index += 1 + continue + if raw in {"p", "prev", "k", "b", "back"}: + if index <= 0: + print("Start of list.") + else: + index -= 1 + continue + if raw in {"q", "quit", "exit"}: + return 0 + if raw in {"r", "redraw"}: + continue + print("Unknown command. Use n / p / q / r") + + +def client_from_args(args: argparse.Namespace) -> TicketRedmineClient: + api_key = os.getenv("REDMINE_API_KEY") + if not api_key: + raise RedmineError("Set REDMINE_API_KEY in the environment.") + return TicketRedmineClient(base_url=args.base_url, api_key=api_key, project=args.project) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Incrementally sync and search enriched Redmine issue payloads.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--base-url", default=os.getenv("REDMINE_BASE_URL", DEFAULT_BASE_URL)) + parser.add_argument("--project", default=os.getenv("REDMINE_PROJECT", DEFAULT_PROJECT)) + parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR) + parser.add_argument( + "--allow-prod-writes", + action="store_true", + help=( + "Allow write/apply operations against non-LAN hosts (e.g. https://rm.ldrprep.com). " + "Parent-level flag so it works with: ... --allow-prod-writes draft apply " + ), + ) + parser.add_argument( + "--config", + type=Path, + default=None, + help="Optional YAML/JSON config for base defaults (base-url, project, cache-dir, allow-prod-writes).", + ) + + subparsers = parser.add_subparsers(dest="command") + sync_parser = subparsers.add_parser("sync", help="Incrementally download enriched issues into cache.") + add_common_filters(sync_parser) + add_remote_sync_filters(sync_parser) + sync_parser.add_argument("--page-size", type=int, default=100) + sync_parser.add_argument( + "--dry-run", + action="store_true", + help="Fetch but do not write cache files/manifest.", + ) + sync_parser.add_argument( + "--no-journals", + action="store_true", + help="Request include=helpdesk only instead of journals,helpdesk on full issue fetch.", + ) + sync_parser.add_argument( + "--quiet", + action="store_true", + help="Suppress per-stage progress output (final summary still prints).", + ) + sync_parser.add_argument( + "--run-log", + nargs="?", + const="__default__", + default=None, + metavar="PATH", + help=( + "Record this sync's stats into a fixed small ring-buffer file " + f"(keeps last {DEFAULT_RUN_LOG_KEEP} runs). " + f"With no PATH, uses /{DEFAULT_RUN_LOG_NAME}. " + "Intended for cron so you get recent stats without managing log rotation." + ), + ) + sync_parser.add_argument( + "--run-log-keep", + type=int, + default=DEFAULT_RUN_LOG_KEEP, + help=f"How many recent runs to keep in --run-log (default: {DEFAULT_RUN_LOG_KEEP}).", + ) + + search_parser = subparsers.add_parser("search", help="Search/filter already cached issues.") + atp = _get_add_triage_parser() + if atp: atp(subparsers) + + add_common_filters(search_parser) + search_parser.add_argument( + "--where", + action="append", + default=[], + metavar="EXPR", + help=( + "LOCAL property query against cached ticket JSON. Repeatable (AND). " + 'Examples: subject.includes("rate sheet"); ' + "@unassigned or alias.helpdeskEmailMatchCompany (saved-filters/); " + 'status.name == "New" AND assigned_to IS NULL.' + ), + ) + search_parser.add_argument( + "--saved-filters-dir", + type=Path, + default=None, + help="Directory of saved filter .expr files (default: ./saved-filters or repo saved-filters/).", + ) + search_parser.add_argument("--json", action="store_true", help="Print matching issues as JSON.") + search_parser.add_argument("--jsonl", action="store_true", help="Print matching issues as JSON Lines.") + search_parser.add_argument( + "--ids-only", + action="store_true", + help="Print only matching issue ids (one per line).", + ) + search_parser.add_argument( + "--has-attachments", + action="store_true", + help="Only tickets whose cached JSON includes a non-empty attachments list.", + ) + search_parser.add_argument( + "--open-only", + action="store_true", + help="Exclude closed/resolved-ish statuses (is_closed or common closed names).", + ) + search_parser.add_argument( + "--control-tag", + help="LOCAL control-tag filter: only tickets labeled with this local tag (not a Redmine tag).", + ) + + view_parser = subparsers.add_parser( + "view", + help="Render a cached ticket as Markdown and show it (glow/bat/cat).", + ) + view_parser.add_argument("--issue-id", type=int, help="Redmine issue id (cache-first).") + view_parser.add_argument( + "issue_id_pos", + nargs="?", + type=int, + help="Issue id positional alternative to --issue-id.", + ) + view_parser.add_argument( + "--pager", + default="auto", + help="Markdown display: auto|glow|bat|batcat|cat (default auto).", + ) + view_parser.add_argument( + "--print", + dest="print_only", + action="store_true", + help="Print Markdown to stdout only (no pager).", + ) + view_parser.add_argument( + "--write-md", + action="store_true", + help="Write/update Markdown under //markdown/.md (regen when updated_on changes).", + ) + view_parser.add_argument( + "--force-md", + action="store_true", + help="Force regenerate Markdown cache even if updated_on stamp matches.", + ) + + walk_parser = subparsers.add_parser( + "walk", + help="Walk tickets interactively (n/p/q). MVP: --ids or same filters as search.", + ) + add_common_filters(walk_parser) + walk_parser.add_argument( + "--where", + action="append", + default=[], + metavar="EXPR", + help="Same property query as search --where (repeatable AND; supports @saved aliases).", + ) + walk_parser.add_argument( + "--saved-filters-dir", + type=Path, + default=None, + help="Directory of saved filter .expr files.", + ) + walk_parser.add_argument( + "--ids", + help="Comma-separated issue ids to walk (overrides search selection when set).", + ) + walk_parser.add_argument( + "--pager", + default="auto", + help="Markdown display: auto|glow|bat|batcat|cat (default auto).", + ) + walk_parser.add_argument( + "--control-tag", + help="LOCAL control-tag filter (same as search --control-tag).", + ) + walk_parser.add_argument( + "--open-only", + action="store_true", + help="Exclude closed/resolved-ish statuses.", + ) + walk_parser.add_argument( + "--has-attachments", + action="store_true", + help="Only tickets with attachments metadata in cache.", + ) + + mq_parser = subparsers.add_parser( + "materialize-queues", + help="Refresh named queue folders of symlinks from saved filters / queue config JSON.", + ) + mq_parser.add_argument( + "--config", + type=Path, + default=Path("queues.example.json"), + help="JSON queue definitions (see queues.example.json).", + ) + mq_parser.add_argument( + "--queues-dir", + type=Path, + default=None, + help="Root directory for queue folders (default: /queues).", + ) + mq_parser.add_argument( + "--saved-filters-dir", + type=Path, + default=None, + help="Directory of saved filter .expr files.", + ) + mq_parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be linked without writing symlinks.", + ) + + draft_parser = subparsers.add_parser( + "draft", + help="Local draft queue: list/cancel/apply staged notes or customer emails.", + ) + draft_sub = draft_parser.add_subparsers(dest="draft_command") + # Legacy "draft add --kind" removed from help focus; use note / customer-email commands. + draft_add = draft_sub.add_parser( + "add", + help="(Prefer `note` / `customer-email` commands.) Queue a draft by explicit kind.", + ) + draft_add.add_argument("--issue-id", type=int, required=True) + draft_add.add_argument( + "--kind", + choices=(KIND_INTERNAL_NOTE, KIND_CUSTOMER_EMAIL, "note", "helpdesk_reply"), + required=True, + help="REQUIRED explicit kind: internal_note (no email) or customer_email (emails customer).", + ) + draft_add.add_argument("--body", help="Draft body text.") + draft_add.add_argument("--body-file", type=Path, help="Read body from file.") + draft_add.add_argument( + "--private", + action="store_true", + help="Only valid for internal_note. Private notes never email customers.", + ) + draft_list = draft_sub.add_parser("list", help="List local drafts.") + draft_list.add_argument("--all", action="store_true", help="Include cancelled/applied, not only pending.") + draft_cancel = draft_sub.add_parser("cancel", help="Cancel a pending draft by id.") + draft_cancel.add_argument("draft_id", nargs="?", help="Draft id (or unique prefix).") + draft_cancel.add_argument("--all-pending", action="store_true", help="Cancel every pending draft.") + draft_apply = draft_sub.add_parser( + "apply", + help="Apply a pending draft to Redmine (LAN/test by default; prod needs --allow-prod-writes).", + ) + draft_apply.add_argument("draft_id", help="Draft id (or unique prefix).") + draft_apply.add_argument( + "--impersonate-user", + default="rebot", + help="X-Redmine-Switch-User for writes (default: rebot).", + ) + draft_apply.add_argument( + "--i-understand-this-emails-the-customer", + action="store_true", + help="REQUIRED when applying a customer_email draft. Refused otherwise.", + ) + draft_apply.add_argument( + "--allow-base-url-override", + action="store_true", + help="Allow applying a draft to a different --base-url than the one stored on the draft.", + ) + + note_parser = subparsers.add_parser( + "note", + help="INTERNAL journal note only — does NOT email the customer. Default queues a draft.", + ) + note_parser.add_argument("--issue-id", type=int, required=True) + note_parser.add_argument("--body", help="Note body text.") + note_parser.add_argument("--body-file", type=Path, help="Read body from file.") + note_parser.add_argument( + "--to-textile", + action="store_true", + help="If pandoc is installed, convert body (Markdown) to Textile before queue/apply.", + ) + note_parser.add_argument( + "--private", + action="store_true", + help="Mark as private journal note (staff-only; still does not email).", + ) + note_parser.add_argument( + "--apply", + action="store_true", + help="Write to Redmine now instead of only queueing a local draft.", + ) + note_parser.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply (default: rebot).", + ) + + email_parser = subparsers.add_parser( + "customer-email", + help="CUSTOMER EMAIL via Helpdesk — WILL email the customer. Separate from `note` on purpose.", + ) + email_parser.add_argument("--issue-id", type=int, required=True) + email_parser.add_argument("--body", help="Email body text.") + email_parser.add_argument("--body-file", type=Path, help="Read body from file.") + email_parser.add_argument( + "--to-textile", + action="store_true", + help="If pandoc is installed, convert body (Markdown) to Textile before queue/send.", + ) + email_parser.add_argument( + "--i-understand-this-emails-the-customer", + action="store_true", + required=True, + help="MANDATORY confirmation flag. Without it the command will not even queue.", + ) + email_parser.add_argument( + "--apply", + action="store_true", + help="Send via Helpdesk API now instead of only queueing a local draft.", + ) + email_parser.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply (default: rebot).", + ) + email_parser.add_argument("--to-address", help="Optional override To: for Helpdesk send.") + email_parser.add_argument("--cc-address", help="Optional Cc: for Helpdesk send.") + email_parser.add_argument("--status-id", type=int, help="Optional status_id when sending customer email.") + + fields_parser = subparsers.add_parser( + "set-fields", + help="Update issue fields (status/assignee/priority/category/due_date). Does NOT email. Default queues a draft.", + ) + fields_parser.add_argument("--issue-id", type=int, required=True) + fields_parser.add_argument( + "--status-id", + dest="field_status_id", + type=int, + help="Redmine status_id (numeric).", + ) + fields_parser.add_argument( + "--status", + dest="status_name", + help="Status by name (e.g. Closed). Resolved via cached meta or live API.", + ) + fields_parser.add_argument( + "--assigned-to-id", + type=int, + help="Assignee user id; use 0 to unassign.", + ) + fields_parser.add_argument( + "--assigned-to", + dest="assignee_name", + help="Assignee by name/login (substring OK). Use 'unassigned' to clear.", + ) + fields_parser.add_argument("--priority-id", type=int, help="Redmine priority_id.") + fields_parser.add_argument( + "--priority", + dest="priority_name", + help="Priority by name (e.g. Normal).", + ) + fields_parser.add_argument( + "--category-id", + type=int, + help="Category id; use 0 to clear.", + ) + fields_parser.add_argument( + "--category", + dest="category_name", + help="Category by name; use 'none' to clear.", + ) + fields_parser.add_argument("--due-date", help="Due date YYYY-MM-DD.") + fields_parser.add_argument("--subject", help="Replace issue subject.") + fields_parser.add_argument( + "--notes", + help="Optional internal journal note attached to the field change (still no email).", + ) + fields_parser.add_argument( + "--private", + action="store_true", + help="If --notes is set, mark that journal note private.", + ) + fields_parser.add_argument( + "--apply", + action="store_true", + help="Write to Redmine now instead of only queueing a local draft.", + ) + fields_parser.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply (default: rebot).", + ) + fields_parser.add_argument( + "--refresh-meta", + action="store_true", + help="Force refresh of status/user/priority/category name→id cache before resolve.", + ) + + meta_parser = subparsers.add_parser( + "meta-refresh", + help="Refresh local name→id metadata cache (statuses, priorities, categories, project users).", + ) + meta_parser.add_argument( + "--force", + action="store_true", + help="Refresh even if cache files are recent.", + ) + + contacts_parser = subparsers.add_parser( + "contacts-sync", + help="Sync project contacts into /contacts/ (durable local mirror).", + ) + contacts_parser.add_argument("--page-size", type=int, default=100) + contacts_parser.add_argument("--max-contacts", type=int, help="Cap how many contacts to process.") + contacts_parser.add_argument("--dry-run", action="store_true") + contacts_parser.add_argument("--quiet", action="store_true") + + csearch_parser = subparsers.add_parser( + "contacts-search", + help="Search locally cached contacts (after contacts-sync).", + ) + csearch_parser.add_argument("--query", help="Free-text match on name/email/company/tags.") + csearch_parser.add_argument("--email", help="Substring match on email.") + csearch_parser.add_argument("--company", help="Substring match on company.") + csearch_parser.add_argument("--tag", help="Match Redmine contact tag name.") + csearch_parser.add_argument("--max-contacts", type=int, default=50) + csearch_parser.add_argument("--ids-only", action="store_true") + csearch_parser.add_argument("--json", action="store_true") + + cshow_parser = subparsers.add_parser( + "contact-show", + help="Show one cached contact (cache-first; optional live fetch).", + ) + cshow_parser.add_argument("--contact-id", type=int, required=True) + cshow_parser.add_argument( + "--fetch", + action="store_true", + help="If missing from cache, fetch from Redmine and write cache.", + ) + cshow_parser.add_argument("--json", action="store_true", help="Print full JSON.") + + cset_parser = subparsers.add_parser( + "contact-set", + help="Update a CRM contact (tags/company/etc). Does NOT email. Default queues a draft.", + ) + cset_parser.add_argument("--contact-id", type=int, required=True) + cset_parser.add_argument( + "--add-tag", + action="append", + default=[], + help="Redmine CRM tag to add (repeatable). Not a local control-tag.", + ) + cset_parser.add_argument( + "--remove-tag", + action="append", + default=[], + help="Redmine CRM tag to remove (repeatable).", + ) + cset_parser.add_argument("--company", help="Set company string.") + cset_parser.add_argument("--first-name", dest="first_name", help="Set first name.") + cset_parser.add_argument("--last-name", dest="last_name", help="Set last name.") + cset_parser.add_argument( + "--set", + action="append", + default=[], + dest="contact_sets", + metavar="KEY=VALUE", + help="Raw contact field assignment (e.g. job_title=Buyer). Repeatable.", + ) + cset_parser.add_argument( + "--apply", + action="store_true", + help="Write to Redmine now instead of only queueing a local draft.", + ) + cset_parser.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply (default: rebot).", + ) + cset_parser.add_argument( + "--refresh-cache", + action="store_true", + help="After apply, re-fetch contact into local contacts cache.", + ) + + tcontact_parser = subparsers.add_parser( + "ticket-contact", + help="Bridge: resolve helpdesk contact id from a cached ticket (handoff to contact-*).", + ) + tcontact_parser.add_argument("--issue-id", type=int, required=True) + tcontact_parser.add_argument( + "--show", + action="store_true", + help="Also print contact name/email/company after resolving id (local contacts cache).", + ) + tcontact_parser.add_argument( + "--fetch", + action="store_true", + help="Fetch/refresh contact from Redmine into local cache and show it (implies --show).", + ) + tcontact_parser.add_argument("--json", action="store_true", help="Emit JSON with issue/contact ids.") + + att_parser = subparsers.add_parser( + "attachments", + help="List/download/upload ticket file attachments (does NOT email customer).", + ) + att_sub = att_parser.add_subparsers(dest="attachments_command") + att_list = att_sub.add_parser("list", help="List attachments on a ticket (cache-first; --refresh for live).") + att_list.add_argument("--issue-id", type=int, required=True) + att_list.add_argument( + "--refresh", + action="store_true", + help="Fetch issue with include=attachments from Redmine and update local cache entry.", + ) + att_list.add_argument("--json", action="store_true") + att_dl = att_sub.add_parser("download", help="Download one or all attachments for a ticket.") + att_dl.add_argument("--issue-id", type=int, required=True) + att_dl.add_argument("--attachment-id", type=int, help="Specific attachment id (required if multiple unless --all).") + att_dl.add_argument("--all", action="store_true", help="Download every attachment on the issue.") + att_dl.add_argument( + "--out-dir", + type=Path, + default=None, + help="Destination dir (default: //attachments//).", + ) + att_dl.add_argument( + "--refresh", + action="store_true", + help="Refresh attachment metadata from Redmine first.", + ) + att_up = att_sub.add_parser( + "upload", + help="Upload a local file and attach it to a ticket (does NOT email). Requires --apply to write.", + ) + att_up.add_argument("--issue-id", type=int, required=True) + att_up.add_argument("--file", type=Path, required=True, help="Local file path to upload.") + att_up.add_argument("--filename", help="Override filename sent to Redmine.") + att_up.add_argument("--description", default="", help="Attachment description.") + att_up.add_argument("--notes", help="Optional internal journal note with the attach (no email).") + att_up.add_argument( + "--apply", + action="store_true", + help="Actually upload+attach. Without this, only validates the local file path.", + ) + att_up.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply (default: rebot).", + ) + + ent_parser = subparsers.add_parser( + "entities", + help="Extract tracking/order/SKU-like entities from tickets (pattern-based; optional NMI enrich).", + ) + ent_sub = ent_parser.add_subparsers(dest="entities_command") + ent_one = ent_sub.add_parser("show", help="Extract entities from one ticket (cache).") + ent_one.add_argument("--issue-id", type=int, required=True) + ent_one.add_argument("--json", action="store_true") + ent_scan = ent_sub.add_parser("scan", help="Scan search results / cache for entity hits.") + ent_scan.add_argument("--where", action="append", default=[], help="Local property filter (repeatable AND).") + ent_scan.add_argument("--type", dest="entity_type", help="Only this entity type (e.g. tracking_ups).") + ent_scan.add_argument("--max-tickets", type=int, default=50) + ent_scan.add_argument("--ids-only", action="store_true", help="Print only issue ids that had hits.") + ent_scan.add_argument("--jsonl", action="store_true", help="One JSON object per hit line.") + ent_nmi = ent_sub.add_parser( + "nmi", + help="Resolve entities against NMI (bin/nmi) and print cs-brief / structured plan.", + ) + ent_nmi.add_argument("--issue-id", type=int, required=True) + ent_nmi.add_argument( + "--format", + choices=["brief", "json", "note"], + default="brief", + help="brief=cs-brief text; json=full payload; note=private-note body for Redmine.", + ) + ent_nmi.add_argument( + "--nmi-mode", + default=None, + help="NMI client mode (default: env NMI_MODE or dev).", + ) + ent_nmi.add_argument( + "--nmi-env-file", + default=None, + help="Path to NMI .env (default: mrscClient examples/modern/.env).", + ) + ent_nmi.add_argument( + "--nmi-bin", + default=None, + help="Path to bin/nmi (default: ~/projects/mrscClient-php/bin/nmi).", + ) + ent_nmi.add_argument( + "--nmi-endpoint", + default=None, + help="Optional NMI endpoint override.", + ) + ent_nmi.add_argument( + "--with-history", + action="store_true", + default=True, + help="Include item history from new getItemHistory endpoint (billings, moves, conditions) when item_no resolved. Default on.", + ) + ent_nmi.add_argument( + "--no-history", + dest="with_history", + action="store_false", + help="Disable item history fetch for this run.", + ) + ent_nmi.add_argument( + "--queue-private-note", + action="store_true", + help="Queue a private note draft with the brief (never emails). Use draft apply separately.", + ) + ent_nmi.add_argument( + "--apply-private-note", + action="store_true", + help="Also apply the private note (requires normal write gates / --allow-prod-writes on prod).", + ) + + browse_parser = subparsers.add_parser( + "browse", + help="Interactive loop: fzf/list → pager (glow/bat) → action menu → back to list.", + ) + browse_parser.add_argument("--where", action="append", default=[], help="Local property filter.") + browse_parser.add_argument("--control-tag", help="Local control-tag filter.") + browse_parser.add_argument("--status", help="Status name filter.") + browse_parser.add_argument("--max-tickets", type=int, default=80) + browse_parser.add_argument( + "--order", + choices=ORDER_CHOICES, + default="newest-updated-first", + ) + browse_parser.add_argument( + "--pager", + default="auto", + help="Markdown pager: auto|glow|bat|batcat|cat (same as view).", + ) + browse_parser.add_argument( + "--once", + action="store_true", + help="Pick one ticket, open pager, exit (no action menu / no loop).", + ) + browse_parser.add_argument( + "--has-attachments", + action="store_true", + help="Only tickets with attachments metadata in cache.", + ) + browse_parser.add_argument( + "--open-only", + action="store_true", + help="Exclude closed/resolved-ish statuses.", + ) + browse_parser.add_argument( + "--no-preview", + action="store_true", + help="Disable fzf right-hand preview pane.", + ) + + if add_tui_parser is not None: + add_tui_parser(subparsers) + + if add_textual_tui_parser is not None: + add_textual_tui_parser(subparsers) + + ocr_parser = subparsers.add_parser( + "ocr-rename", + help="OCR/extract text from ticket attachments; propose subject rename + private note draft.", + ) + ocr_parser.add_argument("--issue-id", type=int, help="Single issue id.") + ocr_parser.add_argument( + "--where", + action="append", + default=[], + help="Scan cached tickets matching --where (e.g. subject.includes(\"Scanned\")).", + ) + ocr_parser.add_argument( + "--max-tickets", + type=int, + default=20, + help="Max tickets when scanning with --where (default 20).", + ) + ocr_parser.add_argument( + "--only-generic-subjects", + action="store_true", + help="When scanning, skip tickets whose subject no longer looks like a generic scan title.", + ) + ocr_parser.add_argument( + "--queue-subject", + action="store_true", + default=True, + help="Queue set-fields draft with proposed subject (default on).", + ) + ocr_parser.add_argument( + "--no-queue-subject", + action="store_false", + dest="queue_subject", + help="Do not queue subject change draft.", + ) + ocr_parser.add_argument( + "--queue-note", + action="store_true", + default=True, + help="Queue private internal note with OCR text snippet (default on).", + ) + ocr_parser.add_argument( + "--no-queue-note", + action="store_false", + dest="queue_note", + help="Do not queue OCR note draft.", + ) + ocr_parser.add_argument( + "--apply-subject", + action="store_true", + help="Apply subject change now (LAN/prod safety gates apply). Still queues note unless --no-queue-note.", + ) + ocr_parser.add_argument( + "--impersonate-user", + default="rebot", + help="Used only with --apply-subject (default: rebot).", + ) + ocr_parser.add_argument( + "--json", + action="store_true", + help="Print machine-readable results.", + ) + + cat_parser = subparsers.add_parser( + "categories", + help="List (and later manage) project issue categories for triage.", + ) + cat_sub = cat_parser.add_subparsers(dest="categories_command") + cat_list = cat_sub.add_parser("list", help="List issue categories for the project.") + cat_list.add_argument( + "--refresh", + action="store_true", + help="Refresh from Redmine API (also updates meta cache).", + ) + cat_list.add_argument("--json", action="store_true") + + ctag_parser = subparsers.add_parser( + "control-tag", + help="LOCAL-only control tags (not Redmine tags). Label tickets for filters/automation.", + ) + ctag_sub = ctag_parser.add_subparsers(dest="control_tag_command") + ctag_add = ctag_sub.add_parser("add", help="Tag a ticket locally.") + ctag_add.add_argument("--tag", required=True, help="Local tag name (no spaces; e.g. bavaria).") + ctag_add.add_argument("--issue-id", type=int, required=True) + ctag_add.add_argument( + "--property", + action="append", + default=[], + dest="properties", + help="JSON path that justified the tag (repeatable).", + ) + ctag_add.add_argument("--annotation", default="", help="Optional free-text reason.") + ctag_rm = ctag_sub.add_parser("remove", help="Remove a local tag from a ticket.") + ctag_rm.add_argument("--tag", required=True) + ctag_rm.add_argument("--issue-id", type=int, required=True) + ctag_list = ctag_sub.add_parser("list", help="List local control tags and ticket counts.") + ctag_show = ctag_sub.add_parser("show", help="Show tickets for a local tag.") + ctag_show.add_argument("--tag", required=True) + ctag_for = ctag_sub.add_parser("for-issue", help="List local control tags on one ticket.") + ctag_for.add_argument("--issue-id", type=int, required=True) + ctag_suggest = ctag_sub.add_parser( + "suggest", + help="Rule-based suggestions for a control tag over cached tickets (does not write unless --apply).", + ) + ctag_suggest.add_argument("--tag", required=True) + ctag_suggest.add_argument( + "--where", + action="append", + default=[], + help="Property query candidates (repeatable AND). Example: subject.includes(\"bavaria\")", + ) + ctag_suggest.add_argument( + "--max-tickets", + type=int, + default=50, + help="Max candidates to propose.", + ) + ctag_suggest.add_argument( + "--apply", + action="store_true", + help="Write suggested tags locally (still not Redmine).", + ) + ctag_suggest.add_argument( + "--property", + action="append", + default=["subject"], + dest="properties", + help="Properties to record as rationale when applying (default: subject).", + ) + ctag_export = ctag_sub.add_parser( + "export", + help="Export tagged tickets as JSONL for training / LLM refine (local only).", + ) + ctag_export.add_argument("--tag", help="Only this control tag (default: all tags).") + ctag_export.add_argument( + "--out", + type=Path, + required=True, + help="Output .jsonl path.", + ) + ctag_wipe = ctag_sub.add_parser( + "wipe", + help="Delete the entire local control-tag store (does not touch Redmine or ticket cache).", + ) + ctag_wipe.add_argument( + "--i-understand-this-wipes-local-control-tags", + action="store_true", + required=True, + help="Mandatory confirmation.", + ) + + spam_parser = subparsers.add_parser("spam-check", help="Score cached issues for obvious spam/promo signals.") + spam_parser.add_argument("--apply", action="store_true", help="Record spam-check result in manifest for idempotency.") + spam_parser.add_argument("--threshold", type=int, default=2, help="Minimum score to report SPAM.") + + close_parser = subparsers.add_parser("close-if-spam", help="Close cached tickets previously marked spam and review sender history.") + close_parser.add_argument("--apply", action="store_true", help="Actually update Redmine issues and record close/contact-review markers.") + close_parser.add_argument("--close-status-id", type=int, default=5, help="Redmine status_id to set when closing spam tickets.") + close_parser.add_argument("--contact-ticket-limit", type=int, default=10, help="Recent sender/contact tickets to review before contact-level spam judgment.") + close_parser.add_argument("--threshold", type=int, default=2, help="Minimum ticket spam score used during contact lookback.") + close_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for write calls; sent as X-Redmine-Switch-User only when --apply updates issues.") + + maybe_parser = subparsers.add_parser("tag-maybe-customer", help="Tag contacts needing manual customer review based on inbound/business inquiry evidence.") + maybe_parser.add_argument("--apply", action="store_true", help="Actually add the tag to matching Redmine contacts.") + maybe_parser.add_argument("--tag", default="maybe-customer", help="Contact tag to add for manual review.") + maybe_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for contact tag writes; sent only with --apply.") + + decompose_parser = subparsers.add_parser("decompose", help="Build a dry-run ticket decomposition plan without writing vault or Redmine state.") + decompose_parser.add_argument("--issue-id", type=int, help="Redmine issue id to decompose. Uses cache first, then live API if needed.") + decompose_parser.add_argument("--input-json", type=Path, help="Read a cached/enriched issue JSON file instead of fetching by id.") + decompose_parser.add_argument("--format", choices=("json", "markdown"), default="json", help="Output format for the dry-run plan.") + kscan_parser = subparsers.add_parser("known-spam-scan", help="Scan for tickets matching known spam profiles (dry-run).") + kscan_parser.add_argument("--profiles", type=Path, default=Path("profiles/known-spam/customer-service.json"), help="Path to known-spam profiles JSON.") + kscan_parser.add_argument("--max-tickets", type=int, help="Limit number of tickets to scan.") + + kclose_parser = subparsers.add_parser("known-spam-close", help="Close tickets matching known spam profiles.") + kclose_parser.add_argument("--profiles", type=Path, default=Path("profiles/known-spam/customer-service.json"), help="Path to known-spam profiles JSON.") + kclose_parser.add_argument("--apply", action="store_true", help="Actually close matching Redmine tickets.") + kclose_parser.add_argument("--close-status-id", type=int, default=5, help="Redmine status_id to use when closing.") + kclose_parser.add_argument("--max-tickets", type=int, help="Limit number of tickets to process.") + kclose_parser.add_argument("--impersonate-user", default="rebot", help="Redmine username for write calls.") + + return parser + + +def add_common_filters(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--updated-since", + help="LOCAL post-filter only: keep issues updated on/after date or datetime (applied after API list).", + ) + parser.add_argument( + "--updated-before", + help="LOCAL post-filter only: keep issues updated on/before date or datetime (applied after API list).", + ) + parser.add_argument( + "--status", + help="LOCAL post-filter only: filter by status name after fetch/cache load (e.g. New, Closed).", + ) + parser.add_argument( + "--category", + help="LOCAL post-filter only: filter by category name after fetch/cache load.", + ) + parser.add_argument("--max-tickets", type=int, help="Maximum ticket count after filters/order.") + parser.add_argument("--order", choices=ORDER_CHOICES, default="newest-updated-first") + + +def add_remote_sync_filters(parser: argparse.ArgumentParser) -> None: + """Server-side Redmine REST filters used by the sync list call.""" + parser.add_argument( + "--status-id", + "--remote-status-id", + dest="status_id", + default="*", + help=( + "SERVER-SIDE Redmine API filter `status_id`. " + "Values: * (open+closed), open, closed, or numeric status id. Default: *." + ), + ) + parser.add_argument( + "--remote-updated-on", + help=( + "SERVER-SIDE raw Redmine API filter for `updated_on` (official operators). " + "Examples: '>=2026-06-14', '>=2014-01-02T08:12:32Z', " + "'><2026-01-01|2026-06-01'. Mutually exclusive with the convenience flags." + ), + ) + parser.add_argument( + "--remote-updated-since", + help=( + "SERVER-SIDE convenience for updated_on >= .... Accepts YYYY-MM-DD, ISO datetime, " + "or relative forms: 30d, 2h, 15m, 1w, '2 days ago'. Builds official Redmine operator syntax." + ), + ) + parser.add_argument( + "--remote-updated-before", + help=( + "SERVER-SIDE convenience for updated_on <= .... Same value forms as --remote-updated-since. " + "Combined with --remote-updated-since as Redmine range operator > list[str]: + """Insert `sync` when the user omitted a subcommand. + + Argparse subparsers only accept their own flags *after* the command name. + Without this, a natural invocation like: + + ./redmine_tickets.py --project customer-service --updated-since 2026-07-12 + + fails with: invalid choice: '2026-07-12' (argparse treats the date as the command). + """ + if any(arg in KNOWN_COMMANDS for arg in argv): + return argv + + i = 0 + while i < len(argv): + arg = argv[i] + if arg in KNOWN_COMMANDS: + return argv + if arg in _PARENT_VALUE_OPTIONS: + i += 2 + continue + if arg.startswith("--base-url=") or arg.startswith("--project=") or arg.startswith("--cache-dir="): + i += 1 + continue + if arg in {"-h", "--help"}: + # Leave bare --help alone so top-level help still works. + return argv + # First unknown option/positional belongs to the default sync command. + return argv[:i] + ["sync"] + argv[i:] + # No args after parent globals (or empty): default to sync. + return argv + ["sync"] + + +def main(argv: list[str] | None = None) -> int: + raw_argv = list(argv) if argv is not None else sys.argv[1:] + try: + raw_argv = apply_runtime_config_to_argv(raw_argv) + except RedmineError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + normalized_argv = inject_default_sync_command(raw_argv) + parser = build_parser() + args = parser.parse_args(normalized_argv) + + # Post-process common globals so subparsers (tui/browse/...) that re-declare them + # (to support "cmd --allow-prod-writes ...") don't leave them as None. + if getattr(args, "cache_dir", None) in (None, ""): + from pathlib import Path as _P + args.cache_dir = _P(".cache/redmine_tickets") + if getattr(args, "project", None) in (None, ""): + args.project = "customer-service" + if getattr(args, "base_url", None) in (None, ""): + args.base_url = os.getenv("REDMINE_BASE_URL", "https://rm.ldrprep.com") + + # Also normalize the write flag so tui/browse can rely on it + if not getattr(args, "allow_prod_writes", False): + env_flag = os.getenv("REDMINE_ALLOW_PROD_WRITES", "").lower() in ("1", "true", "yes") + setattr(args, "allow_prod_writes", env_flag) + + cache = TicketCache(args.cache_dir, args.project) + + # Keep the interactive TUI dispatch independent of the legacy command chain + # below. That chain historically became nested under the sync branch, which + # made `rtl tui` parse normally and then silently fall through to return 1. + if args.command == "tui": + if run_tui_cli is None: + raise RedmineError("TUI unavailable (redmine_ticket_tui.py failed to load).") + return run_tui_cli(args, cache) + + if args.command == "sync": + run_log_path = resolve_run_log_path(getattr(args, "run_log", None), args.cache_dir) + started_at = now_iso() + started_mono = time.monotonic() + remote_updated_on = None + sync_result: SyncResult | None = None + try: + client = client_from_args(args) + remote_updated_on = build_remote_updated_on_value( + raw=getattr(args, "remote_updated_on", None), + since=getattr(args, "remote_updated_since", None), + before=getattr(args, "remote_updated_before", None), + ) + sync_result = sync_tickets( + client, + cache, + updated_since=args.updated_since, + updated_before=args.updated_before, + status=args.status, + category=args.category, + max_tickets=args.max_tickets, + order=args.order, + apply=not args.dry_run, + include_journals=not args.no_journals, + page_size=args.page_size, + status_id=args.status_id, + remote_updated_on=remote_updated_on, + progress=not args.quiet, + ) + print( + f"Sync complete: seen={sync_result.seen} fetched={sync_result.fetched} " + f"skipped_cached={sync_result.skipped_cached} written={sync_result.written}" + ) + if args.dry_run: + print("Dry run only. Cache was not written.") + if run_log_path is not None: + entry = build_sync_run_entry( + started_at=started_at, + finished_at=now_iso(), + duration_seconds=time.monotonic() - started_mono, + args=args, + remote_updated_on=remote_updated_on, + result=sync_result, + ok=True, + ) + written_path = append_run_stats( + run_log_path, + entry, + keep=max(1, int(getattr(args, "run_log_keep", DEFAULT_RUN_LOG_KEEP) or DEFAULT_RUN_LOG_KEEP)), + ) + if not args.quiet: + print(f"Run log updated: {written_path} (kept last {args.run_log_keep})") + return 0 + except RedmineError as exc: + if run_log_path is not None: + entry = build_sync_run_entry( + started_at=started_at, + finished_at=now_iso(), + duration_seconds=time.monotonic() - started_mono, + args=args, + remote_updated_on=remote_updated_on, + result=sync_result, + ok=False, + error=str(exc), + ) + try: + append_run_stats( + run_log_path, + entry, + keep=max(1, int(getattr(args, "run_log_keep", DEFAULT_RUN_LOG_KEEP) or DEFAULT_RUN_LOG_KEEP)), + ) + except OSError: + pass + raise + + + if args.command == "search": + matches = select_cached_issues_for_cli(cache, args) + if not matches: + print("No matching tickets.", file=sys.stderr) + return 0 + ids_only = getattr(args, "ids_only", False) + do_json = getattr(args, "json", False) + do_jsonl = getattr(args, "jsonl", False) + if ids_only: + for issue in matches: + print(issue.get("id")) + elif do_json: + print(json.dumps(matches, indent=2, sort_keys=True, default=str)) + elif do_jsonl: + for issue in matches: + print(json.dumps(issue, default=str)) + else: + for issue in matches: + iid = issue.get("id") + subj = str(issue.get("subject") or "").replace("\n", " ")[:70] + st = (issue.get("status") or {}).get("name", "?") if isinstance(issue.get("status"), dict) else "?" + who = (issue.get("assigned_to") or {}).get("name", "unassigned") if isinstance(issue.get("assigned_to"), dict) else "unassigned" + print(f"#{iid}\t{st}\t{who}\t{subj}") + return 0 + + if args.command == "walk": + if args.ids: + ids: list[int] = [] + for part in str(args.ids).split(","): + part = part.strip() + if not part: + continue + ids.append(int(part)) + issues = [] + for issue_id in ids: + try: + issues.append(load_cached_issue(cache, issue_id)) + except RedmineError as exc: + print(f"warning: {exc}", file=sys.stderr) + else: + issues = select_cached_issues_for_cli(cache, args) + return run_walk_session(issues, cache, pager=args.pager) + + if args.command == "materialize-queues": + if load_queue_definitions is None or materialize_queue is None or filter_by_query is None: + raise RedmineError("Queue helpers unavailable (redmine_ticket_filters.py).") + config_path = args.config + if not config_path.is_file(): + # try next to script + alt = Path(__file__).resolve().parent / config_path + if alt.is_file(): + config_path = alt + if not config_path.is_file(): + raise RedmineError(f"Queue config not found: {args.config}") + queues = load_queue_definitions(config_path) + queues_root = args.queues_dir or (Path(args.cache_dir) / "queues") + saved_dir = Path(args.saved_filters_dir or DEFAULT_SAVED_FILTERS_DIR) + script_saved = Path(__file__).resolve().parent / "saved-filters" + search_dirs = [saved_dir] + if script_saved.is_dir() and script_saved not in search_dirs: + search_dirs.append(script_saved) + + all_issues = list(cache.iter_cached_issues()) + for qdef in queues: + name = str(qdef["name"]) + where_raw = list(qdef.get("where") or []) + if isinstance(qdef.get("filter"), str): + where_raw.append(str(qdef["filter"])) + try: + if expand_where_list is not None: + where_exprs = expand_where_list(where_raw, search_dirs) + else: + where_exprs = where_raw + matched = filter_by_query(all_issues, expressions=where_exprs) if where_exprs else list(all_issues) + except QueryError as exc: + raise RedmineError(f"Queue {name!r} filter error: {exc}") from exc + matched = select_issues( + matched, + order=str(qdef.get("order") or "newest-updated-first"), + max_tickets=qdef.get("max_tickets"), + ) + if args.dry_run: + print(f"queue={name} would_link={len(matched)} ids={[i.get('id') for i in matched[:20]]}") + continue + manifest = materialize_queue( + name=name, + issues=matched, + cache_dir=Path(args.cache_dir), + project=args.project, + queues_root=queues_root, + ) + print( + f"queue={name} linked={manifest['linked']} " + f"missing_source={manifest['missing_source']} dir={queues_root / name}" + ) + return 0 + + if args.command == "note": + body = resolve_body_text(args) + print("ACTION: INTERNAL NOTE") + print(" emails_customer: NO") + print(f" issue_id: {args.issue_id}") + print(f" private: {bool(args.private)}") + if not args.apply: + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + path = add_draft( + Path(args.cache_dir), + issue_id=int(args.issue_id), + kind=KIND_INTERNAL_NOTE, + body=body, + private=bool(args.private), + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_INTERNAL_NOTE, + issue_id=int(args.issue_id), + base_url=args.base_url, + emails_customer=False, + ) + return 0 + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args)) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + client.add_internal_note( + int(args.issue_id), + body, + private=bool(args.private), + impersonate_user=args.impersonate_user, + ) + + # Sync local cache so the new journal/note is visible in TUI/browse without full re-sync + try: + if refresh_cached_issue: + c = TicketCache(Path(args.cache_dir), args.project) + refresh_cached_issue(c, client, int(args.issue_id)) + except Exception: + pass + + print(f"applied internal note on #{args.issue_id} (no customer email)") + return 0 + + if args.command == "set-fields": + resolved = resolve_set_fields_names(args) + fields = build_fields_payload_from_args(args, resolved_ids=resolved) + print("ACTION: FIELD UPDATE") + print(" emails_customer: NO") + print(f" issue_id: {args.issue_id}") + if resolved: + print(f" resolved_names: {json.dumps(resolved, sort_keys=True)}") + print(f" fields: {json.dumps(fields, sort_keys=True)}") + if not args.apply: + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + path = add_draft( + Path(args.cache_dir), + issue_id=int(args.issue_id), + kind=KIND_FIELDS, + body="", + fields=fields, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_FIELDS, + issue_id=int(args.issue_id), + base_url=args.base_url, + emails_customer=False, + ) + return 0 + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args)) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + client.update_issue(int(args.issue_id), fields, impersonate_user=args.impersonate_user) + + # Sync local cache with live so TUI/browse/etc. see the updated status/assignee/etc. immediately + try: + if refresh_cached_issue: + c = TicketCache(Path(args.cache_dir), args.project) + refresh_cached_issue(c, client, int(args.issue_id)) + except Exception: + pass + + print(f"applied field update on #{args.issue_id} (no customer email)") + return 0 + + if args.command == "meta-refresh": + if refresh_meta_cache is None: + raise RedmineError("Meta helpers unavailable (redmine_ticket_meta.py).") + client = client_from_args(args) + paths = refresh_meta_cache( + client, + Path(args.cache_dir), + args.project, + force=bool(args.force), + ) + for name, path in paths.items(): + print(f"{name}\t{path}") + return 0 + + if args.command == "contacts-sync": + if ContactCache is None or sync_contacts is None: + raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).") + client = client_from_args(args) + ccache = ContactCache(Path(args.cache_dir)) + result = sync_contacts( + client, + ccache, + page_size=int(args.page_size), + max_contacts=args.max_contacts, + progress=not bool(args.quiet), + dry_run=bool(args.dry_run), + ) + print(json.dumps(result, sort_keys=True)) + return 0 + + if args.command == "contacts-search": + if ContactCache is None or filter_contacts is None: + raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).") + ccache = ContactCache(Path(args.cache_dir)) + contacts = list(ccache.iter_contacts()) + matches = filter_contacts( + contacts, + query=args.query, + email=args.email, + company=args.company, + tag=args.tag, + ) + if args.max_contacts is not None: + matches = matches[: int(args.max_contacts)] + if args.json: + print(json.dumps(matches, indent=2, sort_keys=True)) + return 0 + if args.ids_only: + for c in matches: + if c.get("id") is not None: + print(c["id"]) + return 0 + print(f"matches={len(matches)}", file=sys.stderr) + for c in matches: + name = contact_display_name(c) if contact_display_name else c.get("name") + email = primary_email(c) if primary_email else c.get("email") + print(f"#{c.get('id')}\t{name}\t{email}\t{c.get('company') or ''}") + return 0 + + if args.command == "contact-show": + if ContactCache is None: + raise RedmineError("Contact helpers unavailable (redmine_ticket_contacts.py).") + ccache = ContactCache(Path(args.cache_dir)) + cid = int(args.contact_id) + try: + contact = ccache.load_contact(cid) + except FileNotFoundError: + if not args.fetch: + raise RedmineError( + f"No cached contact #{cid}. Run contacts-sync or contact-show --fetch." + ) + client = client_from_args(args) + contact = client.get_contact(cid) + ccache.write_contact(contact) + if args.json: + print(json.dumps(contact, indent=2, sort_keys=True)) + else: + name = contact_display_name(contact) if contact_display_name else contact.get("name") + email = primary_email(contact) if primary_email else contact.get("email") + print(f"id={contact.get('id')}") + print(f"name={name}") + print(f"email={email}") + print(f"company={contact.get('company') or ''}") + print(f"updated_on={contact.get('updated_on') or ''}") + return 0 + + if args.command == "ticket-contact": + if contact_id_from_issue is None: + raise RedmineError("Contact bridge helper unavailable.") + issue = load_cached_issue(cache, int(args.issue_id)) + cid = contact_id_from_issue(issue) + if cid is None: + raise RedmineError( + f"Ticket #{args.issue_id} has no helpdesk contact id in cache. " + "Sync with journals,helpdesk or open a non-helpdesk issue?" + ) + if args.json: + print( + json.dumps( + { + "issue_id": int(args.issue_id), + "contact_id": cid, + "from_address": ( + (issue.get("helpdesk_ticket") or {}).get("from_address") + if isinstance(issue.get("helpdesk_ticket"), dict) + else None + ), + }, + indent=2, + sort_keys=True, + ) + ) + else: + print(f"issue_id={args.issue_id}") + print(f"contact_id={cid}") + print(f"handoff_hint=contact-show --contact-id {cid}") + print(f"handoff_hint=contact-set --contact-id {cid} --add-tag maybe-customer") + print( + "handoff_hint_full_crm=" + f"redmine_contacts.py --base-url {args.base_url} --project {args.project} " + f"show --contact-id {cid}" + ) + # --fetch implies show; alone it used to silently do nothing (bug). + want_show = bool(getattr(args, "show", False) or getattr(args, "fetch", False)) + want_fetch = bool(getattr(args, "fetch", False)) + if want_show: + if ContactCache is None: + raise RedmineError("Contact helpers unavailable.") + ccache = ContactCache(Path(args.cache_dir)) + contact: dict[str, Any] + if want_fetch: + client = client_from_args(args) + contact = client.get_contact(cid) + ccache.write_contact(contact) + print(f"fetched_contact=yes contact_id={cid}") + else: + try: + contact = ccache.load_contact(cid) + except FileNotFoundError as exc: + raise RedmineError( + f"Contact #{cid} not in local contacts cache. " + "Re-run with --fetch (or contacts-sync / contact-show --fetch)." + ) from exc + name = contact_display_name(contact) if contact_display_name else contact.get("name") + email = primary_email(contact) if primary_email else contact.get("email") + print(f"name={name}") + print(f"email={email}") + print(f"company={contact.get('company') or ''}") + return 0 + + if args.command == "contact-set": + if ContactCache is None: + raise RedmineError("Contact helpers unavailable.") + cid = int(args.contact_id) + # Prefer cache, fall back to API for tag merge base + contact: dict[str, Any] | None = None + ccache = ContactCache(Path(args.cache_dir)) + try: + contact = ccache.load_contact(cid) + except FileNotFoundError: + contact = None + if contact is None and (args.add_tag or args.remove_tag or args.apply): + client = client_from_args(args) + contact = client.get_contact(cid) + ccache.write_contact(contact) + + fields: dict[str, Any] = {} + for assignment in list(args.contact_sets or []): + if "=" not in assignment: + raise RedmineError(f"Invalid --set value: {assignment!r} (want KEY=VALUE)") + key, value = assignment.split("=", 1) + key = key.strip() + if not key: + raise RedmineError(f"Invalid --set value: {assignment!r}") + fields[key] = value + if args.company is not None: + fields["company"] = args.company + if args.first_name is not None: + fields["first_name"] = args.first_name + if args.last_name is not None: + fields["last_name"] = args.last_name + if args.add_tag or args.remove_tag: + if contact is None: + raise RedmineError( + "Tag changes need a contact base payload. Fetch failed or contact missing." + ) + fields["tag_list"] = merge_tags( + extract_tag_list(contact), + list(args.add_tag or []), + list(args.remove_tag or []), + ) + if not fields: + raise RedmineError( + "contact-set requires at least one of: --add-tag, --remove-tag, " + "--company, --first-name, --last-name, --set KEY=VALUE" + ) + + print("ACTION: CONTACT UPDATE") + print(" emails_customer: NO") + print(f" contact_id: {cid}") + print(f" fields: {json.dumps(fields, sort_keys=True)}") + if not args.apply: + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + path = add_draft( + Path(args.cache_dir), + contact_id=cid, + kind=KIND_CONTACT_FIELDS, + fields=fields, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_CONTACT_FIELDS, + contact_id=cid, + base_url=args.base_url, + emails_customer=False, + ) + return 0 + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args)) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + client.update_contact(cid, fields, impersonate_user=args.impersonate_user) + print(f"applied contact update on contact #{cid} (no customer email)") + if args.refresh_cache: + fresh = client.get_contact(cid) + ccache.write_contact(fresh) + print(f"refreshed local contact cache for #{cid}") + return 0 + + if args.command == "attachments": + if attachments_from_issue is None or pick_attachment is None: + raise RedmineError("Attachment helpers unavailable (redmine_ticket_attachments.py).") + cmd = args.attachments_command + if cmd is None: + raise RedmineError("attachments requires subcommand: list | download | upload") + + def load_issue_atts(issue_id: int, refresh: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]: + issue: dict[str, Any] | None = None + if not refresh: + try: + issue = load_cached_issue(cache, issue_id) + except RedmineError: + issue = None + if refresh or issue is None or not attachments_from_issue(issue or {}): + client = client_from_args(args) + issue = client.get_issue_with_includes( + issue_id, ("attachments", "journals", "helpdesk") + ) + # best-effort write-through to ticket cache + try: + cache.write_issue(issue) + except Exception: + pass + assert issue is not None + return issue, attachments_from_issue(issue) + + if cmd == "list": + issue, atts = load_issue_atts(int(args.issue_id), bool(args.refresh)) + if args.json: + print(json.dumps(atts, indent=2, sort_keys=True)) + return 0 + print(f"issue_id={args.issue_id} attachments={len(atts)}", file=sys.stderr) + for att in atts: + print( + f"#{att.get('id')}\t{att.get('filesize') or att.get('file_size') or ''}\t" + f"{att.get('content_type') or ''}\t{att.get('filename') or att.get('filename')}" + ) + if not atts: + print( + "No attachments in payload. Try: attachments list --issue-id N --refresh", + file=sys.stderr, + ) + return 0 + + if cmd == "download": + if download_attachment_bytes is None or download_url_for_attachment is None: + raise RedmineError("Download helpers unavailable.") + if safe_filename is None or attachment_dir is None: + raise RedmineError("Path helpers unavailable.") + issue_id = int(args.issue_id) + _issue, atts = load_issue_atts(issue_id, bool(args.refresh)) + if args.all: + targets = atts + else: + try: + targets = [pick_attachment(atts, args.attachment_id)] + except LookupError as exc: + raise RedmineError(str(exc)) from exc + out_dir = args.out_dir or attachment_dir(Path(args.cache_dir), args.project, issue_id) + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + api_key = os.getenv("REDMINE_API_KEY") + if not api_key: + raise RedmineError("REDMINE_API_KEY required to download attachments.") + for att in targets: + url = download_url_for_attachment(att, args.base_url) + name = safe_filename(str(att.get("filename") or f"attachment-{att.get('id')}")) + dest = out_dir / name + # avoid clobber if same name different id + if dest.exists() and att.get("id") is not None: + dest = out_dir / f"{att.get('id')}_{name}" + data = download_attachment_bytes(url=url, api_key=api_key) + dest.write_bytes(data) + print(f"downloaded attachment_id={att.get('id')} path={dest} bytes={len(data)}") + return 0 + + if cmd == "upload": + if upload_file_token is None: + raise RedmineError("Upload helpers unavailable.") + path = Path(args.file) + if not path.is_file(): + raise RedmineError(f"File not found: {path}") + print("ACTION: ATTACHMENT UPLOAD") + print(" emails_customer: NO") + print(f" issue_id: {args.issue_id}") + print(f" file: {path}") + print(f" size_bytes: {path.stat().st_size}") + if not args.apply: + print("dry_run=yes (pass --apply to upload+attach)") + print( + f"apply_hint=attachments upload --issue-id {args.issue_id} " + f"--file {path} --apply" + ) + return 0 + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed( + args.base_url, allow_prod_writes=allow_prod_writes_enabled(args) + ) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + api_key = os.getenv("REDMINE_API_KEY") + if not api_key: + raise RedmineError("REDMINE_API_KEY required for upload.") + upload = upload_file_token( + base_url=args.base_url, + api_key=api_key, + path=path, + filename=args.filename, + impersonate_user=args.impersonate_user, + ) + if args.description: + upload["description"] = args.description + client = client_from_args(args) + client.attach_upload_to_issue( + int(args.issue_id), + upload, + notes=args.notes, + impersonate_user=args.impersonate_user, + ) + print( + f"attached token={upload.get('token')} filename={upload.get('filename')} " + f"to issue #{args.issue_id}" + ) + return 0 + + raise RedmineError(f"Unknown attachments subcommand: {cmd}") + + if args.command == "entities": + if extract_entities_from_issue is None: + raise RedmineError("Entity helpers unavailable (redmine_ticket_entities.py).") + ecmd = args.entities_command + if ecmd is None: + raise RedmineError("entities requires subcommand: show | scan | nmi") + if ecmd == "show": + issue = load_cached_issue(cache, int(args.issue_id)) + ents = extract_entities_from_issue(issue) + if args.json: + print(json.dumps(ents, indent=2, sort_keys=True)) + else: + print(f"issue_id={args.issue_id} entities={len(ents)}") + for e in ents: + print(f"{e.get('type')}\t{e.get('value')}\t{e.get('confidence')}") + if not ents: + print("(no pattern hits)", file=sys.stderr) + return 0 + if ecmd == "scan": + issues = select_cached_issues_for_cli(cache, args) + # select uses where/max from args - ensure max_tickets/order present + hits_out = [] + for issue in issues: + ents = extract_entities_from_issue(issue) + if getattr(args, "entity_type", None): + ents = [e for e in ents if e.get("type") == args.entity_type] + if not ents: + continue + iid = issue.get("id") + if args.ids_only: + print(iid) + elif args.jsonl: + for e in ents: + print( + json.dumps( + { + "issue_id": iid, + "subject": issue.get("subject"), + **e, + }, + sort_keys=True, + ) + ) + else: + line = format_entities_line(ents) if format_entities_line else str(ents) + subj = str(issue.get("subject") or "").replace("\n", " ")[:70] + print(f"#{iid}\t{line}\t{subj}") + hits_out.append(iid) + print(f"tickets_with_hits={len(hits_out)}", file=sys.stderr) + return 0 + if ecmd == "nmi": + if brief_for_issue is None or format_private_note is None: + raise RedmineError("NMI bridge unavailable (redmine_ticket_nmi.py).") + issue = load_cached_issue(cache, int(args.issue_id)) + ents = extract_entities_from_issue(issue) + kwargs = {} + if getattr(args, "nmi_mode", None): + kwargs["mode"] = args.nmi_mode + if getattr(args, "nmi_env_file", None): + kwargs["env_file"] = args.nmi_env_file + if getattr(args, "nmi_bin", None): + kwargs["nmi_bin"] = args.nmi_bin + if getattr(args, "nmi_endpoint", None): + kwargs["endpoint"] = args.nmi_endpoint + # History is fetched inside when item_no present; respect explicit --no-history + if not getattr(args, "with_history", True): + # caller can still pass to skip in future + pass + payload = brief_for_issue(issue, ents, **kwargs) + fmt = getattr(args, "format", "brief") or "brief" + note_body = format_private_note(payload) + + if getattr(args, "queue_private_note", False) or getattr(args, "apply_private_note", False): + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + draft_path = add_draft( + Path(args.cache_dir), + issue_id=int(args.issue_id), + kind=KIND_INTERNAL_NOTE, + body=note_body, + private=True, + base_url=getattr(args, "base_url", None), + project=getattr(args, "project", None), + ) + draft_id = draft_path.stem if hasattr(draft_path, "stem") else Path(str(draft_path)).stem + if print_draft_queued is not None: + print_draft_queued( + draft_path, + kind=KIND_INTERNAL_NOTE, + issue_id=int(args.issue_id), + base_url=getattr(args, "base_url", None), + emails_customer=False, + ) + else: + print(f"draft_id={draft_id}") + print(f"draft_path={draft_path}") + print(f"issue_id={args.issue_id}") + print(f"kind={KIND_INTERNAL_NOTE}") + print("emails_customer=NO") + print(f"apply_hint=draft apply {draft_id}") + + if getattr(args, "apply_private_note", False): + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed( + args.base_url, + allow_prod_writes=allow_prod_writes_enabled(args), + ) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + _, draft_data = load_draft(Path(args.cache_dir), draft_id) + apply_draft_to_redmine( + client, + draft_data, + impersonate_user=getattr(args, "impersonate_user", None) or "rebot", + allow_customer_email=False, + ) + if mark_draft_applied is not None: + mark_draft_applied(Path(args.cache_dir), draft_id) + try: + c = TicketCache(Path(args.cache_dir), getattr(args, "project", "customer-service")) + refresh_cached_issue(c, client, int(args.issue_id)) + except Exception: + pass + print(f"applied private NMI brief note on #{args.issue_id} (no customer email)", file=sys.stderr) + + if fmt == "json": + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + elif fmt == "note": + print(note_body, end="" if note_body.endswith("\n") else "\n") + else: + brief = (payload.get("brief") or {}) + if brief.get("ok") and brief.get("text"): + print(brief["text"], end="" if str(brief["text"]).endswith("\n") else "\n") + else: + print(note_body, end="" if note_body.endswith("\n") else "\n") + return 1 if not payload.get("ok") else 0 + return 0 if payload.get("ok") else 1 + raise RedmineError(f"Unknown entities subcommand: {ecmd}") + + + if args.command == "tui": + if run_tui_cli is None: + raise RedmineError("TUI unavailable (redmine_ticket_tui.py). Run with python -c or check import.") + return run_tui_cli(args, cache) + + if args.command == "textual-tui": + if run_textual_tui_cli is None: + raise RedmineError("Textual TUI unavailable (redmine_ticket_textual_tui.py).") + return run_textual_tui_cli(args, cache) + + if args.command == "browse": + if run_browse_loop is None: + raise RedmineError("Browse loop helper unavailable (redmine_ticket_browse.py).") + matches = select_cached_issues_for_cli(cache, args) + if not matches: + print("No matching tickets.", file=sys.stderr) + return 0 + + def make_handlers(issue_id: int, issue: dict[str, Any]) -> dict[str, Callable[[], None]]: + def do_note(private: bool = False) -> None: + if add_draft is None or prompt_multiline is None: + raise RedmineError("Draft/note helpers unavailable.") + body = prompt_multiline( + "Internal note body" + (" (PRIVATE)" if private else "") + ) + if not body: + print("cancelled", file=sys.stderr) + return + path = add_draft( + Path(args.cache_dir), + issue_id=int(issue_id), + kind=KIND_INTERNAL_NOTE, + body=body, + private=private, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_INTERNAL_NOTE, + issue_id=int(issue_id), + base_url=args.base_url, + emails_customer=False, + ) + if announce_draft: + announce_draft(path) + else: + print("Queued only — apply later with: draft apply ", file=sys.stderr) + + def do_fields() -> None: + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + print("Field update (name-based). Blank line cancels.") + status = input("status name (e.g. Closed) or empty: ").strip() + assignee = input("assignee name/login or 'unassigned' or empty: ").strip() + notes = input("optional internal notes: ").strip() + # Build a fake namespace for resolver + ns = argparse.Namespace( + field_status_id=None, + status_name=status or None, + assigned_to_id=None, + assignee_name=assignee or None, + priority_id=None, + priority_name=None, + category_id=None, + category_name=None, + due_date=None, + subject=None, + notes=notes or None, + private=False, + refresh_meta=False, + base_url=args.base_url, + project=args.project, + cache_dir=args.cache_dir, + api_key=os.getenv("REDMINE_API_KEY"), + ) + if not any([status, assignee, notes]): + print("cancelled", file=sys.stderr) + return + resolved = resolve_set_fields_names(ns) + fields = build_fields_payload_from_args(ns, resolved_ids=resolved) + if not fields: + print("no fields to set", file=sys.stderr) + return + path = add_draft( + Path(args.cache_dir), + issue_id=int(issue_id), + kind=KIND_FIELDS, + fields=fields, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_FIELDS, + issue_id=int(issue_id), + base_url=args.base_url, + emails_customer=False, + ) + if announce_draft: + announce_draft(path) + + def do_tag() -> None: + if control_tag_add is None: + raise RedmineError("Control-tag helpers unavailable.") + tag = input("local control-tag name: ").strip() + if not tag: + print("cancelled", file=sys.stderr) + return + ann = input("annotation (optional): ").strip() + detail = control_tag_add( + Path(args.cache_dir), + tag=tag, + issue_id=int(issue_id), + properties=["browse"], + annotation=ann, + ) + print(json.dumps(detail, indent=2, sort_keys=True)) + + def do_contact() -> None: + if contact_id_from_issue is None: + raise RedmineError("Contact bridge unavailable.") + cid = contact_id_from_issue(issue) + print(f"issue_id={issue_id}") + print(f"contact_id={cid}") + if cid is not None: + print(f"handoff=contact-show --contact-id {cid}") + print(f"handoff=contact-set --contact-id {cid} --add-tag maybe-customer") + + def do_entities() -> None: + if extract_entities_from_issue is None: + raise RedmineError("Entity helpers unavailable.") + ents = extract_entities_from_issue(issue) + if not ents: + print("(no pattern hits)") + return + for e in ents: + print(f"{e.get('type')}\t{e.get('value')}\t{e.get('confidence')}") + + def do_nmi() -> None: + if extract_entities_from_issue is None or brief_for_issue is None or format_private_note is None: + raise RedmineError("NMI helpers unavailable.") + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + print("Running NMI brief (may take a few seconds)…", file=sys.stderr) + ents = extract_entities_from_issue(issue) + payload = brief_for_issue(issue, ents) + note_body = format_private_note(payload) + print(note_body[:2000]) + if not payload.get("ok"): + print( + f"NMI lookup incomplete ({payload.get('error')}); " + "still queueing private note with attempt log.", + file=sys.stderr, + ) + ans = input("Queue as PRIVATE internal note draft? [Y/n] ").strip().lower() + if ans in {"n", "no"}: + print("cancelled", file=sys.stderr) + return + path = add_draft( + Path(args.cache_dir), + issue_id=int(issue_id), + kind=KIND_INTERNAL_NOTE, + body=note_body, + private=True, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_INTERNAL_NOTE, + issue_id=int(issue_id), + base_url=args.base_url, + emails_customer=False, + ) + if announce_draft: + announce_draft(path) + + def do_id() -> None: + print(issue_id) + + return { + "n": lambda: do_note(False), + "p": lambda: do_note(True), + "f": do_fields, + "t": do_tag, + "c": do_contact, + "e": do_entities, + "m": do_nmi, + "i": do_id, + } + + return int( + run_browse_loop( + matches, + cache_dir=Path(args.cache_dir), + project=args.project, + pager=str(getattr(args, "pager", "auto") or "auto"), + once=bool(getattr(args, "once", False)), + preview=not bool(getattr(args, "no_preview", False)), + action_factory=make_handlers, + ) + ) + + if args.command == "categories": + cmd = args.categories_command + if cmd is None: + raise RedmineError("categories requires subcommand: list") + if cmd == "list": + cats: list[dict[str, Any]] = [] + if args.refresh or load_meta_lists is None: + if fetch_project_categories is None: + raise RedmineError("Category helpers unavailable.") + client = client_from_args(args) + cats = fetch_project_categories(client, args.project) + # best-effort meta write-through + if refresh_meta_cache is not None: + try: + refresh_meta_cache( + client, Path(args.cache_dir), args.project, force=True + ) + except Exception: + pass + else: + lists = load_meta_lists(Path(args.cache_dir), args.project) + cats = list(lists.get("categories") or []) + if not cats and fetch_project_categories is not None: + client = client_from_args(args) + cats = fetch_project_categories(client, args.project) + if args.json: + print(json.dumps(cats, indent=2, sort_keys=True)) + return 0 + print(f"project={args.project} categories={len(cats)}", file=sys.stderr) + for c in cats: + print(f"#{c.get('id')}\t{c.get('name')}") + return 0 + raise RedmineError(f"Unknown categories subcommand: {cmd}") + + if args.command == "ocr-rename": + if ( + extract_text_from_file is None + or suggest_subject_from_text is None + or attachment_is_ocr_candidate is None + ): + raise RedmineError("OCR helpers unavailable (redmine_ticket_ocr.py).") + if download_attachment_bytes is None or download_url_for_attachment is None: + raise RedmineError("Attachment download helpers unavailable.") + if attachment_dir is None or safe_filename is None: + raise RedmineError("Attachment path helpers unavailable.") + + targets: list[dict[str, Any]] = [] + if args.issue_id is not None: + try: + targets = [load_cached_issue(cache, int(args.issue_id))] + except RedmineError: + client = client_from_args(args) + targets = [ + client.get_issue_with_includes( + int(args.issue_id), ("attachments", "journals", "helpdesk") + ) + ] + elif args.where: + targets = select_cached_issues_for_cli(cache, args) + else: + raise RedmineError("ocr-rename requires --issue-id or --where.") + + api_key = os.getenv("REDMINE_API_KEY") + if not api_key: + raise RedmineError("REDMINE_API_KEY required to download attachments for OCR.") + + results: list[dict[str, Any]] = [] + for issue in targets: + iid = int(issue.get("id") or 0) + if not iid: + continue + subj = str(issue.get("subject") or "") + if args.only_generic_subjects and looks_like_generic_scan_subject and not looks_like_generic_scan_subject(subj): + results.append({"issue_id": iid, "skipped": "subject_not_generic", "subject": subj}) + continue + + # ensure attachments present + atts = issue.get("attachments") if isinstance(issue.get("attachments"), list) else [] + if not atts: + client = client_from_args(args) + issue = client.get_issue_with_includes(iid, ("attachments", "journals", "helpdesk")) + try: + cache.write_issue(issue) + except Exception: + pass + atts = issue.get("attachments") if isinstance(issue.get("attachments"), list) else [] + + cand_atts = [a for a in atts if isinstance(a, dict) and attachment_is_ocr_candidate(a)] + if not cand_atts: + results.append({"issue_id": iid, "skipped": "no_ocr_attachments", "subject": subj}) + continue + + out_dir = attachment_dir(Path(args.cache_dir), args.project, iid) + file_results: list[dict[str, str]] = [] + combined_text_parts: list[str] = [] + for att in cand_atts[:5]: + name = safe_filename(str(att.get("filename") or f"att-{att.get('id')}")) + dest = out_dir / name + try: + if not dest.is_file(): + from redmine_ticket_attachments import download_url_candidates + urls = download_url_candidates(att, args.base_url) + data = download_attachment_bytes( + url=urls[0], + api_key=api_key, + fallback_urls=urls[1:], + ) + dest.write_bytes(data) + text, method = extract_text_from_file(dest) + except Exception as exc: + file_results.append( + { + "filename": name, + "method": "error", + "text": "", + "path": str(dest), + "error": str(exc)[:200], + } + ) + continue + file_results.append( + { + "filename": name, + "method": method, + "text": text, + "path": str(dest), + } + ) + if text: + combined_text_parts.append(text) + + combined = "\n\n".join(combined_text_parts) + proposed = suggest_subject_from_text(combined, original_subject=subj) + row: dict[str, Any] = { + "issue_id": iid, + "old_subject": subj, + "proposed_subject": proposed, + "files": [ + { + "filename": f["filename"], + "method": f["method"], + "chars": len(f.get("text") or ""), + "path": f["path"], + } + for f in file_results + ], + "drafts": {}, + } + + if args.queue_subject and proposed and proposed != subj: + if args.apply_subject: + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed( + args.base_url, + allow_prod_writes=allow_prod_writes_enabled(args), + ) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + client.update_issue( + iid, + {"subject": proposed}, + impersonate_user=args.impersonate_user, + ) + row["drafts"]["subject_applied"] = True + + # Keep local cache in sync with the live ticket we just mutated + try: + if refresh_cached_issue: + c = TicketCache(Path(args.cache_dir), args.project) + refresh_cached_issue(c, client, int(iid)) + except Exception: + pass + elif add_draft is not None: + path = add_draft( + Path(args.cache_dir), + issue_id=iid, + kind=KIND_FIELDS, + fields={"subject": proposed}, + project=args.project, + base_url=args.base_url, + ) + row["drafts"]["subject_draft_id"] = path.stem + if not args.json: + print_draft_queued( + path, + kind=KIND_FIELDS, + issue_id=iid, + base_url=args.base_url, + emails_customer=False, + ) + + if args.queue_note and build_ocr_note is not None and add_draft is not None: + note_body = build_ocr_note( + issue_id=iid, + files=file_results, + proposed_subject=proposed, + ) + path = add_draft( + Path(args.cache_dir), + issue_id=iid, + kind=KIND_INTERNAL_NOTE, + body=note_body, + private=True, + project=args.project, + base_url=args.base_url, + ) + row["drafts"]["note_draft_id"] = path.stem + if not args.json: + print_draft_queued( + path, + kind=KIND_INTERNAL_NOTE, + issue_id=iid, + base_url=args.base_url, + emails_customer=False, + ) + + results.append(row) + if not args.json: + print( + f"#{iid}\told={subj!r}\n proposed={proposed!r}\n" + f" files={len(file_results)} drafts={row['drafts']}" + ) + + if args.json: + print(json.dumps(results, indent=2, sort_keys=True)) + else: + print(f"ocr_rename_done tickets={len(results)}", file=sys.stderr) + return 0 + + if args.command == "control-tag": + if control_tag_add is None or control_tag_list is None: + raise RedmineError("Control-tag helpers unavailable (redmine_ticket_control_tags.py).") + cmd = args.control_tag_command + if cmd is None: + raise RedmineError( + "control-tag requires subcommand: add|remove|list|show|for-issue|suggest|wipe" + ) + cache_path = Path(args.cache_dir) + if cmd == "add": + detail = control_tag_add( + cache_path, + tag=args.tag, + issue_id=int(args.issue_id), + properties=list(args.properties or []), + annotation=args.annotation or "", + ) + print(f"tagged #{args.issue_id} with local control-tag {args.tag!r}") + print(json.dumps(detail, indent=2, sort_keys=True)) + return 0 + if cmd == "remove": + if control_tag_remove is None: + raise RedmineError("Control-tag remove helper unavailable.") + ok = control_tag_remove(cache_path, tag=args.tag, issue_id=int(args.issue_id)) + if not ok: + raise RedmineError(f"No local tag {args.tag!r} on #{args.issue_id}") + print(f"removed local control-tag {args.tag!r} from #{args.issue_id}") + return 0 + if cmd == "list": + for row in control_tag_list(cache_path): + print(f"{row['tag']}\tcount={row['ticket_count']}\tids={row['issue_ids'][:20]}") + return 0 + if cmd == "show": + ids = tickets_for_tag(cache_path, args.tag) if tickets_for_tag else [] + for iid in ids: + print(iid) + return 0 + if cmd == "for-issue": + tags = tags_for_issue(cache_path, int(args.issue_id)) if tags_for_issue else [] + print(",".join(tags) if tags else "") + return 0 + if cmd == "suggest": + if filter_by_query is None or control_tag_add is None: + raise RedmineError("Suggest requires query + control-tag helpers.") + where = list(args.where or []) + if not where: + # default: subject includes tag name + where = [f'subject.includes("{args.tag}")'] + issues = list(cache.iter_cached_issues()) + try: + candidates = filter_by_query(issues, expressions=where) + except QueryError as exc: + raise RedmineError(f"Invalid --where for suggest: {exc}") from exc + candidates = select_issues( + candidates, + order="newest-updated-first", + max_tickets=args.max_tickets, + ) + already = set(tickets_for_tag(cache_path, args.tag) if tickets_for_tag else []) + proposed = [i for i in candidates if i.get("id") is not None and int(i["id"]) not in already] + print( + f"suggest tag={args.tag!r} candidates={len(candidates)} " + f"already_tagged={len(already)} new={len(proposed)} apply={bool(args.apply)}" + ) + for issue in proposed: + iid = int(issue["id"]) + subj = str(issue.get("subject") or "").replace("\n", " ")[:80] + print(f"#{iid}\t{subj}") + if args.apply: + control_tag_add( + cache_path, + tag=args.tag, + issue_id=iid, + properties=list(args.properties or ["subject"]), + annotation=f"auto-suggest where={where}", + ) + if args.apply: + print(f"applied_local_tags={len(proposed)}") + return 0 + if cmd == "export": + if export_control_tag_jsonl is None: + raise RedmineError("Export helper unavailable (redmine_ticket_control_export.py).") + from redmine_ticket_control_tags import load_store as _load_store + store = _load_store(cache_path) + issues_by_id = {} + for issue in cache.iter_cached_issues(): + if issue.get("id") is not None: + issues_by_id[int(issue["id"])] = issue + n = export_control_tag_jsonl( + cache_dir=cache_path, + tags_payload=store, + issues_by_id=issues_by_id, + tag=args.tag, + out_path=Path(args.out), + ) + print(f"exported_records={n} path={args.out}") + return 0 + if cmd == "wipe": + if not getattr(args, "i_understand_this_wipes_local_control_tags", False): + raise RedmineError("wipe requires confirmation flag") + path = control_tag_wipe(cache_path) if control_tag_wipe else None + print(f"wiped local control-tag store: {path}") + return 0 + raise RedmineError(f"Unknown control-tag subcommand: {cmd}") + + if args.command == "customer-email": + # Flag is required=True on parser; keep explicit checks for safety. + if not getattr(args, "i_understand_this_emails_the_customer", False): + raise RedmineError( + "customer-email requires --i-understand-this-emails-the-customer. " + "This command is for emailing the customer, not internal notes. " + "Use `note` for staff-only journal notes." + ) + body = resolve_body_text(args) + print("ACTION: CUSTOMER EMAIL (Helpdesk)") + print(" emails_customer: YES — this will email the customer when applied") + print(f" issue_id: {args.issue_id}") + print(" private: NOT ALLOWED on this path") + if not args.apply: + if add_draft is None: + raise RedmineError("Draft helpers unavailable.") + path = add_draft( + Path(args.cache_dir), + issue_id=int(args.issue_id), + kind=KIND_CUSTOMER_EMAIL, + body=body, + private=False, + project=args.project, + base_url=args.base_url, + ) + print_draft_queued( + path, + kind=KIND_CUSTOMER_EMAIL, + issue_id=int(args.issue_id), + base_url=args.base_url, + emails_customer=True, + ) + return 0 + if assert_write_target_allowed is None: + raise RedmineError("Write safety helpers unavailable.") + try: + assert_write_target_allowed(args.base_url, allow_prod_writes=allow_prod_writes_enabled(args)) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + client = client_from_args(args) + result = client.send_customer_helpdesk_email( + int(args.issue_id), + body, + status_id=getattr(args, "status_id", None), + to_address=getattr(args, "to_address", None), + cc_address=getattr(args, "cc_address", None), + impersonate_user=args.impersonate_user, + ) + print(f"sent customer Helpdesk email for #{args.issue_id}") + print(json.dumps(result, indent=2, sort_keys=True) if result else "(empty response)") + return 0 + + if args.command == "draft": + if add_draft is None or list_drafts is None or cancel_draft is None: + raise RedmineError("Draft helpers unavailable (redmine_ticket_drafts.py).") + if args.draft_command is None: + raise RedmineError("draft requires a subcommand: add | list | cancel | apply") + if args.draft_command == "add": + body = resolve_body_text(args) + try: + kind = normalize_kind(args.kind) if normalize_kind else args.kind + except ValueError as exc: + raise RedmineError(str(exc)) from exc + if kind == KIND_CUSTOMER_EMAIL: + print("WARNING: queuing CUSTOMER EMAIL draft (will email when applied).") + else: + print("Queuing INTERNAL NOTE draft (will NOT email when applied).") + try: + path = add_draft( + Path(args.cache_dir), + issue_id=int(args.issue_id), + kind=str(kind), + body=body, + private=bool(args.private), + project=args.project, + base_url=args.base_url, + ) + except ValueError as exc: + raise RedmineError(str(exc)) from exc + print_draft_queued( + path, + kind=str(kind), + issue_id=int(args.issue_id), + base_url=args.base_url, + emails_customer=(str(kind) == KIND_CUSTOMER_EMAIL), + ) + return 0 + if args.draft_command == "list": + status = None if args.all else "pending" + for draft in list_drafts(Path(args.cache_dir), status=status): + preview = (draft.get("body") or "").replace("\n", " ")[:80] + emails = "EMAILS_CUSTOMER" if draft.get("kind") == KIND_CUSTOMER_EMAIL or draft.get("emails_customer") else "no-email" + target = ( + f"contact#{draft.get('contact_id')}" + if draft.get("contact_id") is not None + else f"#{draft.get('issue_id')}" + ) + print( + f"{draft.get('id')}\t{draft.get('status')}\t{target}\t" + f"{draft.get('kind')}\t{emails}\tbase_url={draft.get('base_url') or ''}\t" + f"private={draft.get('private')}\t{preview}" + ) + return 0 + if args.draft_command == "cancel": + if args.all_pending: + n = cancel_all_pending(Path(args.cache_dir)) if cancel_all_pending else 0 + print(f"cancelled_pending={n}") + return 0 + if not args.draft_id: + raise RedmineError("draft cancel requires draft_id or --all-pending") + ok = cancel_draft(Path(args.cache_dir), args.draft_id) + if not ok: + raise RedmineError(f"Draft not found: {args.draft_id}") + print(f"cancelled {args.draft_id}") + return 0 + if args.draft_command == "apply": + if load_draft is None or mark_draft_applied is None or assert_write_target_allowed is None: + raise RedmineError("Draft apply helpers unavailable.") + try: + _path, draft = load_draft(Path(args.cache_dir), args.draft_id) + except FileNotFoundError as exc: + raise RedmineError(str(exc)) from exc + if draft.get("status") != "pending": + raise RedmineError(f"Draft {draft.get('id')} is not pending (status={draft.get('status')})") + kind = normalize_kind(str(draft.get("kind"))) if normalize_kind else str(draft.get("kind")) + apply_base = resolve_apply_base_url(args, draft) + print(f"Applying draft {draft.get('id')}") + print(f" kind: {kind_label(kind) if kind_label else kind}") + if draft.get("issue_id") is not None: + print(f" issue_id: {draft.get('issue_id')}") + if draft.get("contact_id") is not None: + print(f" contact_id: {draft.get('contact_id')}") + print(f" base_url: {apply_base}") + print(f" emails_customer: {kind == KIND_CUSTOMER_EMAIL}") + try: + assert_write_target_allowed( + apply_base, + allow_prod_writes=allow_prod_writes_enabled(args), + ) + except RuntimeError as exc: + raise RedmineError(str(exc)) from exc + # Point client at draft host + args.base_url = apply_base + client = client_from_args(args) + try: + result = apply_draft_to_redmine( + client, + draft, + impersonate_user=args.impersonate_user, + allow_customer_email=bool(args.i_understand_this_emails_the_customer), + ) + mark_draft_applied(Path(args.cache_dir), str(draft["id"]), result=result) + + # Refresh local cache immediately so browse / tui / listings see the live state + # (status, journals, updated_on, etc.). This matches "update existing cache files on material change". + try: + issue_id = draft.get("issue_id") + if issue_id and refresh_cached_issue: + c = TicketCache(Path(args.cache_dir), args.project) + refresh_cached_issue(c, client, int(issue_id)) + except Exception: + pass + + except Exception as exc: + if mark_draft_error is not None: + mark_draft_error(Path(args.cache_dir), str(draft["id"]), str(exc)) + raise + print(f"applied draft {draft.get('id')}: {json.dumps(result, sort_keys=True)}") + return 0 + raise RedmineError(f"Unknown draft subcommand: {args.draft_command}") + + if args.command == "spam-check": + results = run_spam_check(cache, apply=args.apply, threshold=args.threshold) + print_spam_results(results) + print(f"Spam check complete: checked={len(results)} apply={args.apply}") + if not args.apply: + print("Dry run only. Re-run with --apply to record spam-check idempotency markers.") + return 0 + + if args.command == "close-if-spam": + client = client_from_args(args) + plans = run_close_if_spam( + client, + cache, + close_status_id=args.close_status_id, + contact_ticket_limit=args.contact_ticket_limit, + apply=args.apply, + threshold=args.threshold, + impersonate_user=args.impersonate_user, + ) + print_close_if_spam_plans(plans) + print(f"Close-if-spam complete: planned={len(plans)} apply={args.apply}") + if not args.apply: + print("Dry run only. Re-run with --apply to close Redmine issues and record idempotency markers.") + return 0 + + if args.command == "tag-maybe-customer": + client = client_from_args(args) + results = run_tag_maybe_customer_contacts( + client, + cache, + apply=args.apply, + tag_name=args.tag, + impersonate_user=args.impersonate_user, + ) + for result in results: + print( + f"contact_id={result['contact_id']}\t" + f"source_issue_id={result['source_issue_id']}\t" + f"tag_list={json.dumps(result['tag_list'])}" + ) + print(f"Tag-maybe-customer complete: planned={len(results)} apply={args.apply}") + if not args.apply: + print("Dry run only. Re-run with --apply to tag Redmine contacts.") + return 0 + + if args.command == "decompose": + issue = load_issue_for_decompose(args, cache) + plan = decompose_issue_dry_run(issue) + if args.format == "markdown": + print(render_decomposition_markdown(plan)) + else: + print(json.dumps(plan, indent=2, sort_keys=True)) + return 0 + return 1 + + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/configs/dev.dist.json b/configs/dev.dist.json new file mode 100644 index 0000000..ed3afd8 --- /dev/null +++ b/configs/dev.dist.json @@ -0,0 +1,5 @@ +{ + "base-url": "http://192.168.50.170", + "project": "customer-service", + "cache-dir": "/tmp/redmine/cache-dev/redmine_tickets" +} diff --git a/docs/ticket triage open questions.md b/docs/ticket triage open questions.md new file mode 100644 index 0000000..f6ff662 --- /dev/null +++ b/docs/ticket triage open questions.md @@ -0,0 +1,3 @@ + + - [ ] No good way to link voicemail/call transcriptins to the contact_id of the called (the tickets are from the voip.ms service) +