eef33606ff233b6ce9afd612e89fe80b99744312
max
  Tue Mar 10 05:49:20 2026 -0700
Add redmineDump script to dump Redmine tickets as Markdown, refs #36890

Python script that reads redmine.apiKey from ~/.hg.conf, fetches a
ticket with all journals/attachments via the Redmine API, and outputs
the full discussion in Markdown. Supports --images to download
attachments to a temp directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

diff --git src/utils/redmineDump src/utils/redmineDump
new file mode 100755
index 00000000000..bac187d381d
--- /dev/null
+++ src/utils/redmineDump
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+"""Dump a Redmine ticket discussion to stdout in Markdown format.
+
+Reads the Redmine API key from ~/.hg.conf (redmine.apiKey=...).
+
+Usage:
+    redmineDump 33571
+    redmineDump --images 33571
+    redmineDump --url https://redmine.gi.ucsc.edu/issues/33571
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+import tempfile
+import urllib.request
+from datetime import datetime
+
+DEFAULT_REDMINE = "https://redmine.gi.ucsc.edu"
+
+
+def read_api_key(conf_path="~/.hg.conf"):
+    """Read redmine.apiKey from ~/.hg.conf."""
+    conf_path = os.path.expanduser(conf_path)
+    with open(conf_path) as f:
+        for line in f:
+            line = line.strip()
+            if line.startswith("redmine.apiKey="):
+                return line.split("=", 1)[1]
+    sys.exit("Error: redmine.apiKey not found in " + conf_path)
+
+
+def api_get(base_url, path, api_key):
+    """GET a JSON endpoint from Redmine."""
+    url = base_url.rstrip("/") + path
+    req = urllib.request.Request(url)
+    req.add_header("X-Redmine-API-Key", api_key)
+    req.add_header("Accept", "application/json")
+    with urllib.request.urlopen(req) as resp:
+        return json.loads(resp.read())
+
+
+def download_file(url, dest_path, api_key):
+    """Download a file with API key auth."""
+    req = urllib.request.Request(url)
+    req.add_header("X-Redmine-API-Key", api_key)
+    with urllib.request.urlopen(req) as resp:
+        with open(dest_path, "wb") as f:
+            f.write(resp.read())
+
+
+def format_date(iso_str):
+    """Format an ISO date string nicely."""
+    dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
+    return dt.strftime("%Y-%m-%d %H:%M UTC")
+
+
+def redmine_textile_to_md(text):
+    """Convert common Redmine textile/wiki markup to Markdown."""
+    if not text:
+        return ""
+    # Bold: *text* -> **text** (but not bullet lists)
+    text = re.sub(r'(?<!\w)\*(\S.*?\S)\*(?!\w)', r'**\1**', text)
+    # Italic: _text_ -> *text*
+    text = re.sub(r'(?<!\w)_(\S.*?\S)_(?!\w)', r'*\1*', text)
+    # Inline code: @text@ -> `text`
+    text = re.sub(r'@([^@\n]+)@', r'`\1`', text)
+    # Code blocks: <pre>...</pre> -> ```...```
+    text = re.sub(r'<pre>\s*', '\n```\n', text)
+    text = re.sub(r'\s*</pre>', '\n```\n', text)
+    # Headings: h1. -> #, h2. -> ##, etc.
+    for i in range(1, 7):
+        text = re.sub(rf'^h{i}\.\s*', '#' * i + ' ', text, flags=re.MULTILINE)
+    # Redmine image references: !image.png! or !filename!
+    text = re.sub(r'!([^!\n]+\.(png|jpg|jpeg|gif))!', r'![image](\1)', text, flags=re.IGNORECASE)
+    # Links: "text":url -> [text](url)
+    text = re.sub(r'"([^"]+)":(\S+)', r'[\1](\2)', text)
+    # Issue references: #1234 -> link (leave as-is, just ensure they're visible)
+    return text
+
+
+def format_details(details):
+    """Format journal detail changes (status changes, assignments, etc.)."""
+    lines = []
+    for d in details:
+        prop = d.get("property", "")
+        name = d.get("name", "")
+        old = d.get("old_value", "")
+        new = d.get("new_value", "")
+        if prop == "attr":
+            if name == "status_id":
+                lines.append(f"  - Status changed: {old} -> {new}")
+            elif name == "assigned_to_id":
+                lines.append(f"  - Assignee changed: {old} -> {new}")
+            elif name == "done_ratio":
+                lines.append(f"  - Progress: {old}% -> {new}%")
+            else:
+                lines.append(f"  - {name}: {old} -> {new}")
+        elif prop == "attachment":
+            lines.append(f"  - Attached: {new}")
+    return "\n".join(lines)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Dump a Redmine ticket to Markdown")
+    parser.add_argument("ticket", nargs="?", help="Ticket ID number")
+    parser.add_argument("--url", help="Full Redmine issue URL (extracts ID and base URL)")
+    parser.add_argument("--redmine", default=DEFAULT_REDMINE, help="Redmine base URL (default: %(default)s)")
+    parser.add_argument("--images", action="store_true", help="Download images to a temp directory")
+    parser.add_argument("--conf", default="~/.hg.conf", help="Path to config file with redmine.apiKey")
+    args = parser.parse_args()
+
+    # Parse URL if given
+    if args.url:
+        m = re.match(r'(https?://[^/]+)/issues/(\d+)', args.url)
+        if not m:
+            sys.exit("Error: cannot parse URL: " + args.url)
+        args.redmine = m.group(1)
+        args.ticket = m.group(2)
+
+    if not args.ticket:
+        parser.print_help()
+        sys.exit(1)
+
+    ticket_id = str(args.ticket)
+    api_key = read_api_key(args.conf)
+    base_url = args.redmine
+
+    # Fetch ticket with journals and attachments
+    data = api_get(base_url, f"/issues/{ticket_id}.json?include=journals,attachments", api_key)
+    issue = data["issue"]
+
+    # Build attachment lookup: id -> attachment info
+    attachments = {a["id"]: a for a in issue.get("attachments", [])}
+    # Also build filename -> URL lookup for inline image references
+    attach_by_name = {a["filename"]: a for a in issue.get("attachments", [])}
+
+    # Setup image download directory
+    img_dir = None
+    if args.images:
+        img_dir = tempfile.mkdtemp(prefix=f"redmine_{ticket_id}_")
+        print(f"<!-- Images downloaded to: {img_dir} -->", file=sys.stderr)
+
+    def resolve_images(text):
+        """Replace image filenames with local paths if --images, or full URLs."""
+        if not text:
+            return text
+        def replace_img(m):
+            fname = m.group(1)
+            ext = m.group(2)
+            if fname in attach_by_name:
+                a = attach_by_name[fname]
+                if img_dir:
+                    local = os.path.join(img_dir, fname)
+                    if not os.path.exists(local):
+                        download_file(a["content_url"], local, api_key)
+                        print(f"  Downloaded: {local}", file=sys.stderr)
+                    return f"![image]({local})"
+                else:
+                    return f"![image]({a['content_url']})"
+            return m.group(0)
+        return re.sub(r'!\[image\]\(([^)]+\.(png|jpg|jpeg|gif))\)', replace_img, text, flags=re.IGNORECASE)
+
+    # Print header
+    out = []
+    out.append(f"# #{issue['id']}: {issue['subject']}")
+    out.append("")
+    out.append(f"- **Project:** {issue['project']['name']}")
+    out.append(f"- **Tracker:** {issue['tracker']['name']}")
+    out.append(f"- **Status:** {issue['status']['name']}")
+    out.append(f"- **Priority:** {issue['priority']['name']}")
+    out.append(f"- **Author:** {issue['author']['name']}")
+    if issue.get("assigned_to"):
+        out.append(f"- **Assigned to:** {issue['assigned_to']['name']}")
+    out.append(f"- **Created:** {format_date(issue['created_on'])}")
+    out.append(f"- **Updated:** {format_date(issue['updated_on'])}")
+    if issue.get("closed_on"):
+        out.append(f"- **Closed:** {format_date(issue['closed_on'])}")
+    out.append(f"- **URL:** {base_url}/issues/{ticket_id}")
+    out.append("")
+
+    # Attachments summary
+    if attachments:
+        out.append("## Attachments")
+        out.append("")
+        for a in issue["attachments"]:
+            out.append(f"- [{a['filename']}]({a['content_url']}) ({a['filesize']} bytes, {a['author']['name']}, {format_date(a['created_on'])})")
+        out.append("")
+
+    # Description
+    out.append("## Description")
+    out.append("")
+    desc = redmine_textile_to_md(issue.get("description", ""))
+    desc = resolve_images(desc)
+    out.append(desc)
+    out.append("")
+
+    # Journals (discussion)
+    journals = issue.get("journals", [])
+    if journals:
+        out.append("---")
+        out.append("## Discussion")
+        out.append("")
+
+    for j in journals:
+        notes = j.get("notes", "")
+        details = j.get("details", [])
+        # Skip empty journals (no notes and no interesting details)
+        if not notes and not details:
+            continue
+
+        user = j["user"]["name"]
+        date = format_date(j["created_on"])
+        out.append(f"### {user} — {date}")
+        out.append("")
+
+        if details:
+            detail_text = format_details(details)
+            if detail_text:
+                out.append(detail_text)
+                out.append("")
+
+        if notes:
+            md_notes = redmine_textile_to_md(notes)
+            md_notes = resolve_images(md_notes)
+            out.append(md_notes)
+            out.append("")
+
+        out.append("---")
+        out.append("")
+
+    # Download any remaining images not referenced inline
+    if img_dir:
+        for a in issue["attachments"]:
+            if a["content_type"] and a["content_type"].startswith("image/"):
+                local = os.path.join(img_dir, a["filename"])
+                if not os.path.exists(local):
+                    download_file(a["content_url"], local, api_key)
+                    print(f"  Downloaded: {local}", file=sys.stderr)
+
+    print("\n".join(out))
+
+
+if __name__ == "__main__":
+    main()