87340d09edb5b6ce0fe39d99f574b62d70dc8c0e braney Wed Apr 15 12:25:35 2026 -0700 Add 'note' subcommand to redmineCli to fetch a specific note from a ticket, refs #20460 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git src/utils/redmineCli src/utils/redmineCli index 1dfa438819c..3ff4803237b 100755 --- src/utils/redmineCli +++ src/utils/redmineCli @@ -1,29 +1,30 @@ #!/usr/bin/env python3 """Comprehensive Redmine CLI for the UCSC Genome Browser team. Reads the Redmine API key from ~/.hg.conf (redmine.apiKey=...). Usage: redmineCli show 33571 redmineCli list --project maillists --status open --limit 10 redmineCli create --subject "Bug report" --description "Details here" redmineCli comment 33571 --message "Adding a note" redmineCli update 33571 --status 1 --assigned-to lou --note "Reopening" redmineCli update 33571 --release-log-url "../cgi-bin/hgTrackUi?db=hg38&g=myTrack" redmineCli update 33571 --custom-field 46="some value" redmineCli attach 33571 screenshot.png --note "See attached" + redmineCli note 20460 85 # show note-85 from ticket 20460 redmineCli relate 10316 15336 30368 # relate tickets to each other redmineCli watch 37339 lou braney # add watchers to a ticket redmineCli users # list user names and IDs """ import argparse import json import mimetypes import os import re import sys import tempfile import urllib.error import urllib.parse import urllib.request @@ -786,30 +787,74 @@ data = {"user_id": user_id} try: api_post(args.base_url, f"/issues/{ticket_id}/watchers.json", args.api_key, data) print(f" Added watcher {name} (user {user_id}) to #{ticket_id}") except SystemExit as e: if "422" in str(e): print(f" {name} is already watching #{ticket_id}") else: raise print(f"Done: {make_url(args.base_url, ticket_id)}") +# --------------------------------------------------------------------------- +# Subcommand: note +# --------------------------------------------------------------------------- + +def cmd_note(args): + """Display a specific note from a ticket.""" + data = api_get(args.base_url, + f"/issues/{args.ticket_id}.json?include=journals", + args.api_key) + issue = data["issue"] + journals = issue.get("journals", []) + + # Redmine API may return journals newest-first; sort by created_on + # to match the web UI's #note-N numbering (note-1 = oldest). + journals.sort(key=lambda j: j["created_on"]) + + note_num = args.note_number + if note_num < 1 or note_num > len(journals): + sys.exit(f"Error: note-{note_num} does not exist. " + f"Ticket #{args.ticket_id} has {len(journals)} journal entries.") + + j = journals[note_num - 1] + user = j["user"]["name"] + date = format_date(j["created_on"]) + notes = j.get("notes", "") + details = j.get("details", []) + + out = [] + out.append(f"# #{issue['id']} note-{note_num}: {user} — {date}") + out.append(f"URL: {make_url(args.base_url, issue['id'])}#note-{note_num}") + out.append("") + if details: + detail_text = format_details(details) + if detail_text: + out.append(detail_text) + out.append("") + if notes: + out.append(redmine_textile_to_md(notes)) + if not notes and not details: + out.append("(empty journal entry)") + + print("\n".join(out)) + + # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- def build_parser(): parser = argparse.ArgumentParser( prog="redmineCli", description="Redmine CLI for the UCSC Genome Browser team") parser.add_argument("--redmine", default=DEFAULT_REDMINE, help="Redmine base URL (default: %(default)s)") parser.add_argument("--conf", default="~/.hg.conf", help="Config file with redmine.apiKey (default: %(default)s)") sub = parser.add_subparsers(dest="command", required=True) @@ -910,45 +955,51 @@ help="Project identifier (default: %(default)s)") # relate p_relate = sub.add_parser("relate", help="Create relations between tickets") p_relate.add_argument("ticket_ids", nargs="+", help="Two or more ticket IDs to relate") p_relate.add_argument("--type", default="relates", help="Relation type: relates, duplicates, duplicated, blocks, " "blocked, precedes, follows, copied_to, copied_from " "(default: %(default)s)") # watch p_watch = sub.add_parser("watch", help="Add watchers to a ticket") p_watch.add_argument("ticket_id", help="Ticket ID number") p_watch.add_argument("users", nargs="+", help="User names or IDs to add as watchers") + # note + p_note = sub.add_parser("note", help="Display a specific note from a ticket") + p_note.add_argument("ticket_id", help="Ticket ID number") + p_note.add_argument("note_number", type=int, help="Note number (as shown in Redmine URL #note-N)") + return parser # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = build_parser() args = parser.parse_args() args.api_key = read_api_key(args.conf) args.base_url = args.redmine commands = { "show": cmd_show, "list": cmd_list, "create": cmd_create, "comment": cmd_comment, "update": cmd_update, "attach": cmd_attach, "users": cmd_users, "relate": cmd_relate, "watch": cmd_watch, + "note": cmd_note, } commands[args.command](args) if __name__ == "__main__": main()