Files
2026-10-18 10:45:14 -04:00

199 lines
6.6 KiB
Python

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