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) <noreply@anthropic.com>

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()