#!/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