6570715c5cd53f3c7a71460a6806e13ade21909d lrnassar Mon Mar 16 09:07:31 2026 -0700 Removing redmineDump script as it was replaced by redmineCli, refs conversation with Max. diff --git src/utils/redmineDump src/utils/redmineDump deleted file mode 100755 index bac187d381d..00000000000 --- src/utils/redmineDump +++ /dev/null @@ -1,247 +0,0 @@ -#!/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'', 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"" - else: - return f"" - 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()