eff4c5c82d82b41a6dc377aa4c48b65c1a4ce135
lrnassar
  Mon Apr 13 18:30:27 2026 -0700
Add track ticket custom field support to redmineCli update subcommand. refs #37339

New named options: --release-log-text, --release-log-url, --released-to-rr,
--file-list, --table-list, --assemblies. Also adds generic --custom-field ID=VALUE
for arbitrary custom fields.

diff --git src/utils/redmineCli src/utils/redmineCli
index 909628e2c0b..1dfa438819c 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1,26 +1,28 @@
 #!/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 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
@@ -91,30 +93,38 @@
     "hibernating": 32,
     "preview1": 17,
     "preview2": 18,
     "final build": 19,
     "rejected": 6,
     "verified": 23,
     "released": 12,
     "closed": 5,
     "reopened": 31,
 }
 
 CF_CATEGORY = 28
 CF_EMAIL = 40
 CF_MLM = 9
 
+# Track ticket custom fields
+CF_RELEASE_LOG_TEXT = 48
+CF_RELEASE_LOG_URL = 46
+CF_RELEASED_TO_RR = 47
+CF_FILE_LIST = 43
+CF_TABLE_LIST = 44
+CF_ASSEMBLIES = 2
+
 
 # Name -> Redmine user ID mapping (short names and full names)
 USER_IDS = {
     "ana": 174, "ana benet": 174,
     "angie": 34, "angie hinrichs": 34,
     "ann": 3, "ann zweig": 3,
     "bob": 45, "bob kuhn": 45,
     "blee": 122, "brian lee": 122,
     "braney": 31, "brian raney": 31,
     "build": 197, "build meister": 197,
     "cath": 155, "cath tyner": 155,
     "charlie": 186, "charlie vaske": 186,
     "chin": 25, "chin li": 25,
     "chris": 152, "chris eisenhart": 152,
     "christopher": 156, "christopher lee": 156,
@@ -592,41 +602,62 @@
     if args.assigned_to is not None:
         if args.assigned_to == "":
             issue_data["assigned_to_id"] = ""
         else:
             issue_data["assigned_to_id"] = resolve_user(args.assigned_to)
     if args.priority is not None:
         issue_data["priority_id"] = args.priority
     if args.subject is not None:
         issue_data["subject"] = strip_emoji(args.subject)
 
     custom_fields = []
     if args.category is not None:
         custom_fields.append({"id": CF_CATEGORY, "value": args.category})
     if args.mlm is not None:
         custom_fields.append({"id": CF_MLM, "value": args.mlm})
+    if args.release_log_text is not None:
+        custom_fields.append({"id": CF_RELEASE_LOG_TEXT, "value": args.release_log_text})
+    if args.release_log_url is not None:
+        custom_fields.append({"id": CF_RELEASE_LOG_URL, "value": args.release_log_url})
+    if args.released_to_rr is not None:
+        custom_fields.append({"id": CF_RELEASED_TO_RR, "value": args.released_to_rr})
+    if args.file_list is not None:
+        custom_fields.append({"id": CF_FILE_LIST, "value": args.file_list})
+    if args.table_list is not None:
+        custom_fields.append({"id": CF_TABLE_LIST, "value": args.table_list})
+    if args.assemblies is not None:
+        custom_fields.append({"id": CF_ASSEMBLIES, "value": args.assemblies})
+    for cf_spec in (args.custom_field or []):
+        if "=" not in cf_spec:
+            sys.exit(f"Error: --custom-field must be ID=VALUE, got: {cf_spec}")
+        cf_id, cf_val = cf_spec.split("=", 1)
+        if not cf_id.isdigit():
+            sys.exit(f"Error: custom field ID must be numeric, got: {cf_id}")
+        custom_fields.append({"id": int(cf_id), "value": cf_val})
     if custom_fields:
         issue_data["custom_fields"] = custom_fields
 
     note = read_text_input(args.note, args.note_file)
     if note:
         issue_data["notes"] = strip_emoji(prepend_attribution(note))
 
     if not issue_data:
         sys.exit("Error: no fields to update. Provide at least one of: "
                  "--status, --assigned-to, --priority, --subject, "
-                 "--category, --mlm, --note")
+                 "--category, --mlm, --release-log-text, --release-log-url, "
+                 "--released-to-rr, --file-list, --table-list, --assemblies, "
+                 "--custom-field, --note")
 
     data = {"issue": issue_data}
     api_put(args.base_url, f"/issues/{args.ticket_id}.json", args.api_key, data)
     print(f"Updated #{args.ticket_id}: {make_url(args.base_url, args.ticket_id)}")
 
 
 # ---------------------------------------------------------------------------
 # Subcommand: attach
 # ---------------------------------------------------------------------------
 
 def cmd_attach(args):
     """Upload an attachment to a ticket."""
     filepath = args.file
     if not os.path.isfile(filepath):
         sys.exit(f"Error: file not found: {filepath}")
@@ -834,30 +865,45 @@
     p_comment.add_argument("ticket_id", help="Ticket ID number")
     p_comment.add_argument("--message", help="Comment text")
     p_comment.add_argument("--message-file", dest="message_file",
                            help="Read comment from file (- for stdin)")
 
     # update
     p_update = sub.add_parser("update", help="Update ticket fields")
     p_update.add_argument("ticket_id", help="Ticket ID number")
     p_update.add_argument("--status", help="New status name or ID (e.g. 'QA Ready' or 10)")
     p_update.add_argument("--assigned-to", dest="assigned_to",
                           help="Assignee name/ID (empty string to clear)")
     p_update.add_argument("--priority", type=int, help="New priority ID")
     p_update.add_argument("--subject", help="New subject")
     p_update.add_argument("--category", help="MLQ Category")
     p_update.add_argument("--mlm", help="MLM name")
+    p_update.add_argument("--release-log-text", dest="release_log_text",
+                          help="Release Log Text (custom field)")
+    p_update.add_argument("--release-log-url", dest="release_log_url",
+                          help="Release Log URL (custom field)")
+    p_update.add_argument("--released-to-rr", dest="released_to_rr",
+                          help="Released to RR (custom field, 0 or 1)")
+    p_update.add_argument("--file-list", dest="file_list",
+                          help="File List (custom field)")
+    p_update.add_argument("--table-list", dest="table_list",
+                          help="Table List (custom field)")
+    p_update.add_argument("--assemblies",
+                          help="Assemblies (custom field)")
+    p_update.add_argument("--custom-field", dest="custom_field",
+                          action="append", metavar="ID=VALUE",
+                          help="Set arbitrary custom field by ID (repeatable)")
     p_update.add_argument("--note", help="Comment to include with update")
     p_update.add_argument("--note-file", dest="note_file",
                           help="Read note from file (- for stdin)")
 
     # attach
     p_attach = sub.add_parser("attach", help="Upload an attachment")
     p_attach.add_argument("ticket_id", help="Ticket ID number")
     p_attach.add_argument("file", help="File path to upload")
     p_attach.add_argument("--filename", help="Override filename")
     p_attach.add_argument("--description", help="Attachment description")
     p_attach.add_argument("--note", help="Comment to add with attachment")
 
     # users
     p_users = sub.add_parser("users", help="List project members and their user IDs")
     p_users.add_argument("--project", default=DEFAULT_PROJECT,