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 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'(? *text* + text = re.sub(r'(? `text` + text = re.sub(r'@([^@\n]+)@', r'`\1`', text) + # Code blocks:
...
-> ```...``` + text = re.sub(r'
\s*', '\n```\n', text)
+    text = re.sub(r'\s*
', '\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"", 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()